Skip to content
MacScout

Writing an action pack

Build a Swift Package menu as a new action pack, register it, and test it.

A custom action runs a command. A pack is Swift code inside MacScout, so it can do more:

  • build its menu from the project, the way the Scripts pack lists the scripts in package.json,
  • open its own windows and dialogs, like Batch Rename…,
  • do its work in Swift, with Apple's frameworks, instead of in a shell.

Packs are built into the app

There is no plugin folder. A pack is part of MacScout's source code, and you build the app yourself to use it. You need the MacScout source and Xcode 26 or later.

How packs work

Each time the selection changes or a menu opens, MacScout asks every pack for its items and passes along the current selection. The ⚡ Actions menu in the toolbar is rebuilt on every selection change. A pack that has nothing to offer returns an empty list, and its submenu doesn't appear. MacScout does the same when you press a key, to find actions with that shortcut.

So the method that builds the items runs very often, on the main thread. Checking whether a file exists or reading a small file is fine there. Anything slower belongs in the action itself, which runs only when the user picks it.

Example: a Swift Package menu

This pack adds a Swift Package submenu to folders that contain a Package.swift:

  • Build and Test run swift build and swift test in the terminal, where you can watch the output.
  • Resolve Packages runs swift package resolve in the background and reports when it's done.

Create the pack

Packs live in Sources/MacScout/Actions/Packs/. Add a file named SwiftPackagePack.swift:

Sources/MacScout/Actions/Packs/SwiftPackagePack.swift
import Foundation

struct SwiftPackagePack: ActionPack {
    let id = "swiftpm"
    let title = "Swift Package"
    let icon = "swift"
    let summary = "Build, test and resolve Swift packages"

    func nodes(for context: ActionContext) -> [ActionNode] {
        guard let folder = context.workingFolder,
              FileManager.default.fileExists(atPath: folder.appendingPathComponent("Package.swift").path)
        else { return [] }

        return [
            .item("swiftpm.build", "Build", icon: "hammer") { _ in
                ActionRunner.runInTerminal("swift build", in: folder)
            },
            .item("swiftpm.test", "Test", icon: "checkmark.diamond") { _ in
                ActionRunner.runInTerminal("swift test", in: folder)
            },
            .separator(),
            .item("swiftpm.resolve", "Resolve Packages", icon: "arrow.triangle.2.circlepath") { context in
                ActionCenter.shared.run("Resolve Packages", refresh: context.pane) {
                    let result = await ActionRunner.run("/usr/bin/swift", ["package", "resolve"], in: folder)
                    guard result.succeeded else { throw ActionError.failed(result.output) }
                    return .done
                }
            },
        ]
    }
}

Register it

Add the pack to ActionRegistry.packs in Sources/MacScout/Actions/ActionModel.swift. Submenus appear in the order of this list, so put it where it fits best:

Sources/MacScout/Actions/ActionModel.swift
static let packs: [ActionPack] = [
    AppsPack(), ScriptsPack(), SwiftPackagePack(), GitPack(), JSONPack(), OrganizePack(), ArchivePack(),
    ImagesPack(), AIPack(), HashPack(), LockedFilesPack(), PathPack(),
]

Build and try it

scripts/build-app.sh debug

The script builds the app into the build folder. Start it from there, right-click a folder that contains a Package.swift, and choose Swift Package → Build.

What the code does

The four properties describe the pack. id is its key in the packs setting, so "swiftpm": false in the config file turns it off. title and icon are the name and the SF Symbol of the submenu. Settings → Actions → Action Packs lists the pack with its title, icon and summary, next to a switch.

nodes(for:) returns the menu items for a selection. context.workingFolder is the selected folder, or the folder that contains the selection, or the open folder when nothing is selected. Without a Package.swift there, the pack returns an empty list and stays out of the menu.

.item creates a menu item from an ID, a title, an optional icon and a closure that runs when the user picks it. IDs have to be unique across all packs, so start them with the pack's id. The closure gets the context again, which is handy for context.pane.

ActionRunner.runInTerminal opens the user's terminal in a folder and runs a command there. MacScout doesn't wait for it to finish. The App Store build copies the command for the user to paste instead.

ActionCenter.shared.run runs work in the background. While it runs, a progress indicator with the title shows in the corner of the window. .done shows Resolve Packages — done when it's over. A thrown ActionError.failed shows its message the same way a failing custom action does: short messages in the corner, long ones in a window. refresh: context.pane reloads the folder afterwards, because swift package resolve can create a Package.resolved.

Showing items in the main menu

Packs appear as a submenu. To put the items directly into the context menu, like Open In does, add:

var isInline: Bool { true }

Use it sparingly. Every inline item makes the menu longer for everyone, whatever they selected.

Testing a pack

The test target uses Swift Testing and has a TempDirectory helper that creates a folder for the test and deletes it afterwards. This test checks that the menu appears only in Swift packages:

Tests/MacScoutTests/SwiftPackagePackTests.swift
import Foundation
import Testing
@testable import MacScout

@Suite @MainActor struct SwiftPackagePackTests {
    @Test func appearsOnlyInSwiftPackages() throws {
        let temp = try TempDirectory()
        let pack = SwiftPackagePack()
        #expect(pack.nodes(for: ActionContext(items: [], folder: temp.url)).isEmpty)

        try temp.file("Package.swift", contents: "// swift-tools-version: 6.0")
        let titles = pack.nodes(for: ActionContext(items: [], folder: temp.url)).flatMap(\.actions).map(\.title)
        #expect(titles == ["Build", "Test", "Resolve Packages"])
    }
}

Run the tests with swift test. TempDirectory also turns off dialogs: ErrorPresenter.confirm answers yes on its own instead of waiting for a click.

Guidelines

  • Stay out of the way. Return an empty list when the pack has nothing useful for the selection.
  • Keep nodes(for:) fast. Check files and read small ones. Do the real work in the action.
  • Ask before you destroy. Call ErrorPresenter.confirm before deleting or overwriting anything.
  • Never overwrite by accident. FileOperations.uniqueURL finds a free name, like Report 2.pdf.
  • Make it undoable. Register the files an action creates or moves with FileOperations.shared, so ⌘Z works.
  • Write titles like macOS does. Use title case, and end a title with "…" when the item opens a window that asks for more input, like Batch Rename….

On this page