Skip to content

Build a Swift CLI Tool — ArgumentParser, File I/O, and Terminal Apps

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about Build a Swift CLI Tool. We cover key concepts, practical examples, and best practices to help you master this topic.

Build a Swift command-line tool using Apple's ArgumentParser library that processes files, handles terminal I/O, and is packaged as an executable SPM project for distribution via Homebrew or direct download.

What You'll Build

  • A CLI tool for file analysis (word count, line count, frequency)
  • ArgumentParser for command-line arguments
  • File system operations with FileManager
  • Colorized terminal output
  • SPM executable packaging
  • Error handling for invalid input

Why It Matters

CLI tools automate repetitive tasks. Swift's ArgumentParser makes building professional-quality command-line tools trivial. Many popular developer tools — including SwiftLint, Sourcery, and CocoaPods — are written in Swift.

Real-World Use

A team maintains a "CodeAnalyzer" CLI tool that scans Swift files for TODOs, FIXMEs, and code smells. It runs in CI and fails the build if too many issues are found. The tool is distributed via Homebrew.

Learning Path

flowchart LR
  A[Project: Game
Lesson 36] --> B[Project: CLI Tool
You are here] B --> C[Server-Side Swift
Lesson 38] B --> D[App Architecture
Lesson 39] style B fill:#f90,color:#fff

Project Setup

mkdir FileAnalyzer
cd FileAnalyzer
swift package init --name FileAnalyzer --type executable

Add the ArgumentParser dependency to Package.swift:

// swift-tools-version: 5.9
import PackageDescription

let package = Package(
    name: "FileAnalyzer",
    platforms: [.macOS(.v13)],
    dependencies: [
        .package(url: "https://github.com/apple/swift-argument-parser.git",
                 from: "1.3.0"),
    ],
    targets: [
        .executableTarget(
            name: "FileAnalyzer",
            dependencies: [
                .product(name: "ArgumentParser", package: "swift-argument-parser")
            ]
        ),
    ]
)

Core Command

import ArgumentParser
import Foundation

@main
struct FileAnalyzer: ParsableCommand {
    static let configuration = CommandConfiguration(
        commandName: "fileanalyzer",
        abstract: "Analyze text files and report statistics.",
        discussion: "Count lines, words, characters, and more in text files.",
        version: "1.0.0",
        subcommands: [Stats.self, Find.self, Replace.self, Watch.self]
    )
}

Stats Subcommand

import ArgumentParser
import Foundation

extension FileAnalyzer {
    struct Stats: ParsableCommand {
        static let configuration = CommandConfiguration(
            abstract: "Display file statistics."
        )

        @Argument(help: "File path to analyze")
        var path: String

        @Flag(name: .shortAndLong, help: "Show detailed statistics")
        var detailed = false

        @Flag(name: .shortAndLong, help: "Include hidden files")
        var all = false

        @Option(name: .shortAndLong, help: "Encoding to use (utf8, utf16, ascii)")
        var encoding: String = "utf8"

        mutating func run() throws {
            let url = URL(fileURLWithPath: path)
            let data = try Data(contentsOf: url)

            guard let content = String(data: data, encoding: .utf8) else {
                throw CLIError.invalidEncoding
            }

            let lines = content.components(separatedBy: .newlines)
            let words = content.components(separatedBy: .whitespacesAndNewlines)
                .filter { !$0.isEmpty }
            let chars = content.count
            let bytes = data.count

            print(Terminal.bold("File: \(path)"))
            print(String(repeating: "─", count: 40))
            print("Lines:     \(Terminal.green("\(lines.count)"))")
            print("Words:     \(Terminal.cyan("\(words.count)"))")
            print("Chars:     \(Terminal.yellow("\(chars)"))")
            print("Bytes:     \(Terminal.magenta("\(bytes)"))")

            if detailed {
                print(String(repeating: "─", count: 40))
                print(Terminal.bold("Detailed Statistics:"))
                print("Avg line length: \(chars / max(lines.count, 1))")
                print("Max line length: \(lines.map(\.count).max() ?? 0)")

                let freq = wordFrequency(content)
                print(Terminal.bold("\nTop 10 Words:"))
                for (word, count) in freq.prefix(10) {
                    let bar = String(repeating: "█", count: count)
                    print("  \(Terminal.cyan(word.padding(toLength: 15, withPad: " ", startingAt: 0))) \(bar) \(count)")
                }
            }
        }

        func wordFrequency(_ text: String) -> [(String, Int)] {
            let words = text.lowercased()
                .components(separatedBy: .alphanumerics.inverted)
                .filter { !$0.isEmpty && $0.count > 2 }

            var freq: [String: Int] = [:]
            words.forEach { freq[$0, default: 0] += 1 }
            return freq.sorted { $0.value > $1.value }
        }
    }
}

enum CLIError: LocalizedError {
    case invalidEncoding
    case fileNotFound(String)
    case permissionDenied(String)
    case invalidPath

    var errorDescription: String? {
        switch self {
        case .invalidEncoding: return "Could not decode file with specified encoding"
        case .fileNotFound(let path): return "File not found: \(path)"
        case .permissionDenied(let path): return "Permission denied: \(path)"
        case .invalidPath: return "Invalid file path"
        }
    }
}

Terminal Helpers

import Foundation

enum Terminal {
    static func bold(_ text: String) -> String { "\u{001B}[1m\(text)\u{001B}[22m" }
    static func red(_ text: String) -> String { "\u{001B}[31m\(text)\u{001B}[0m" }
    static func green(_ text: String) -> String { "\u{001B}[32m\(text)\u{001B}[0m" }
    static func yellow(_ text: String) -> String { "\u{001B}[33m\(text)\u{001B}[0m" }
    static func blue(_ text: String) -> String { "\u{001B}[34m\(text)\u{001B}[0m" }
    static func magenta(_ text: String) -> String { "\u{001B}[35m\(text)\u{001B}[0m" }
    static func cyan(_ text: String) -> String { "\u{001B}[36m\(text)\u{001B}[0m" }

    static func error(_ text: String) -> String { red("Error: \(text)") }
    static func warning(_ text: String) -> String { yellow("Warning: \(text)") }
    static func success(_ text: String) -> String { green("✓ \(text)") }
}

Find Subcommand

import ArgumentParser
import Foundation

extension FileAnalyzer {
    struct Find: ParsableCommand {
        static let configuration = CommandConfiguration(
            abstract: "Search for text in files."
        )

        @Argument(help: "Pattern to search for")
        var pattern: String

        @Argument(help: "File or directory to search")
        var path: String

        @Option(name: .shortAndLong, help: "File extension filter (e.g., swift, md)")
        var ext: String?

        @Flag(name: .shortAndLong, help: "Case insensitive search")
        var ignoreCase = false

        @Flag(name: .long, help: "Show line numbers")
        var lineNumbers = false

        mutating func run() throws {
            let url = URL(fileURLWithPath: path)
            var isDir: ObjCBool = false

            guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir) else {
                throw CLIError.fileNotFound(path)
            }

            let searchPattern = ignoreCase ? pattern.lowercased() : pattern
            var totalMatches = 0

            if isDir.boolValue {
                let enumerator = FileManager.default.enumerator(at: url,
                    includingPropertiesForKeys: nil)
                while let fileURL = enumerator?.nextObject() as? URL {
                    if let ext = ext, fileURL.pathExtension != ext { continue }
                    totalMatches += try searchFile(fileURL, pattern: searchPattern)
                }
            } else {
                totalMatches = try searchFile(url, pattern: searchPattern)
            }

            print(Terminal.success("Found \(totalMatches) matches"))
        }

        func searchFile(_ url: URL, pattern: String) throws -> Int {
            let content = try String(contentsOf: url, encoding: .utf8)
            let lines = content.components(separatedBy: .newlines)
            var matches = 0

            for (index, line) in lines.enumerated() {
                let searchLine = ignoreCase ? line.lowercased() : line
                if searchLine.contains(pattern) {
                    matches += 1
                    let location = "\(url.lastPathComponent):\(index + 1)"
                    let prefix = lineNumbers ? Terminal.cyan("[\(index + 1)] ") : ""
                    print("  \(Terminal.blue(location)): \(prefix)\(line.trimmingCharacters(in: .whitespaces).prefix(100))")
                }
            }
            return matches
        }
    }
}

Replace Subcommand

import ArgumentParser
import Foundation

extension FileAnalyzer {
    struct Replace: ParsableCommand {
        static let configuration = CommandConfiguration(
            abstract: "Replace text in files."
        )

        @Argument(help: "Text to find")
        var find: String

        @Argument(help: "Replacement text")
        var replace: String

        @Argument(help: "File path")
        var path: String

        @Flag(name: .shortAndLong, help: "Dry run (show changes without saving)")
        var dryRun = false

        mutating func run() throws {
            let url = URL(fileURLWithPath: path)
            var content = try String(contentsOf: url, encoding: .utf8)
            let original = content

            content = content.replacingOccurrences(of: find, with: replace)
            let changes = zip(original.components(separatedBy: .newlines),
                              content.components(separatedBy: .newlines))
                .enumerated()
                .filter { $0.element.0 != $0.element.1 }

            if changes.isEmpty {
                print(Terminal.warning("No occurrences of '\(find)' found"))
                return
            }

            print("Found \(changes.count) occurrence(s):")
            for (_, (old, new)) in changes.prefix(10) {
                print("  \(Terminal.red(old.prefix(60)))\(Terminal.green(new.prefix(60)))")
            }

            if changes.count > 10 {
                print("  ... and \(changes.count - 10) more")
            }

            if !dryRun {
                try content.write(to: url, atomically: true, encoding: .utf8)
                print(Terminal.success("File updated"))
            } else {
                print(Terminal.warning("Dry run — no changes saved"))
            }
        }
    }
}

Watch Subcommand

import ArgumentParser
import Foundation

extension FileAnalyzer {
    struct Watch: ParsableCommand {
        static let configuration = CommandConfiguration(
            abstract: "Watch a file for changes."
        )

        @Argument(help: "File to watch")
        var path: String

        @Option(name: .shortAndLong, help: "Polling interval in seconds")
        var interval: Double = 1.0

        mutating func run() throws {
            let url = URL(fileURLWithPath: path)
            var lastModification = try fileModificationDate(url)

            print(Terminal.blue("Watching \(path) every \(interval)s..."))
            print("Press Ctrl+C to stop")

            repeat {
                try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))

                let currentMod = try? fileModificationDate(url)
                if currentMod != lastModification {
                    lastModification = currentMod
                    print(Terminal.green("\n[\(Date().formatted(date: .omitted, time: .standard))] File changed!"))
                    let stats = try await analyzeFile(url)
                    print(stats)
                }
            } while true
        }

        func fileModificationDate(_ url: URL) throws -> Date? {
            let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
            return attrs[.modificationDate] as? Date
        }

        func analyzeFile(_ url: URL) async throws -> String {
            let content = try String(contentsOf: url, encoding: .utf8)
            let lines = content.components(separatedBy: .newlines).count
            let words = content.components(separatedBy: .whitespacesAndNewlines)
                .filter { !$0.isEmpty }.count
            return "Lines: \(lines), Words: \(words)"
        }
    }
}

Usage Examples

# Build
swift build -c release

# Run stats
./.build/release/FileAnalyzer stats README.md --detailed

# Search for TODO
./.build/release/FileAnalyzer find "TODO" Sources/ --ext swift --line-numbers

# Replace text (dry run)
./.build/release/FileAnalyzer replace "old" "new" config.json --dry-run

# Watch file changes
./.build/release/FileAnalyzer watch log.txt --interval 2

# Get help
./.build/release/FileAnalyzer --help
./.build/release/FileAnalyzer stats --help

Key Takeaways

  • ArgumentParser handles all CLI boilerplate
  • ANSI escape codes provide colored terminal output
  • FileManager enables file system operations
  • Subcommands create a professional tool hierarchy
  • SPM packaging makes distribution easy

Common Mistakes

  1. Not handling file encoding properly: Always specify encoding. Default to UTF-8 but allow configuration.

  2. Blocking the main thread with file I/O: For large files, use async file reading or read in chunks.

  3. Not validating paths: Check file existence and permissions before reading.

  4. Ignoring stderr for errors: Print errors to FileHandle.standardError, not standard output.

  5. Not cleaning up ANSI codes in logs: Strip ANSI codes when output is piped to a file.

Practice Questions

  1. How does ArgumentParser handle subcommands?
  2. Why should errors go to stderr instead of stdout?
  3. How would you add JSON output mode (--json)?
  4. What is the purpose of --dry-run in the Replace command?
  5. Challenge: Add a --recursive flag to the Stats command that walks a directory tree and produces a summary of all files found.

FAQ

How do I distribute a Swift CLI tool?

Build with swift build -c release, zip the binary, and create a GitHub release. For Homebrew, create a formula that downloads the binary.

Can Swift CLI tools run on Linux?

Yes. Swift compiles for Linux. Use #if os(Linux) for platform-specific code. The ArgumentParser library works on both platforms.

How do I handle signals like SIGINT?

Use signal(SIGINT) { _ in } to set up a handler that cleans up temporary files and exits gracefully.

How do I add progress indicators for long operations?

Use ANSI escape codes to update the same line: print('\rProcessing: (i)%', terminator: '') and fflush(stdout).

Can I use SwiftUI in a CLI tool?

No. SwiftUI requires a graphical environment. Use terminal output, ncurses (via C interop), or SwiftPlot for charts.

What's Next

Explore Server-Side Swift with Vapor, or learn App Architecture patterns for large-scale iOS apps.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro