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

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 .prebuildCommand instead of .buildCommand when 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 permissions array 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 XcodeProjectPlugin module 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)

ScenarioRecommendation
Code generation tied to source filesUse a build tool plugin. It runs automatically and guarantees outputs match inputs.
Formatting, linting, or docs generationUse a command plugin. Developer-triggered, won’t slow builds.
Vending a precompiled binary toolUse an artifact bundle. Keeps your repo small and builds fast.
Feature flags controlling which targets compileUse SPM Traits. Excludes code at package-resolution level.
Simple conditional compilation in one targetStick with #if compiler directives. Traits are overkill here.
Team on Swift < 6.1, needs conditional depsUse 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 package subcommands 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 #if flags.
  • 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.