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 buildandswift testin the terminal, where you can watch the output. - Resolve Packages runs
swift package resolvein the background and reports when it's done.
Create the pack
Packs live in Sources/MacScout/Actions/Packs/. Add a file named 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:
static let packs: [ActionPack] = [
AppsPack(), ScriptsPack(), SwiftPackagePack(), GitPack(), JSONPack(), OrganizePack(), ArchivePack(),
ImagesPack(), AIPack(), HashPack(), LockedFilesPack(), PathPack(),
]Build and try it
scripts/build-app.sh debugThe 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:
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.confirmbefore deleting or overwriting anything. - Never overwrite by accident.
FileOperations.uniqueURLfinds a free name, likeReport 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….