Swift Package Manager: Build Plugins, Command Plugins, and Traits
You have added packages to your Xcode projects hundreds of times, but SPM can do far more than resolve dependency
graphs. Build tool plugins run arbitrary code generation during every build, command plugins give your team custom Xcode
menu items, and SPM Traits let you conditionally expose API surfaces without #if flags scattered across your codebase.
This post covers the three plugin types (build tool, command, and prebuild), artifact bundles, and the newer SPM Traits system introduced with SE-0450. We will not cover basic package consumption or dependency resolution — those are handled in Using Swift Packages.
Contents
- The Problem
- Build Tool Plugins
- Command Plugins
- Artifact Bundles
- SPM Traits for Conditional API
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
The Problem
Imagine your team maintains a modular app split across a dozen Swift packages. Every package that defines a data model
also needs generated Equatable conformances, mock factories for testing, and maybe some Protobuf-to-Swift codegen. The
typical approach looks like this:
// Package.swift -- the "run a script" workaround
let package = Package(
name: "PixarAssetCatalog",
targets: [
.target(
name: "PixarAssetCatalog",
dependencies: [],
path: "Sources/PixarAssetCatalog"
),
]
)
// Then somewhere in your CI pipeline or a Makefile:
// $ swift run codegen --input Models/ --output Generated/
// $ swift run protoc --swift_out=Sources/ schema.proto
This workflow has real problems. The generated files drift out of sync with the source models. New team members forget to run the script. CI has a separate step that duplicates logic from local development. And none of it is checked by Xcode’s build system — you only discover a mismatch at link time or, worse, at runtime.
SPM plugins solve this by integrating code generation and developer tooling directly into the build graph.
Build Tool Plugins
A build tool plugin runs during every build. The Swift build system invokes it, passes the target’s source files, and expects a list of build commands in return. The generated files feed into the normal compilation pipeline, so they are always up to date.
Defining the Plugin Target
Build tool plugins live inside a Plugins/ directory in your package. You declare them in Package.swift alongside
your regular targets:
// Package.swift
let package = Package(
name: "PixarAssetCatalog",
targets: [
.target(
name: "PixarAssetCatalog",
dependencies: [],
plugins: [.plugin(name: "AssetCodegen")]
),
.plugin(
name: "AssetCodegen",
capability: .buildTool()
),
.executableTarget(
name: "asset-codegen-tool",
path: "Sources/AssetCodegenTool"
),
]
)
The .plugin target with .buildTool() capability tells SPM this runs at build time. The separate executable target is
the actual tool that does the work.
Implementing the Plugin
The plugin itself conforms to BuildToolPlugin and returns an array of commands:
// Plugins/AssetCodegen/AssetCodegen.swift
import PackagePlugin
@main
struct AssetCodegen: BuildToolPlugin {
func createBuildCommands(
context: PluginContext,
target: Target
) async throws -> [Command] {
guard let sourceTarget = target as? SourceModuleTarget else {
return []
}
let inputFiles = sourceTarget.sourceFiles
.filter { $0.type == .resource }
let tool = try context.tool(named: "asset-codegen-tool")
let outputDir = context.pluginWorkDirectoryURL
return inputFiles.map { file in
let outputFile = outputDir.appending(
path: file.url.stem + "Generated.swift"
)
return .buildCommand(
displayName: "Generate asset code for \(file.url.lastPathComponent)",
executable: tool.url,
arguments: [
"--input", file.url.path(),
"--output", outputFile.path()
],
inputFiles: [file.url],
outputFiles: [outputFile]
)
}
}
}
Each .buildCommand declares its inputs and outputs explicitly. The build system uses those declarations to determine
when to re-run the command — if the input file has not changed, the command is skipped entirely.
Tip: You can also use
.prebuildCommandinstead of.buildCommandwhen your plugin needs to scan the entire target’s directory and cannot enumerate outputs upfront. Prebuild commands run before every build, so use them sparingly.Apple Docs:
PackagePlugin— Swift Package Manager
Command Plugins
Command plugins are different from build tool plugins: they do not run during the build. Instead, they appear as menu
items in Xcode (right-click a package in the navigator) or as swift package subcommands on the command line. They are
ideal for formatting, linting, documentation generation, or any task a developer triggers manually.
Declaring a Command Plugin
// Package.swift
.plugin(
name: "FormatCode",
capability: .command(
intent: .sourceCodeFormatting(),
permissions: [
.writeToPackageDirectory(reason: "Format source files")
]
)
)
The intent parameter tells Xcode where to surface the command. .sourceCodeFormatting() puts it under the Source
Editor menu. .documentationGeneration() links it to documentation workflows. For everything else, use
.custom(verb:description:).
Implementing the Command
// Plugins/FormatCode/FormatCode.swift
import PackagePlugin
@main
struct FormatCode: CommandPlugin {
func performCommand(
context: PluginContext,
arguments: [String]
) async throws {
let swiftFormat = try context.tool(named: "swift-format")
let sourceFiles = context.package.targets
.compactMap { $0 as? SourceModuleTarget }
.flatMap { $0.sourceFiles }
.filter { $0.type == .source }
.map { $0.url }
let process = Process()
process.executableURL = swiftFormat.url
process.arguments = ["--in-place"]
+ sourceFiles.map { $0.path() }
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
Diagnostics.error(
"swift-format exited with code \(process.terminationStatus)"
)
return
}
Diagnostics.remark("Formatted \(sourceFiles.count) files")
}
}
The plugin requests .writeToPackageDirectory permission, which Xcode surfaces as a consent dialog the first time the
command runs. This prevents plugins from silently modifying your source tree.
Warning: Command plugins run in a sandbox by default. They can read the package directory but cannot write to it unless the
permissionsarray includes.writeToPackageDirectory. If your plugin needs network access, it must declare that too — and users will be prompted to allow it.
Artifact Bundles
Sometimes the tool your plugin invokes is not a Swift executable you build from source. It might be a precompiled binary
like protoc, swift-format, or a custom C++ code generator. Artifact bundles let you vend precompiled binaries for
multiple platforms inside a single package.
An artifact bundle is a directory with a .artifactbundle extension containing the binaries and an info.json
manifest:
{
"schemaVersion": "1.0",
"artifacts": {
"asset-codegen-tool": {
"version": "1.2.0",
"type": "executable",
"variants": [
{
"path": "bin/macos-arm64/asset-codegen-tool",
"supportedTriples": ["arm64-apple-macosx"]
},
{
"path": "bin/macos-x86_64/asset-codegen-tool",
"supportedTriples": ["x86_64-apple-macosx"]
}
]
}
}
}
Reference the bundle in your Package.swift as a binary target:
.binaryTarget(
name: "asset-codegen-tool",
path: "Artifacts/AssetCodegenTool.artifactbundle"
)
You can also host the artifact bundle as a zip archive on a server and reference it by URL with a checksum. This keeps your repository small while still giving the build system everything it needs.
Apple Docs:
binaryTarget(name:path:)— PackageDescription
SPM Traits for Conditional API
SPM Traits, proposed in
SE-0450, give package authors
a way to conditionally compile code based on traits enabled by the consuming package. Think of them as feature flags
that flow through the dependency graph without scattering #if checks or requiring separate target names for each
variant.
Defining Traits
Traits are declared in Package.swift on the package that owns the conditional code:
// Package.swift for PixarRenderEngine
let package = Package(
name: "PixarRenderEngine",
traits: [
.trait(
name: "MetalBackend",
description: "Enable Metal rendering backend"
),
.trait(
name: "VulkanBackend",
description: "Enable Vulkan rendering backend"
),
.default(enabledTraits: ["MetalBackend"]),
],
targets: [
.target(
name: "PixarRenderEngine",
dependencies: [
.target(
name: "MetalRenderer",
condition: .when(traits: ["MetalBackend"])
),
.target(
name: "VulkanRenderer",
condition: .when(traits: ["VulkanBackend"])
),
]
),
.target(name: "MetalRenderer"),
.target(name: "VulkanRenderer"),
]
)
Enabling Traits from the Consumer
The consuming package selects traits when declaring the dependency:
// Package.swift for PixarStudioApp
.package(
url: "https://github.com/pixar/render-engine",
from: "2.0.0",
traits: ["MetalBackend", "VulkanBackend"]
)
Only the selected traits compile. Everything else is excluded at the package resolution level, not just gated behind conditional compilation. This means unused trait targets do not contribute to build times.
Using Traits in Source Code
Within your Swift source files, you can check for trait activation using the trait compilation condition:
// Sources/PixarRenderEngine/RenderCoordinator.swift
struct RenderCoordinator {
func render(scene: PixarScene) {
#if trait("MetalBackend")
let renderer = MetalRenderer()
renderer.draw(scene)
#elseif trait("VulkanBackend")
let renderer = VulkanRenderer()
renderer.draw(scene)
#else
fatalError("No rendering backend enabled")
#endif
}
}
Note: SPM Traits were proposed in SE-0450 and are available in Swift 6.1+. If your team has not migrated to Swift 6.1 yet, the older pattern of separate targets with conditional dependencies still works, but traits are the recommended path forward.
Advanced Usage
Combining Build Tool Plugins with Traits
A powerful pattern is using traits to control whether a build tool plugin runs at all. For instance, your
PixarAssetCatalog package might offer a MockGeneration trait that only runs the mock factory generator in test
configurations:
// Package.swift
let package = Package(
name: "PixarAssetCatalog",
traits: [
.trait(
name: "MockGeneration",
description: "Generate mock factories for testing"
),
],
targets: [
.target(
name: "PixarAssetCatalog",
plugins: [
.plugin(name: "AssetCodegen"),
]
),
.target(
name: "PixarAssetCatalogTesting",
dependencies: [
.target(
name: "PixarAssetCatalog",
condition: .when(traits: ["MockGeneration"])
),
],
plugins: [
.plugin(name: "MockFactoryGen"),
]
),
]
)
Consumers that only need production code never pay the cost of generating mocks. Test targets opt in explicitly.
Xcode Integration for Command Plugins
When working in Xcode, command plugins surface under the package’s context menu in the Project Navigator. Your team can right-click a package, select the plugin, and Xcode runs it with the correct context. This is particularly useful for formatting, linting, or regenerating resources.
For Xcode-specific plugins, implement XcodeCommandPlugin alongside CommandPlugin:
#if canImport(XcodeProjectPlugin)
import XcodeProjectPlugin
extension FormatCode: XcodeCommandPlugin {
func performCommand(
context: XcodePluginContext,
arguments: [String]
) throws {
// Xcode provides different context types
// but the core logic remains the same
let tool = try context.tool(named: "swift-format")
// ... same formatting logic
}
}
#endif
Warning: The
XcodeProjectPluginmodule is only available when the plugin is invoked from Xcode, not from the command line. Always guard the import with#if canImport(XcodeProjectPlugin)to keep your plugin usable in both contexts.
Diagnostics and Error Reporting
Plugins communicate with the build system through the Diagnostics API. Use it to surface warnings and errors that
appear in Xcode’s Issue Navigator:
Diagnostics.warning(
"Model file 'ToyStoryCharacter.json' has no version field"
)
Diagnostics.error(
"Failed to parse schema: \(error.localizedDescription)"
)
Diagnostics.remark(
"Generated 42 mock factories for PixarAssetCatalog"
)
These integrate directly into Xcode’s build log, making plugin issues as visible as compiler warnings.
Performance Considerations
Build tool plugins participate in the build graph, so their performance directly affects incremental build times. A few things to watch:
Input/output declarations matter. If your .buildCommand correctly declares input and output files, the build
system skips the command when inputs have not changed. If you use .prebuildCommand instead, it runs on every single
build regardless. The difference on a large project can be seconds per build.
Plugin process overhead. Each plugin invocation spawns a new process. If you have a plugin that processes files one at a time, consider batching — accept multiple input files in a single invocation and produce all outputs at once. The process startup cost is non-trivial when multiplied across hundreds of files.
Artifact bundles versus building from source. If your code generation tool is complex and takes a long time to compile, shipping it as an artifact bundle eliminates that compilation from your dependency graph. On the other hand, building from source guarantees you are always running the latest version. Choose based on your team’s workflow — CI pipelines often benefit from prebuilt binaries, while local development prefers source builds for debuggability.
Tip: Profile your build with Xcode’s build timeline (Product > Perform Action > Build With Timing Summary) to see exactly how long each plugin command takes. If a plugin is a bottleneck, that is a strong signal to move it to an artifact bundle or optimize its implementation.
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| Code generation tied to source files | Use a build tool plugin. It runs automatically and guarantees outputs match inputs. |
| Formatting, linting, or docs generation | Use a command plugin. Developer-triggered, won’t slow builds. |
| Vending a precompiled binary tool | Use an artifact bundle. Keeps your repo small and builds fast. |
| Feature flags controlling which targets compile | Use SPM Traits. Excludes code at package-resolution level. |
| Simple conditional compilation in one target | Stick with #if compiler directives. Traits are overkill here. |
| Team on Swift < 6.1, needs conditional deps | Use separate targets with conditions. Migrate to traits later. |
Summary
- Build tool plugins run during every build and integrate generated code directly into the compilation pipeline. Declare inputs and outputs precisely to keep incremental builds fast.
- Command plugins surface as Xcode menu items or
swift packagesubcommands for developer-triggered tasks like formatting and linting. - Artifact bundles let you vend precompiled binaries for tools that your plugins invoke, avoiding the need to build them from source.
- SPM Traits (SE-0450, Swift 6.1+) provide a clean mechanism for conditional API exposure that excludes unused code
at the package-resolution level rather than relying on
#ifflags. - The combination of plugins and traits gives you a build system that is self-documenting, reproducible, and requires zero manual steps from your team.
If you are splitting your app into packages, the natural next step is designing the module boundaries themselves. Head over to Modular App Architecture for patterns on feature modules, dependency inversion, and build-time isolation.