krz/keycask

Password manager: Swift core library, CLI for macOS/Linux/Windows, iOS/macOS app. cli password-manager swift

docs/superpowers/plans/2026-09-17-core-cli.md

main
keycask/docs/superpowers/plans/2026-09-17-core-cli.md rendered · source · history · blame · raw

3337 lines · 113078 bytes

keycask core + CLI Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A Swift package with KeycaskCore (vault model, passphrase-encrypted envelope, generator, resolution) and a keycask CLI that behaves identically on macOS, Linux, and Windows, covered by in-process and black-box tests.

Architecture: KeycaskCore depends on Foundation and swift-crypto only and holds every rule about entries, IDs, encryption, and lookup. The keycask executable wraps it with ArgumentParser commands and a small platform layer (paths, terminal, atomic write, clipboard). Tests in KeycaskCoreTests run in process; tests in KeycaskCLITests spawn the built binary and check stdout, stderr, and exit codes.

Tech Stack: Swift 6.4, SwiftPM, Swift Testing, swift-crypto 3.15 (Crypto, _CryptoExtras), swift-argument-parser 1.8.

Spec: docs/superpowers/specs/2026-09-17-keycask-design.md

Global Constraints

  • // swift-tools-version:6.4, Swift 6 language mode, platforms: [.macOS(.v14), .iOS(.v17)].
  • Dependencies are exactly apple/swift-crypto and apple/swift-argument-parser. Package.resolved is committed.
  • KeycaskCore imports only Foundation, Crypto, and _CryptoExtras. It never imports ArgumentParser, never spawns a process, never reads the environment.
  • Envelope: format 1, kdf.name "pbkdf2-hmac-sha256", 600000 iterations, 16-byte salt, ChaCha20-Poly1305 combined box, key 32 bytes, passphrase NFC-normalized UTF-8.
  • EntryID: 8 characters from abcdefghijkmnpqrstuvwxyz23456789.
  • Dates: ISO 8601 UTC, whole seconds. JSON: sorted keys.
  • Exit codes: 0 ok, 1 failure, 2 usage, 3 not found, 4 cannot decrypt, 5 ambiguous.
  • Masked secret string is exactly ********.
  • Clipboard timeout is 45 seconds. Password default length 24. Passphrase separator -.
  • No code comments that mention the history of the project or how it used to work. No attribution trailers in commits.
  • Every file passes swift format lint --strict.
  • Work happens on a branch. Tasks 1-8 go on branch core, merged through one MR. Tasks 9-15 go on branch cli, merged through a second MR. Never commit to main directly.

File structure

Package.swift
Package.resolved
NOTICE
.gitbay/ci.yml
Sources/KeycaskCore/
  KeycaskError.swift      error enum, exit codes, messages
  EntryID.swift           8-char random ID, Codable as a string
  Entry.swift             entry struct, tag normalization, second-truncated dates
  Vault.swift             entries, add/remove/update, resolve, filter, search
  VaultCodec.swift        deterministic JSON encode/decode of Vault
  Envelope.swift          file envelope, PBKDF2 + ChaChaPoly seal/open, parse/encode
  Generator.swift         random password and passphrase
  Wordlist.swift          EFF long list as one string literal (generated file)
Sources/keycask/
  main.swift              parse, run, map errors to exit codes
  Keycask.swift           root command, GlobalOptions
  Paths.swift             vault path resolution
  Terminal.swift          isatty, echo-off line read, y/N confirm
  Passphrase.swift        env var or prompt
  AtomicFile.swift        temp + fsync + rename, 0600 on Unix, MoveFileExW on Windows
  OpenVault.swift         load/save/create: ties paths, passphrase, envelope, codec, atomic write
  Output.swift            text, table, JSON, masking, --field
  Clipboard.swift         tool discovery, read/write, daemon handoff
  Commands/Init.swift
  Commands/Add.swift
  Commands/Show.swift
  Commands/Ls.swift
  Commands/Find.swift
  Commands/Edit.swift
  Commands/Rm.swift
  Commands/Generate.swift
  Commands/Clip.swift
  Commands/ClipboardDaemon.swift
Tests/KeycaskCoreTests/
  EntryIDTests.swift
  EntryTests.swift
  VaultTests.swift
  VaultCodecTests.swift
  EnvelopeTests.swift
  GeneratorTests.swift
  KeycaskErrorTests.swift
Tests/KeycaskCLITests/
  CLI.swift               harness: locate binary, temp vault, run with env
  InitTests.swift
  AddShowTests.swift
  LsFindTests.swift
  EditRmTests.swift
  GenerateTests.swift
  ClipboardTests.swift
  PathsTests.swift

Task 1: Package scaffold and CI

Files:

  • Create: Package.swift
  • Create: Sources/KeycaskCore/KeycaskCore.swift (temporary, deleted in Task 2)
  • Create: Sources/keycask/main.swift (replaced in Task 9)
  • Create: Tests/KeycaskCoreTests/SmokeTests.swift (deleted in Task 2)
  • Create: .gitbay/ci.yml
  • Create: .swift-format

Interfaces:

  • Produces: the package layout every later task adds files to.

  • Step 1: Create the branch

cd /Users/cmc/git/krz/keycask && git switch -c core
  • Step 2: Write Package.swift
// swift-tools-version:6.4
import PackageDescription

let package = Package(
    name: "keycask",
    platforms: [.macOS(.v14), .iOS(.v17)],
    products: [
        .library(name: "KeycaskCore", targets: ["KeycaskCore"]),
        .executable(name: "keycask", targets: ["keycask"]),
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-crypto", from: "3.15.0"),
        .package(url: "https://github.com/apple/swift-argument-parser", from: "1.8.0"),
    ],
    targets: [
        .target(
            name: "KeycaskCore",
            dependencies: [
                .product(name: "Crypto", package: "swift-crypto"),
                .product(name: "_CryptoExtras", package: "swift-crypto"),
            ]
        ),
        .executableTarget(
            name: "keycask",
            dependencies: [
                "KeycaskCore",
                .product(name: "ArgumentParser", package: "swift-argument-parser"),
            ]
        ),
        .testTarget(name: "KeycaskCoreTests", dependencies: ["KeycaskCore"]),
        .testTarget(name: "KeycaskCLITests", dependencies: ["keycask"]),
    ]
)
  • Step 3: Write placeholder sources so the package builds

Sources/KeycaskCore/KeycaskCore.swift:

public enum KeycaskCore {
    public static let name = "keycask"
}

Sources/keycask/main.swift:

import KeycaskCore

print(KeycaskCore.name)

Tests/KeycaskCoreTests/SmokeTests.swift:

import Testing

@testable import KeycaskCore

@Test func packageBuilds() {
    #expect(KeycaskCore.name == "keycask")
}

Tests/KeycaskCLITests/CLI.swift (a real file, extended in Task 9; this version only locates the binary):

import Foundation
import Testing

enum Binary {
    static let url: URL = {
        #if os(macOS)
            for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") {
                return bundle.bundleURL.deletingLastPathComponent().appendingPathComponent("keycask")
            }
            fatalError("test bundle not found")
        #elseif os(Windows)
            return Bundle.main.bundleURL.appendingPathComponent("keycask.exe")
        #else
            return Bundle.main.bundleURL.appendingPathComponent("keycask")
        #endif
    }()
}

@Test func binaryIsBuilt() {
    #expect(FileManager.default.isExecutableFile(atPath: Binary.url.path))
}
  • Step 4: Write .swift-format
{
  "version": 1,
  "indentation": { "spaces": 4 },
  "lineLength": 100,
  "maximumBlankLines": 1,
  "respectsExistingLineBreaks": true,
  "rules": {
    "AlwaysUseLowerCamelCase": true,
    "NeverForceUnwrap": false,
    "NeverUseImplicitlyUnwrappedOptionals": true
  }
}
  • Step 5: Build and test

Run: swift build && swift test Expected: Build complete, two tests pass. Package.resolved now exists.

  • Step 6: Lint

Run: swift format lint --strict --recursive Sources Tests Package.swift Expected: no output. If it reports findings, run swift format --in-place --recursive Sources Tests Package.swift and re-lint.

  • Step 7: Write .gitbay/ci.yml
# Each step runs in its own `sh -c`; exports do not survive between steps.
# swiftly installs into $HOME, which persists across builds.
jobs:
  build:
    steps:
      - |
        set -eu
        command -v curl >/dev/null || { echo "runner is missing: curl"; exit 1; }
        if ! command -v "$HOME/.local/bin/swiftly" >/dev/null 2>&1; then
          curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz" | tar -xz -C /tmp
          /tmp/swiftly init --assume-yes --skip-install --quiet-shell-followup
        fi
        . "$HOME/.local/share/swiftly/env.sh"
        swiftly install --use 6.4
        swift format lint --strict --recursive Sources Tests Package.swift
        swift build
  test:
    steps:
      - |
        set -eu
        . "$HOME/.local/share/swiftly/env.sh"
        swiftly install --use 6.4
        swift test
    paths-ignore:
      - docs/**
  • Step 8: Commit
git add Package.swift Package.resolved .swift-format .gitbay/ci.yml Sources Tests
git commit -m "Add package scaffold and CI"

Task 2: KeycaskError

Files:

  • Create: Sources/KeycaskCore/KeycaskError.swift
  • Create: Tests/KeycaskCoreTests/KeycaskErrorTests.swift
  • Delete: Sources/KeycaskCore/KeycaskCore.swift, Tests/KeycaskCoreTests/SmokeTests.swift

Interfaces:

  • Produces: public enum KeycaskError: Error, Equatable, Sendable with cases notFound(String), ambiguous(name: String, candidates: [Entry]), cannotDecrypt, corrupt(String), vaultExists(String), noVault(String), duplicateID(EntryID), io(String), usage(String), failure(String); properties exitCode: Int32, message: String.

  • Note: Entry and EntryID do not exist yet. Write this task with ambiguous(name: String, candidates: [String]) and duplicateID(String) and change them to the real types in Tasks 3 and 4.

  • Step 1: Delete placeholders

git rm -q Sources/KeycaskCore/KeycaskCore.swift Tests/KeycaskCoreTests/SmokeTests.swift
  • Step 2: Write the failing test

Tests/KeycaskCoreTests/KeycaskErrorTests.swift:

import Testing

@testable import KeycaskCore

@Suite struct KeycaskErrorTests {
    @Test func exitCodesFollowTheSpec() {
        #expect(KeycaskError.failure("x").exitCode == 1)
        #expect(KeycaskError.io("x").exitCode == 1)
        #expect(KeycaskError.corrupt("x").exitCode == 1)
        #expect(KeycaskError.vaultExists("x").exitCode == 1)
        #expect(KeycaskError.duplicateID("abcd2345").exitCode == 1)
        #expect(KeycaskError.usage("x").exitCode == 2)
        #expect(KeycaskError.notFound("x").exitCode == 3)
        #expect(KeycaskError.noVault("/p").exitCode == 3)
        #expect(KeycaskError.cannotDecrypt.exitCode == 4)
        #expect(KeycaskError.ambiguous(name: "gh", candidates: []).exitCode == 5)
    }

    @Test func messagesNameTheSubject() {
        #expect(KeycaskError.notFound("gh").message == "gh: not found")
        #expect(KeycaskError.noVault("/v").message == "vault /v not found (run `keycask init`)")
        #expect(KeycaskError.vaultExists("/v").message == "vault /v already exists")
        #expect(KeycaskError.cannotDecrypt.message == "cannot decrypt: wrong passphrase or damaged vault")
        #expect(KeycaskError.corrupt("bad json").message == "vault is corrupt: bad json")
    }
}
  • Step 3: Run test to verify it fails

Run: swift test --filter KeycaskErrorTests Expected: compile error, KeycaskError not found.

  • Step 4: Write the implementation

Sources/KeycaskCore/KeycaskError.swift:

public enum KeycaskError: Error, Equatable, Sendable {
    case notFound(String)
    case ambiguous(name: String, candidates: [String])
    case cannotDecrypt
    case corrupt(String)
    case vaultExists(String)
    case noVault(String)
    case duplicateID(String)
    case io(String)
    case usage(String)
    case failure(String)

    public var exitCode: Int32 {
        switch self {
        case .failure, .io, .corrupt, .vaultExists, .duplicateID: 1
        case .usage: 2
        case .notFound, .noVault: 3
        case .cannotDecrypt: 4
        case .ambiguous: 5
        }
    }

    public var message: String {
        switch self {
        case .notFound(let what): "\(what): not found"
        case .ambiguous(let name, let candidates):
            (["\(name): ambiguous, use an id:"] + candidates).joined(separator: "\n")
        case .cannotDecrypt: "cannot decrypt: wrong passphrase or damaged vault"
        case .corrupt(let why): "vault is corrupt: \(why)"
        case .vaultExists(let path): "vault \(path) already exists"
        case .noVault(let path): "vault \(path) not found (run `keycask init`)"
        case .duplicateID(let id): "duplicate id \(id)"
        case .io(let why): why
        case .usage(let why): why
        case .failure(let why): why
        }
    }
}
  • Step 5: Run tests

Run: swift test --filter KeycaskErrorTests Expected: 2 tests pass.

  • Step 6: Commit
git add -A Sources/KeycaskCore Tests/KeycaskCoreTests
git commit -m "Add KeycaskError with exit codes"

Task 3: EntryID

Files:

  • Create: Sources/KeycaskCore/EntryID.swift
  • Create: Tests/KeycaskCoreTests/EntryIDTests.swift
  • Modify: Sources/KeycaskCore/KeycaskError.swift (duplicateID(EntryID))
  • Modify: Tests/KeycaskCoreTests/KeycaskErrorTests.swift

Interfaces:

  • Produces: public struct EntryID: Hashable, Sendable, Codable, CustomStringConvertible with static let alphabet: [Character], static let length = 8, let rawValue: String, init?(_ raw: String), static func random() -> EntryID, static func random(using: inout some RandomNumberGenerator) -> EntryID. Codable as a bare JSON string.

  • Step 1: Write the failing test

Tests/KeycaskCoreTests/EntryIDTests.swift:

import Foundation
import Testing

@testable import KeycaskCore

@Suite struct EntryIDTests {
    @Test func randomIDsHaveLengthEightFromTheAlphabet() {
        let allowed = Set(EntryID.alphabet)
        for _ in 0..<200 {
            let id = EntryID.random()
            #expect(id.rawValue.count == 8)
            #expect(id.rawValue.allSatisfy { allowed.contains($0) })
        }
    }

    @Test func alphabetExcludesAmbiguousCharacters() {
        let alphabet = Set(EntryID.alphabet)
        #expect(alphabet.count == 32)
        for bad in ["l", "o", "0", "1"] {
            #expect(!alphabet.contains(Character(bad)))
        }
    }

    @Test func parsingValidatesLengthAndAlphabet() {
        #expect(EntryID("abcd2345") != nil)
        #expect(EntryID("abcd234") == nil)
        #expect(EntryID("abcd23456") == nil)
        #expect(EntryID("abcd234l") == nil)
        #expect(EntryID("ABCD2345") == nil)
    }

    @Test func codableIsABareString() throws {
        let id = EntryID("abcd2345")!
        let data = try JSONEncoder().encode([id])
        #expect(String(decoding: data, as: UTF8.self) == "[\"abcd2345\"]")
        let back = try JSONDecoder().decode([EntryID].self, from: data)
        #expect(back == [id])
        #expect(throws: DecodingError.self) {
            try JSONDecoder().decode([EntryID].self, from: Data("[\"bad\"]".utf8))
        }
    }

    @Test func seededGeneratorIsDeterministic() {
        struct Counter: RandomNumberGenerator {
            var n: UInt64 = 0
            mutating func next() -> UInt64 {
                n += 1
                return n
            }
        }
        var a = Counter()
        var b = Counter()
        #expect(EntryID.random(using: &a) == EntryID.random(using: &b))
    }
}
  • Step 2: Run test to verify it fails

Run: swift test --filter EntryIDTests Expected: compile error, EntryID not found.

  • Step 3: Write the implementation

Sources/KeycaskCore/EntryID.swift:

public struct EntryID: Hashable, Sendable, CustomStringConvertible {
    public static let alphabet: [Character] = Array("abcdefghijkmnpqrstuvwxyz23456789")
    public static let length = 8

    public let rawValue: String

    public init?(_ raw: String) {
        guard raw.count == Self.length else { return nil }
        let allowed = Set(Self.alphabet)
        guard raw.allSatisfy({ allowed.contains($0) }) else { return nil }
        rawValue = raw
    }

    public static func random() -> EntryID {
        var rng = SystemRandomNumberGenerator()
        return random(using: &rng)
    }

    public static func random(using rng: inout some RandomNumberGenerator) -> EntryID {
        var chars: [Character] = []
        chars.reserveCapacity(length)
        for _ in 0..<length {
            chars.append(alphabet[Int(rng.next(upperBound: UInt32(alphabet.count)))])
        }
        return EntryID(String(chars))!
    }

    public var description: String { rawValue }
}

extension EntryID: Codable {
    public init(from decoder: any Decoder) throws {
        let raw = try decoder.singleValueContainer().decode(String.self)
        guard let id = EntryID(raw) else {
            throw DecodingError.dataCorrupted(
                .init(codingPath: decoder.codingPath, debugDescription: "invalid entry id \(raw)"))
        }
        self = id
    }

    public func encode(to encoder: any Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(rawValue)
    }
}
  • Step 4: Switch duplicateID to the real type

In KeycaskError.swift change case duplicateID(String) to case duplicateID(EntryID) and the message to "duplicate id \(id.rawValue)". In KeycaskErrorTests.swift change .duplicateID("abcd2345") to .duplicateID(EntryID("abcd2345")!).

  • Step 5: Run tests

Run: swift test --filter 'EntryIDTests|KeycaskErrorTests' Expected: 7 tests pass.

  • Step 6: Commit
git add Sources/KeycaskCore Tests/KeycaskCoreTests
git commit -m "Add EntryID"

Task 4: Entry

Files:

  • Create: Sources/KeycaskCore/Entry.swift
  • Create: Tests/KeycaskCoreTests/EntryTests.swift
  • Modify: Sources/KeycaskCore/KeycaskError.swift (ambiguous(name:candidates: [Entry]))
  • Modify: Tests/KeycaskCoreTests/KeycaskErrorTests.swift

Interfaces:

  • Consumes: EntryID.
  • Produces:
public struct Entry: Codable, Equatable, Sendable {
    public let id: EntryID
    public var name: String
    public var username: String?
    public var password: String
    public var url: String?
    public var notes: String?
    public var tags: [String]
    public let created: Date
    public var updated: Date

    public init(id: EntryID = .random(), name: String, username: String? = nil,
                password: String, url: String? = nil, notes: String? = nil,
                tags: [String] = [], now: Date = .now)
    public static func normalize(tags: [String]) -> [String]
    public static func truncateToSeconds(_ date: Date) -> Date
    public func hasTag(_ tag: String) -> Bool
    public func matches(_ query: String) -> Bool
}
  • Step 1: Write the failing test

Tests/KeycaskCoreTests/EntryTests.swift:

import Foundation
import Testing

@testable import KeycaskCore

@Suite struct EntryTests {
    @Test func initNormalizesTagsAndTruncatesDates() {
        let now = Date(timeIntervalSince1970: 1_700_000_000.75)
        let e = Entry(name: "gh", password: "p", tags: [" work", "Dev", "dev", "", "alpha"], now: now)
        #expect(e.tags == ["alpha", "Dev", "work"])
        #expect(e.created == Date(timeIntervalSince1970: 1_700_000_000))
        #expect(e.updated == e.created)
    }

    @Test func normalizeSortsCaseInsensitivelyAndKeepsFirstSpelling() {
        #expect(Entry.normalize(tags: ["b", "A", "a", "B"]) == ["A", "b"])
        #expect(Entry.normalize(tags: []) == [])
    }

    @Test func hasTagIsCaseInsensitive() {
        let e = Entry(name: "gh", password: "p", tags: ["Dev"])
        #expect(e.hasTag("dev"))
        #expect(e.hasTag("DEV"))
        #expect(!e.hasTag("ops"))
    }

    @Test func matchesSearchesEveryTextFieldExceptPassword() {
        let e = Entry(
            name: "GitHub", username: "cmc", password: "hunter2", url: "https://github.com",
            notes: "downtown office", tags: ["Dev"])
        #expect(e.matches("github"))
        #expect(e.matches("CMC"))
        #expect(e.matches("github.com"))
        #expect(e.matches("downtown"))
        #expect(e.matches("dev"))
        #expect(!e.matches("hunter2"))
        #expect(!e.matches("nothing"))
    }
}
  • Step 2: Run test to verify it fails

Run: swift test --filter EntryTests Expected: compile error, Entry not found.

  • Step 3: Write the implementation

Sources/KeycaskCore/Entry.swift:

import Foundation

public struct Entry: Codable, Equatable, Sendable {
    public let id: EntryID
    public var name: String
    public var username: String?
    public var password: String
    public var url: String?
    public var notes: String?
    public var tags: [String]
    public let created: Date
    public var updated: Date

    public init(
        id: EntryID = .random(),
        name: String,
        username: String? = nil,
        password: String,
        url: String? = nil,
        notes: String? = nil,
        tags: [String] = [],
        now: Date = .now
    ) {
        self.id = id
        self.name = name
        self.username = username
        self.password = password
        self.url = url
        self.notes = notes
        self.tags = Self.normalize(tags: tags)
        let stamp = Self.truncateToSeconds(now)
        created = stamp
        updated = stamp
    }

    public static func normalize(tags: [String]) -> [String] {
        var seen: Set<String> = []
        var out: [String] = []
        for raw in tags {
            let tag = raw.trimmingCharacters(in: .whitespaces)
            guard !tag.isEmpty, seen.insert(tag.lowercased()).inserted else { continue }
            out.append(tag)
        }
        return out.sorted { a, b in
            let (la, lb) = (a.lowercased(), b.lowercased())
            return la == lb ? a < b : la < lb
        }
    }

    public static func truncateToSeconds(_ date: Date) -> Date {
        Date(timeIntervalSince1970: date.timeIntervalSince1970.rounded(.down))
    }

    public func hasTag(_ tag: String) -> Bool {
        let needle = tag.lowercased()
        return tags.contains { $0.lowercased() == needle }
    }

    public func matches(_ query: String) -> Bool {
        let needle = query.lowercased()
        guard !needle.isEmpty else { return false }
        let haystacks = [name, username ?? "", url ?? "", notes ?? ""] + tags
        return haystacks.contains { $0.lowercased().contains(needle) }
    }
}
  • Step 4: Switch ambiguous to carry entries

In KeycaskError.swift change the case to case ambiguous(name: String, candidates: [Entry]) and the message body to:

case .ambiguous(let name, let candidates):
    (["\(name): ambiguous, use an id:"]
        + candidates.map { "  \($0.id.rawValue)  \($0.username ?? "")  \($0.url ?? "")" })
        .joined(separator: "\n")

KeycaskErrorTests.swift already passes candidates: [], which now infers [Entry]. Add one test there:

@Test func ambiguousListsCandidateIDs() {
    let a = Entry(id: EntryID("aaaa2222")!, name: "gh", username: "one", password: "p")
    let b = Entry(id: EntryID("bbbb3333")!, name: "gh", password: "p", url: "https://x")
    let m = KeycaskError.ambiguous(name: "gh", candidates: [a, b]).message
    #expect(m.hasPrefix("gh: ambiguous, use an id:\n"))
    #expect(m.contains("aaaa2222"))
    #expect(m.contains("bbbb3333"))
    #expect(m.contains("https://x"))
}
  • Step 5: Run tests

Run: swift test --filter 'EntryTests|KeycaskErrorTests' Expected: all pass.

  • Step 6: Commit
git add Sources/KeycaskCore Tests/KeycaskCoreTests
git commit -m "Add Entry with tag normalization and search"

Task 5: Vault and VaultCodec

Files:

  • Create: Sources/KeycaskCore/Vault.swift
  • Create: Sources/KeycaskCore/VaultCodec.swift
  • Create: Tests/KeycaskCoreTests/VaultTests.swift
  • Create: Tests/KeycaskCoreTests/VaultCodecTests.swift

Interfaces:

  • Consumes: Entry, EntryID, KeycaskError.
  • Produces:
public struct Vault: Codable, Equatable, Sendable {
    public var entries: [Entry]
    public init(entries: [Entry] = [])
    public func entry(id: EntryID) -> Entry?
    public mutating func add(_ entry: Entry) throws            // duplicateID
    public mutating func remove(id: EntryID) throws            // notFound(id)
    public mutating func update(id: EntryID, now: Date = .now,
                                _ change: (inout Entry) -> Void) throws  // notFound(id); normalizes tags, sets updated
    public func resolve(_ ref: String) throws -> Entry         // id, unique name, ambiguous, notFound
    public func filter(tag: String) -> [Entry]
    public func search(_ query: String) -> [Entry]
    public var sortedEntries: [Entry]                          // by name (case-insensitive), then id
}

public enum VaultCodec {
    public static func encode(_ vault: Vault) throws -> Data   // sortedKeys, iso8601; io on failure
    public static func decode(_ data: Data) throws -> Vault    // corrupt on failure
    public static func makeEncoder() -> JSONEncoder            // shared settings, also used by CLI output
}
  • Step 1: Write the failing tests

Tests/KeycaskCoreTests/VaultTests.swift:

import Foundation
import Testing

@testable import KeycaskCore

@Suite struct VaultTests {
    func idA() -> EntryID { EntryID("aaaa2222")! }
    func idB() -> EntryID { EntryID("bbbb3333")! }

    @Test func addRejectsDuplicateID() throws {
        var v = Vault()
        try v.add(Entry(id: idA(), name: "gh", password: "p"))
        #expect(throws: KeycaskError.duplicateID(idA())) {
            try v.add(Entry(id: idA(), name: "other", password: "p"))
        }
        #expect(v.entries.count == 1)
    }

    @Test func removeUnknownIsNotFound() {
        var v = Vault()
        #expect(throws: KeycaskError.notFound("aaaa2222")) { try v.remove(id: idA()) }
    }

    @Test func updateSetsUpdatedAndNormalizesTags() throws {
        let t0 = Date(timeIntervalSince1970: 1_000)
        let t1 = Date(timeIntervalSince1970: 2_000.9)
        var v = Vault()
        try v.add(Entry(id: idA(), name: "gh", password: "p", now: t0))
        try v.update(id: idA(), now: t1) { e in
            e.tags = ["z", "A", "a"]
            e.password = "q"
        }
        let e = v.entry(id: idA())!
        #expect(e.password == "q")
        #expect(e.tags == ["A", "z"])
        #expect(e.created == t0)
        #expect(e.updated == Date(timeIntervalSince1970: 2_000))
    }

    @Test func resolvePrefersIDThenUniqueName() throws {
        var v = Vault()
        try v.add(Entry(id: idA(), name: "gh", password: "p"))
        try v.add(Entry(id: idB(), name: "aaaa2222", password: "p"))
        #expect(try v.resolve("aaaa2222").id == idA())
        #expect(try v.resolve("gh").id == idA())
        #expect(try v.resolve("bbbb3333").id == idB())
    }

    @Test func resolveReportsAmbiguousWithAllCandidates() throws {
        var v = Vault()
        let a = Entry(id: idA(), name: "gh", password: "p")
        let b = Entry(id: idB(), name: "gh", password: "p")
        try v.add(a)
        try v.add(b)
        #expect(throws: KeycaskError.ambiguous(name: "gh", candidates: [a, b])) {
            try v.resolve("gh")
        }
    }

    @Test func resolveUnknownIsNotFound() {
        #expect(throws: KeycaskError.notFound("nope")) { try Vault().resolve("nope") }
    }

    @Test func filterAndSearch() throws {
        var v = Vault()
        try v.add(Entry(id: idA(), name: "GitHub", password: "p", tags: ["dev"]))
        try v.add(Entry(id: idB(), name: "bank", password: "p", url: "https://bank.example"))
        #expect(v.filter(tag: "DEV").map(\.id) == [idA()])
        #expect(v.search("example").map(\.id) == [idB()])
        #expect(v.search("zzz").isEmpty)
    }

    @Test func sortedEntriesOrderByNameThenID() throws {
        var v = Vault()
        try v.add(Entry(id: idB(), name: "gh", password: "p"))
        try v.add(Entry(id: idA(), name: "gh", password: "p"))
        try v.add(Entry(id: EntryID("cccc4444")!, name: "Alpha", password: "p"))
        #expect(v.sortedEntries.map(\.id.rawValue) == ["cccc4444", "aaaa2222", "bbbb3333"])
    }
}

Tests/KeycaskCoreTests/VaultCodecTests.swift:

import Foundation
import Testing

@testable import KeycaskCore

@Suite struct VaultCodecTests {
    @Test func roundTripsAndIsDeterministic() throws {
        var v = Vault()
        try v.add(
            Entry(
                id: EntryID("aaaa2222")!, name: "gh", username: "cmc", password: "p",
                url: "https://github.com", notes: "n", tags: ["dev"],
                now: Date(timeIntervalSince1970: 1_700_000_000)))
        let a = try VaultCodec.encode(v)
        let b = try VaultCodec.encode(v)
        #expect(a == b)
        #expect(try VaultCodec.decode(a) == v)
    }

    @Test func datesAreISO8601WholeSeconds() throws {
        var v = Vault()
        try v.add(
            Entry(id: EntryID("aaaa2222")!, name: "gh", password: "p",
                  now: Date(timeIntervalSince1970: 1_700_000_000)))
        let text = String(decoding: try VaultCodec.encode(v), as: UTF8.self)
        #expect(text.contains("\"created\":\"2023-11-14T22:13:20Z\""))
    }

    @Test func keysAreSorted() throws {
        var v = Vault()
        try v.add(Entry(id: EntryID("aaaa2222")!, name: "gh", password: "p"))
        let text = String(decoding: try VaultCodec.encode(v), as: UTF8.self)
        let created = text.range(of: "\"created\"")!.lowerBound
        let id = text.range(of: "\"id\"")!.lowerBound
        let updated = text.range(of: "\"updated\"")!.lowerBound
        #expect(created < id && id < updated)
    }

    @Test func garbageIsCorrupt() {
        #expect(throws: KeycaskError.self) { try VaultCodec.decode(Data("nope".utf8)) }
        do {
            _ = try VaultCodec.decode(Data("{\"entries\":[{\"id\":1}]}".utf8))
            Issue.record("expected corrupt")
        } catch let e as KeycaskError {
            #expect(e.exitCode == 1)
            #expect(e.message.hasPrefix("vault is corrupt:"))
        } catch {
            Issue.record("wrong error \(error)")
        }
    }
}
  • Step 2: Run tests to verify they fail

Run: swift test --filter 'VaultTests|VaultCodecTests' Expected: compile error, Vault not found.

  • Step 3: Write Vault.swift
import Foundation

public struct Vault: Codable, Equatable, Sendable {
    public var entries: [Entry]

    public init(entries: [Entry] = []) {
        self.entries = entries
    }

    public func entry(id: EntryID) -> Entry? {
        entries.first { $0.id == id }
    }

    public mutating func add(_ entry: Entry) throws {
        guard self.entry(id: entry.id) == nil else { throw KeycaskError.duplicateID(entry.id) }
        entries.append(entry)
    }

    public mutating func remove(id: EntryID) throws {
        guard let index = entries.firstIndex(where: { $0.id == id }) else {
            throw KeycaskError.notFound(id.rawValue)
        }
        entries.remove(at: index)
    }

    public mutating func update(
        id: EntryID, now: Date = .now, _ change: (inout Entry) -> Void
    ) throws {
        guard let index = entries.firstIndex(where: { $0.id == id }) else {
            throw KeycaskError.notFound(id.rawValue)
        }
        change(&entries[index])
        entries[index].tags = Entry.normalize(tags: entries[index].tags)
        entries[index].updated = Entry.truncateToSeconds(now)
    }

    public func resolve(_ ref: String) throws -> Entry {
        if let id = EntryID(ref), let hit = entry(id: id) {
            return hit
        }
        let byName = entries.filter { $0.name == ref }
        switch byName.count {
        case 0: throw KeycaskError.notFound(ref)
        case 1: return byName[0]
        default: throw KeycaskError.ambiguous(name: ref, candidates: byName)
        }
    }

    public func filter(tag: String) -> [Entry] {
        sortedEntries.filter { $0.hasTag(tag) }
    }

    public func search(_ query: String) -> [Entry] {
        sortedEntries.filter { $0.matches(query) }
    }

    public var sortedEntries: [Entry] {
        entries.sorted { a, b in
            let (la, lb) = (a.name.lowercased(), b.name.lowercased())
            return la == lb ? a.id.rawValue < b.id.rawValue : la < lb
        }
    }
}
  • Step 4: Write VaultCodec.swift
import Foundation

public enum VaultCodec {
    public static func makeEncoder() -> JSONEncoder {
        let encoder = JSONEncoder()
        encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
        encoder.dateEncodingStrategy = .iso8601
        return encoder
    }

    public static func makeDecoder() -> JSONDecoder {
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        return decoder
    }

    public static func encode(_ vault: Vault) throws -> Data {
        do {
            return try makeEncoder().encode(vault)
        } catch {
            throw KeycaskError.io("encode vault: \(error)")
        }
    }

    public static func decode(_ data: Data) throws -> Vault {
        do {
            return try makeDecoder().decode(Vault.self, from: data)
        } catch {
            throw KeycaskError.corrupt("\(error)")
        }
    }
}
  • Step 5: Run tests

Run: swift test --filter 'VaultTests|VaultCodecTests' Expected: 12 tests pass.

  • Step 6: Commit
git add Sources/KeycaskCore Tests/KeycaskCoreTests
git commit -m "Add Vault operations and deterministic JSON codec"

Task 6: Generator and word list

Files:

  • Create: Sources/KeycaskCore/Wordlist.swift (generated)
  • Create: Sources/KeycaskCore/Generator.swift
  • Create: NOTICE
  • Create: Tests/KeycaskCoreTests/GeneratorTests.swift

Interfaces:

  • Produces:
public enum Wordlist { public static let words: [String] }   // 7776 entries
public enum Generator {
    public static let alphabet: [Character]  // A-Z a-z 0-9 and !@#$%^&*()-_=+[]{};:,.<>?
    public static let defaultLength = 24
    public static let wordSeparator = "-"
    public static func password(length: Int) -> String
    public static func password(length: Int, using: inout some RandomNumberGenerator) -> String
    public static func passphrase(words: Int) -> String
    public static func passphrase(words: Int, using: inout some RandomNumberGenerator) -> String
}
  • Step 1: Generate Wordlist.swift from the EFF list
cd /Users/cmc/git/krz/keycask
curl -fsSL https://www.eff.org/files/2016/07/18/eff_large_wordlist.txt -o /tmp/eff.txt
test "$(wc -l < /tmp/eff.txt)" -eq 7776
{
  printf '// EFF long word list, https://www.eff.org/dice. See NOTICE.\n'
  printf 'let effLongWordlist = """\n'
  cut -f2 /tmp/eff.txt
  printf '"""\n\npublic enum Wordlist {\n'
  printf '    public static let words: [String] = effLongWordlist.split(separator: "\\n").map(String.init)\n'
  printf '}\n'
} > Sources/KeycaskCore/Wordlist.swift
rm /tmp/eff.txt
  • Step 2: Write NOTICE
The word list in Sources/KeycaskCore/Wordlist.swift is the EFF Long
Wordlist by the Electronic Frontier Foundation, licensed under the
Creative Commons Attribution 3.0 United States License.
https://www.eff.org/dice
https://creativecommons.org/licenses/by/3.0/us/
  • Step 3: Write the failing test

Tests/KeycaskCoreTests/GeneratorTests.swift:

import Testing

@testable import KeycaskCore

@Suite struct GeneratorTests {
    struct Counter: RandomNumberGenerator {
        var n: UInt64 = 0
        mutating func next() -> UInt64 {
            n &+= 0x9E37_79B9_7F4A_7C15
            return n
        }
    }

    @Test func wordlistHas7776UniqueWords() {
        #expect(Wordlist.words.count == 7776)
        #expect(Set(Wordlist.words).count == 7776)
        #expect(Wordlist.words.first == "abacus")
        #expect(Wordlist.words.allSatisfy { !$0.isEmpty && !$0.contains(" ") })
    }

    @Test func passwordHasRequestedLengthFromTheAlphabet() {
        let allowed = Set(Generator.alphabet)
        for length in [1, 8, 24, 64] {
            let p = Generator.password(length: length)
            #expect(p.count == length)
            #expect(p.allSatisfy { allowed.contains($0) })
        }
        #expect(Generator.password(length: 0) == "")
    }

    @Test func alphabetCoversAllClasses() {
        let s = String(Generator.alphabet)
        #expect(s.contains("A") && s.contains("z") && s.contains("7") && s.contains("!"))
        #expect(Set(Generator.alphabet).count == Generator.alphabet.count)
    }

    @Test func passphraseUsesWordsFromTheList() {
        let words = Set(Wordlist.words)
        let p = Generator.passphrase(words: 5)
        let parts = p.split(separator: "-").map(String.init)
        #expect(parts.count == 5)
        #expect(parts.allSatisfy { words.contains($0) })
        #expect(Generator.passphrase(words: 0) == "")
    }

    @Test func seededOutputIsReproducible() {
        var a = Counter()
        var b = Counter()
        #expect(Generator.password(length: 16, using: &a) == Generator.password(length: 16, using: &b))
        #expect(Generator.passphrase(words: 3, using: &a) == Generator.passphrase(words: 3, using: &b))
    }
}
  • Step 4: Run test to verify it fails

Run: swift test --filter GeneratorTests Expected: compile error, Generator not found.

  • Step 5: Write Generator.swift
public enum Generator {
    public static let alphabet: [Character] = Array(
        "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{};:,.<>?"
    )
    public static let defaultLength = 24
    public static let wordSeparator = "-"

    public static func password(length: Int) -> String {
        var rng = SystemRandomNumberGenerator()
        return password(length: length, using: &rng)
    }

    public static func password(length: Int, using rng: inout some RandomNumberGenerator) -> String {
        var chars: [Character] = []
        chars.reserveCapacity(max(length, 0))
        for _ in 0..<max(length, 0) {
            chars.append(alphabet[Int(rng.next(upperBound: UInt32(alphabet.count)))])
        }
        return String(chars)
    }

    public static func passphrase(words: Int) -> String {
        var rng = SystemRandomNumberGenerator()
        return passphrase(words: words, using: &rng)
    }

    public static func passphrase(words: Int, using rng: inout some RandomNumberGenerator) -> String {
        let list = Wordlist.words
        var picked: [String] = []
        for _ in 0..<max(words, 0) {
            picked.append(list[Int(rng.next(upperBound: UInt32(list.count)))])
        }
        return picked.joined(separator: wordSeparator)
    }
}
  • Step 6: Run tests and lint

Run: swift test --filter GeneratorTests && swift format lint --strict --recursive Sources Tests Expected: 5 tests pass. If the linter complains about the long string literal in Wordlist.swift, add "// swift-format-ignore-file" as its first line.

  • Step 7: Commit
git add NOTICE Sources/KeycaskCore Tests/KeycaskCoreTests
git commit -m "Add password and passphrase generator with EFF word list"

Task 7: Envelope

Files:

  • Create: Sources/KeycaskCore/Envelope.swift
  • Create: Tests/KeycaskCoreTests/EnvelopeTests.swift

Interfaces:

  • Consumes: KeycaskError.
  • Produces:
public struct Envelope: Codable, Equatable, Sendable {
    public struct KDFParams: Codable, Equatable, Sendable {
        public var name: String
        public var iterations: Int
        public var salt: Data
        public init(name: String, iterations: Int, salt: Data)
        public static func fresh(iterations: Int = Envelope.defaultIterations) -> KDFParams
    }
    public static let currentFormat = 1
    public static let defaultIterations = 600_000
    public static let kdfName = "pbkdf2-hmac-sha256"
    public static let saltLength = 16
    public var format: Int
    public var kdf: KDFParams
    public var box: Data

    public static func seal(_ plaintext: Data, passphrase: String, kdf: KDFParams) throws -> Envelope
    public func open(passphrase: String) throws -> Data       // cannotDecrypt / corrupt
    public init(parsing data: Data) throws                     // corrupt
    public func encoded() throws -> Data
    static func deriveKey(passphrase: String, kdf: KDFParams) throws -> SymmetricKey
}
  • Step 1: Write the failing test

Tests/KeycaskCoreTests/EnvelopeTests.swift:

import Crypto
import Foundation
import Testing

@testable import KeycaskCore

@Suite struct EnvelopeTests {
    // Low iteration count keeps the suite fast. Production uses Envelope.defaultIterations.
    let kdf = Envelope.KDFParams(
        name: Envelope.kdfName, iterations: 1_000, salt: Data(repeating: 7, count: 16))

    func hex(_ key: SymmetricKey) -> String {
        key.withUnsafeBytes { $0.map { String(format: "%02x", $0) }.joined() }
    }

    @Test func pbkdf2MatchesPublishedVectors() throws {
        let one = Envelope.KDFParams(name: Envelope.kdfName, iterations: 1, salt: Data("salt".utf8))
        #expect(
            hex(try Envelope.deriveKey(passphrase: "password", kdf: one))
                == "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b")
        let many = Envelope.KDFParams(name: Envelope.kdfName, iterations: 4096, salt: Data("salt".utf8))
        #expect(
            hex(try Envelope.deriveKey(passphrase: "password", kdf: many))
                == "c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a")
    }

    @Test func sealThenOpenRoundTrips() throws {
        let env = try Envelope.seal(Data("hello vault".utf8), passphrase: "pw", kdf: kdf)
        #expect(env.format == 1)
        #expect(env.kdf == kdf)
        #expect(try env.open(passphrase: "pw") == Data("hello vault".utf8))
    }

    @Test func wrongPassphraseCannotDecrypt() throws {
        let env = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
        #expect(throws: KeycaskError.cannotDecrypt) { try env.open(passphrase: "PW") }
    }

    @Test func tamperedBoxCannotDecrypt() throws {
        var env = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
        env.box[env.box.count - 1] ^= 0x01
        #expect(throws: KeycaskError.cannotDecrypt) { try env.open(passphrase: "pw") }
    }

    @Test func nonceIsFreshAndSaltIsKept() throws {
        let a = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
        let b = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
        #expect(a.box != b.box)
        #expect(a.kdf.salt == b.kdf.salt)
    }

    @Test func freshParamsUseDefaults() {
        let p = Envelope.KDFParams.fresh()
        #expect(p.name == "pbkdf2-hmac-sha256")
        #expect(p.iterations == 600_000)
        #expect(p.salt.count == 16)
        #expect(p.salt != Envelope.KDFParams.fresh().salt)
    }

    @Test func encodedShapeMatchesTheSpec() throws {
        let env = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
        let json = try JSONSerialization.jsonObject(with: env.encoded()) as! [String: Any]
        #expect(json["format"] as? Int == 1)
        let k = json["kdf"] as! [String: Any]
        #expect(k["name"] as? String == "pbkdf2-hmac-sha256")
        #expect(k["iterations"] as? Int == 1_000)
        #expect(Data(base64Encoded: k["salt"] as! String) == kdf.salt)
        #expect(Data(base64Encoded: json["box"] as! String) == env.box)
        #expect(try Envelope(parsing: env.encoded()) == env)
    }

    @Test func malformedInputsAreCorrupt() throws {
        #expect(throws: KeycaskError.self) { try Envelope(parsing: Data("not json".utf8)) }
        #expect(throws: KeycaskError.self) { try Envelope(parsing: Data("{\"format\":1}".utf8)) }

        var wrongFormat = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
        wrongFormat.format = 2
        #expect(throws: KeycaskError.corrupt("unsupported format 2")) {
            try wrongFormat.open(passphrase: "pw")
        }

        var wrongKDF = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
        wrongKDF.kdf.name = "argon2id"
        #expect(throws: KeycaskError.corrupt("unsupported kdf argon2id")) {
            try wrongKDF.open(passphrase: "pw")
        }

        var shortBox = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
        shortBox.box = Data([1, 2, 3])
        #expect(throws: KeycaskError.corrupt("box too short")) { try shortBox.open(passphrase: "pw") }
    }

    @Test func passphraseIsNFCNormalized() throws {
        let composed = "caf\u{00E9}"
        let decomposed = "cafe\u{0301}"
        let env = try Envelope.seal(Data("x".utf8), passphrase: composed, kdf: kdf)
        #expect(try env.open(passphrase: decomposed) == Data("x".utf8))
    }
}
  • Step 2: Run test to verify it fails

Run: swift test --filter EnvelopeTests Expected: compile error, Envelope not found.

  • Step 3: Write Envelope.swift
import Crypto
import Foundation
import _CryptoExtras

public struct Envelope: Codable, Equatable, Sendable {
    public struct KDFParams: Codable, Equatable, Sendable {
        public var name: String
        public var iterations: Int
        public var salt: Data

        public init(name: String, iterations: Int, salt: Data) {
            self.name = name
            self.iterations = iterations
            self.salt = salt
        }

        public static func fresh(iterations: Int = Envelope.defaultIterations) -> KDFParams {
            var rng = SystemRandomNumberGenerator()
            let salt = Data((0..<Envelope.saltLength).map { _ in UInt8.random(in: .min ... .max, using: &rng) })
            return KDFParams(name: Envelope.kdfName, iterations: iterations, salt: salt)
        }
    }

    public static let currentFormat = 1
    public static let defaultIterations = 600_000
    public static let kdfName = "pbkdf2-hmac-sha256"
    public static let saltLength = 16
    static let keyLength = 32
    static let minimumBoxLength = 12 + 16

    public var format: Int
    public var kdf: KDFParams
    public var box: Data

    public static func seal(_ plaintext: Data, passphrase: String, kdf: KDFParams) throws -> Envelope {
        let key = try deriveKey(passphrase: passphrase, kdf: kdf)
        do {
            let sealed = try ChaChaPoly.seal(plaintext, using: key)
            return Envelope(format: currentFormat, kdf: kdf, box: sealed.combined)
        } catch {
            throw KeycaskError.failure("encrypt: \(error)")
        }
    }

    public func open(passphrase: String) throws -> Data {
        guard format == Self.currentFormat else {
            throw KeycaskError.corrupt("unsupported format \(format)")
        }
        guard kdf.name == Self.kdfName else {
            throw KeycaskError.corrupt("unsupported kdf \(kdf.name)")
        }
        guard box.count >= Self.minimumBoxLength else {
            throw KeycaskError.corrupt("box too short")
        }
        let key = try Self.deriveKey(passphrase: passphrase, kdf: kdf)
        let sealed: ChaChaPoly.SealedBox
        do {
            sealed = try ChaChaPoly.SealedBox(combined: box)
        } catch {
            throw KeycaskError.corrupt("box is malformed")
        }
        do {
            return try ChaChaPoly.open(sealed, using: key)
        } catch {
            throw KeycaskError.cannotDecrypt
        }
    }

    public init(parsing data: Data) throws {
        do {
            self = try JSONDecoder().decode(Envelope.self, from: data)
        } catch {
            throw KeycaskError.corrupt("not a keycask vault: \(error)")
        }
    }

    public func encoded() throws -> Data {
        let encoder = JSONEncoder()
        encoder.outputFormatting = [.sortedKeys, .prettyPrinted]
        do {
            return try encoder.encode(self)
        } catch {
            throw KeycaskError.io("encode envelope: \(error)")
        }
    }

    init(format: Int, kdf: KDFParams, box: Data) {
        self.format = format
        self.kdf = kdf
        self.box = box
    }

    static func deriveKey(passphrase: String, kdf: KDFParams) throws -> SymmetricKey {
        let normalized = Array(passphrase.precomposedStringWithCanonicalMapping.utf8)
        do {
            return try KDF.Insecure.PBKDF2.deriveKey(
                from: normalized, salt: kdf.salt, using: .sha256,
                outputByteCount: keyLength, unsafeUncheckedRounds: kdf.iterations)
        } catch {
            throw KeycaskError.failure("derive key: \(error)")
        }
    }
}

unsafeUncheckedRounds is used because the checked overload rejects fewer than 210000 rounds, and the vault decides the count. KDFParams.fresh() always produces 600000.

  • Step 4: Run tests

Run: swift test --filter EnvelopeTests Expected: 9 tests pass.

  • Step 5: Commit
git add Sources/KeycaskCore Tests/KeycaskCoreTests
git commit -m "Add passphrase-encrypted vault envelope"

Task 8: Core merge request

Files: none new.

  • Step 1: Full suite and lint

Run: swift test && swift format lint --strict --recursive Sources Tests Package.swift Expected: all pass, no lint output.

  • Step 2: Push and open the MR
git push -u origin core
gitbay mr create --source core --target main --title "Core library: model, envelope, generator" --file - <<'EOF'
KeycaskCore: Entry, EntryID, Vault, VaultCodec, Envelope, Generator, Wordlist, KeycaskError.
Package scaffold and gitbay CI.
EOF
  • Step 3: Wait for CI, merge, clean up

Run gitbay build list --json until the build for core is green. Then:

gitbay mr merge <n> --strategy squash
git switch main && git pull && git branch -D core && git push origin --delete core

If the CI job fails on the swiftly install lines, read gitbay build log <n>, fix .gitbay/ci.yml on the branch, push, and re-check. Do not merge red.


Task 9: CLI skeleton, paths, passphrase, atomic write, init

Files:

  • Create: Sources/keycask/main.swift (replace)
  • Create: Sources/keycask/Keycask.swift
  • Create: Sources/keycask/Paths.swift
  • Create: Sources/keycask/Terminal.swift
  • Create: Sources/keycask/Passphrase.swift
  • Create: Sources/keycask/AtomicFile.swift
  • Create: Sources/keycask/OpenVault.swift
  • Create: Sources/keycask/Commands/Init.swift
  • Modify: Tests/KeycaskCLITests/CLI.swift
  • Create: Tests/KeycaskCLITests/InitTests.swift
  • Create: Tests/KeycaskCLITests/PathsTests.swift

Interfaces:

  • Consumes: Vault, VaultCodec, Envelope, KeycaskError.
  • Produces:
struct GlobalOptions: ParsableArguments { var vault: String? }
enum Paths { static func vaultURL(override: String?, environment: [String: String]) -> URL }
enum Terminal {
    static var stdinIsTTY: Bool
    static func readSecretLine(prompt: String) throws -> String
    static func readLine(prompt: String) -> String?
    static func confirm(_ question: String) -> Bool
}
enum Passphrase {
    static let variable = "KEYCASK_PASSPHRASE"
    static func obtain(confirm: Bool, environment: [String: String]) throws -> String
}
enum AtomicFile { static func write(_ data: Data, to url: URL) throws }
struct OpenVault {
    var vault: Vault
    let kdf: Envelope.KDFParams
    let url: URL
    let passphrase: String
    static func load(_ options: GlobalOptions) throws -> OpenVault
    static func create(_ options: GlobalOptions) throws -> URL
    func save() throws
}
struct CLI {   // test harness
    struct Result { let status: Int32; let stdout: String; let stderr: String }
    let dir: URL; let vault: URL; static let passphrase = "correct horse battery"
    init() throws
    func run(_ args: [String], stdin: String? = nil, passphrase: String? = CLI.passphrase,
             extraEnvironment: [String: String] = [:]) throws -> Result
}
  • Step 1: Create the branch
git switch -c cli
  • Step 2: Extend the test harness

Replace Tests/KeycaskCLITests/CLI.swift:

import Foundation
import Testing

enum Binary {
    static let url: URL = {
        #if os(macOS)
            for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") {
                return bundle.bundleURL.deletingLastPathComponent().appendingPathComponent("keycask")
            }
            fatalError("test bundle not found")
        #elseif os(Windows)
            return Bundle.main.bundleURL.appendingPathComponent("keycask.exe")
        #else
            return Bundle.main.bundleURL.appendingPathComponent("keycask")
        #endif
    }()
}

struct CLI {
    struct Result {
        let status: Int32
        let stdout: String
        let stderr: String
        var lines: [String] { stdout.split(separator: "\n").map(String.init) }
    }

    static let passphrase = "correct horse battery"

    let dir: URL
    let vault: URL

    init() throws {
        dir = FileManager.default.temporaryDirectory
            .appendingPathComponent("keycask-tests-\(UUID().uuidString)")
        try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        vault = dir.appendingPathComponent("vault.kc")
    }

    @discardableResult
    func run(
        _ args: [String],
        stdin: String? = nil,
        passphrase: String? = CLI.passphrase,
        extraEnvironment: [String: String] = [:]
    ) throws -> Result {
        let process = Process()
        process.executableURL = Binary.url
        process.arguments = args
        var env = ProcessInfo.processInfo.environment
        env["KEYCASK_VAULT"] = vault.path
        env.removeValue(forKey: "KEYCASK_PASSPHRASE")
        if let passphrase { env["KEYCASK_PASSPHRASE"] = passphrase }
        for (k, v) in extraEnvironment { env[k] = v }
        process.environment = env

        let out = Pipe()
        let err = Pipe()
        let input = Pipe()
        process.standardOutput = out
        process.standardError = err
        process.standardInput = input
        try process.run()
        if let stdin {
            input.fileHandleForWriting.write(Data(stdin.utf8))
        }
        try input.fileHandleForWriting.close()
        let outData = out.fileHandleForReading.readDataToEndOfFile()
        let errData = err.fileHandleForReading.readDataToEndOfFile()
        process.waitUntilExit()
        return Result(
            status: process.terminationStatus,
            stdout: String(decoding: outData, as: UTF8.self),
            stderr: String(decoding: errData, as: UTF8.self))
    }

    /// Runs `init` and returns the harness, for tests that need a vault.
    static func initialized() throws -> CLI {
        let cli = try CLI()
        let r = try cli.run(["init"])
        precondition(r.status == 0, "init failed: \(r.stderr)")
        return cli
    }
}

Remove the binaryIsBuilt test from this file; the harness replaces it.

  • Step 3: Write the failing tests

Tests/KeycaskCLITests/InitTests.swift:

import Foundation
import Testing

@Suite struct InitTests {
    @Test func initCreatesVaultAndPrintsPath() throws {
        let cli = try CLI()
        let r = try cli.run(["init"])
        #expect(r.status == 0)
        #expect(r.stdout.contains(cli.vault.path))
        #expect(FileManager.default.fileExists(atPath: cli.vault.path))
        let text = try String(contentsOf: cli.vault, encoding: .utf8)
        #expect(text.contains("\"format\" : 1"))
        #expect(text.contains("pbkdf2-hmac-sha256"))
        #expect(!text.contains("entries"))
    }

    @Test func initRefusesExistingVault() throws {
        let cli = try CLI.initialized()
        let r = try cli.run(["init"])
        #expect(r.status == 1)
        #expect(r.stderr.contains("already exists"))
    }

    @Test func initWithoutPassphraseOrTTYIsUsageError() throws {
        let cli = try CLI()
        let r = try cli.run(["init"], passphrase: nil)
        #expect(r.status == 2)
        #expect(r.stderr.contains("KEYCASK_PASSPHRASE"))
    }

    @Test func emptyPassphraseIsRejected() throws {
        let cli = try CLI()
        let r = try cli.run(["init"], passphrase: "")
        #expect(r.status == 1)
        #expect(r.stderr.contains("empty"))
    }

    @Test func vaultFlagBeatsEnvironment() throws {
        let cli = try CLI()
        let other = cli.dir.appendingPathComponent("elsewhere.kc")
        let r = try cli.run(["--vault", other.path, "init"])
        #expect(r.status == 0)
        #expect(FileManager.default.fileExists(atPath: other.path))
        #expect(!FileManager.default.fileExists(atPath: cli.vault.path))
    }

    @Test func unknownSubcommandIsUsageError() throws {
        let cli = try CLI()
        let r = try cli.run(["frobnicate"])
        #expect(r.status == 2)
        #expect(r.stderr.contains("Usage"))
    }

    @Test func helpExitsZero() throws {
        let cli = try CLI()
        let r = try cli.run(["--help"])
        #expect(r.status == 0)
        #expect(r.stdout.contains("init"))
    }

    #if !os(Windows)
        @Test func vaultIsPrivateOnUnix() throws {
            let cli = try CLI.initialized()
            let attrs = try FileManager.default.attributesOfItem(atPath: cli.vault.path)
            let mode = (attrs[.posixPermissions] as! NSNumber).intValue & 0o777
            #expect(mode == 0o600)
        }
    #endif

    @Test func noTempFileLeftBehind() throws {
        let cli = try CLI.initialized()
        let names = try FileManager.default.contentsOfDirectory(atPath: cli.dir.path)
        #expect(names == ["vault.kc"])
    }
}

Tests/KeycaskCLITests/PathsTests.swift tests Paths in process. It needs @testable import keycask, which works because the test target depends on the executable target:

import Foundation
import Testing

@testable import keycask

@Suite struct PathsTests {
    @Test func overrideWinsOverEverything() {
        let url = Paths.vaultURL(
            override: "/x/v.kc", environment: ["KEYCASK_VAULT": "/y", "HOME": "/h"])
        #expect(url.path == "/x/v.kc")
    }

    @Test func environmentVariableWinsOverDefaults() {
        let url = Paths.vaultURL(override: nil, environment: ["KEYCASK_VAULT": "/y/v.kc", "HOME": "/h"])
        #expect(url.path == "/y/v.kc")
    }

    #if os(Windows)
        @Test func windowsUsesLocalAppData() {
            let url = Paths.vaultURL(override: nil, environment: ["LOCALAPPDATA": "C:\\Users\\u\\AppData\\Local"])
            #expect(url.path.hasSuffix("keycask/vault.kc") || url.path.hasSuffix("keycask\\vault.kc"))
        }
    #else
        @Test func xdgDataHomeIsUsedWhenSet() {
            let url = Paths.vaultURL(override: nil, environment: ["XDG_DATA_HOME": "/d", "HOME": "/h"])
            #expect(url.path == "/d/keycask/vault.kc")
        }

        @Test func homeFallback() {
            let url = Paths.vaultURL(override: nil, environment: ["HOME": "/h"])
            #expect(url.path == "/h/.local/share/keycask/vault.kc")
        }
    #endif
}
  • Step 4: Run tests to verify they fail

Run: swift test --filter 'InitTests|PathsTests' Expected: compile error, Paths not found.

  • Step 5: Write Paths.swift
import Foundation

enum Paths {
    static let variable = "KEYCASK_VAULT"

    static func vaultURL(
        override: String?, environment: [String: String] = ProcessInfo.processInfo.environment
    ) -> URL {
        if let override { return URL(fileURLWithPath: override) }
        if let env = environment[variable], !env.isEmpty { return URL(fileURLWithPath: env) }
        return defaultDirectory(environment: environment)
            .appendingPathComponent("keycask").appendingPathComponent("vault.kc")
    }

    private static func defaultDirectory(environment: [String: String]) -> URL {
        #if os(Windows)
            let base = environment["LOCALAPPDATA"] ?? environment["USERPROFILE"] ?? "."
            return URL(fileURLWithPath: base)
        #else
            if let xdg = environment["XDG_DATA_HOME"], !xdg.isEmpty {
                return URL(fileURLWithPath: xdg)
            }
            let home = environment["HOME"] ?? "."
            return URL(fileURLWithPath: home).appendingPathComponent(".local/share")
        #endif
    }
}
  • Step 6: Write Terminal.swift
import Foundation
import KeycaskCore

#if canImport(Darwin)
    import Darwin
#elseif canImport(Glibc)
    import Glibc
#elseif canImport(Musl)
    import Musl
#elseif os(Windows)
    import CRT
    import WinSDK
#endif

enum Terminal {
    static var stdinIsTTY: Bool {
        #if os(Windows)
            return _isatty(_fileno(stdin)) != 0
        #else
            return isatty(STDIN_FILENO) != 0
        #endif
    }

    static func write(_ text: String) {
        FileHandle.standardError.write(Data(text.utf8))
    }

    static func readLine(prompt: String) -> String? {
        write(prompt)
        return Swift.readLine(strippingNewline: true)
    }

    static func confirm(_ question: String) -> Bool {
        guard let answer = readLine(prompt: question + " [y/N] ") else { return false }
        return answer.lowercased().hasPrefix("y")
    }

    static func readSecretLine(prompt: String) throws -> String {
        write(prompt)
        defer { write("\n") }
        return try withEchoDisabled { Swift.readLine(strippingNewline: true) ?? "" }
    }

    #if os(Windows)
        private static func withEchoDisabled<T>(_ body: () throws -> T) throws -> T {
            let handle = GetStdHandle(DWORD(bitPattern: -10))
            var mode: DWORD = 0
            guard GetConsoleMode(handle, &mode).boolValue else {
                throw KeycaskError.io("GetConsoleMode failed")
            }
            SetConsoleMode(handle, mode & ~DWORD(ENABLE_ECHO_INPUT))
            defer { SetConsoleMode(handle, mode) }
            return try body()
        }
    #else
        private static func withEchoDisabled<T>(_ body: () throws -> T) throws -> T {
            var original = termios()
            guard tcgetattr(STDIN_FILENO, &original) == 0 else {
                throw KeycaskError.io("tcgetattr failed")
            }
            var quiet = original
            quiet.c_lflag &= ~tcflag_t(ECHO)
            tcsetattr(STDIN_FILENO, TCSANOW, &quiet)
            defer { tcsetattr(STDIN_FILENO, TCSANOW, &original) }
            return try body()
        }
    #endif
}
  • Step 7: Write Passphrase.swift
import Foundation
import KeycaskCore

enum Passphrase {
    static let variable = "KEYCASK_PASSPHRASE"

    static func obtain(
        confirm: Bool, environment: [String: String] = ProcessInfo.processInfo.environment
    ) throws -> String {
        if let fromEnv = environment[variable] {
            return try validated(fromEnv)
        }
        guard Terminal.stdinIsTTY else {
            throw KeycaskError.usage("no passphrase: set \(variable) or run on a terminal")
        }
        let first = try Terminal.readSecretLine(prompt: "Passphrase: ")
        if confirm {
            let second = try Terminal.readSecretLine(prompt: "Confirm passphrase: ")
            guard first == second else { throw KeycaskError.failure("passphrases do not match") }
        }
        return try validated(first)
    }

    private static func validated(_ passphrase: String) throws -> String {
        guard !passphrase.isEmpty else { throw KeycaskError.failure("passphrase is empty") }
        return passphrase
    }
}
  • Step 8: Write AtomicFile.swift
import Foundation
import KeycaskCore

#if canImport(Darwin)
    import Darwin
#elseif canImport(Glibc)
    import Glibc
#elseif canImport(Musl)
    import Musl
#elseif os(Windows)
    import WinSDK
#endif

enum AtomicFile {
    static func write(_ data: Data, to url: URL) throws {
        let directory = url.deletingLastPathComponent()
        let temp = url.appendingPathExtension("tmp")
        do {
            try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
            try writePrivate(data, to: temp)
            try replace(url, with: temp)
        } catch let error as KeycaskError {
            try? FileManager.default.removeItem(at: temp)
            throw error
        } catch {
            try? FileManager.default.removeItem(at: temp)
            throw KeycaskError.io("write \(url.path): \(error)")
        }
    }

    #if os(Windows)
        private static func writePrivate(_ data: Data, to url: URL) throws {
            try data.write(to: url)
            let handle = try FileHandle(forWritingTo: url)
            try handle.synchronize()
            try handle.close()
        }

        private static func replace(_ target: URL, with temp: URL) throws {
            let ok = temp.path.withCString(encodedAs: UTF16.self) { src in
                target.path.withCString(encodedAs: UTF16.self) { dst in
                    MoveFileExW(src, dst, DWORD(MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
                }
            }
            guard ok.boolValue else { throw KeycaskError.io("rename \(temp.path): error \(GetLastError())") }
        }
    #else
        private static func writePrivate(_ data: Data, to url: URL) throws {
            let fd = open(url.path, O_WRONLY | O_CREAT | O_TRUNC, 0o600)
            guard fd >= 0 else {
                throw KeycaskError.io("open \(url.path): \(String(cString: strerror(errno)))")
            }
            let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
            try handle.write(contentsOf: data)
            try handle.synchronize()
            try handle.close()
        }

        private static func replace(_ target: URL, with temp: URL) throws {
            guard rename(temp.path, target.path) == 0 else {
                throw KeycaskError.io("rename \(temp.path): \(String(cString: strerror(errno)))")
            }
        }
    #endif
}
  • Step 9: Write OpenVault.swift
import Foundation
import KeycaskCore

struct OpenVault {
    var vault: Vault
    let kdf: Envelope.KDFParams
    let url: URL
    let passphrase: String

    static func load(_ options: GlobalOptions) throws -> OpenVault {
        let url = Paths.vaultURL(override: options.vault)
        let data: Data
        do {
            data = try Data(contentsOf: url)
        } catch let error as CocoaError where error.code == .fileReadNoSuchFile {
            throw KeycaskError.noVault(url.path)
        } catch {
            if !FileManager.default.fileExists(atPath: url.path) {
                throw KeycaskError.noVault(url.path)
            }
            throw KeycaskError.io("read \(url.path): \(error)")
        }
        let envelope = try Envelope(parsing: data)
        let passphrase = try Passphrase.obtain(confirm: false)
        let plaintext = try envelope.open(passphrase: passphrase)
        let vault = try VaultCodec.decode(plaintext)
        return OpenVault(vault: vault, kdf: envelope.kdf, url: url, passphrase: passphrase)
    }

    static func create(_ options: GlobalOptions) throws -> URL {
        let url = Paths.vaultURL(override: options.vault)
        guard !FileManager.default.fileExists(atPath: url.path) else {
            throw KeycaskError.vaultExists(url.path)
        }
        let passphrase = try Passphrase.obtain(confirm: true)
        let fresh = OpenVault(vault: Vault(), kdf: .fresh(), url: url, passphrase: passphrase)
        try fresh.save()
        return url
    }

    func save() throws {
        let plaintext = try VaultCodec.encode(vault)
        let envelope = try Envelope.seal(plaintext, passphrase: passphrase, kdf: kdf)
        try AtomicFile.write(try envelope.encoded(), to: url)
    }
}
  • Step 10: Write Keycask.swift and Commands/Init.swift

Sources/keycask/Keycask.swift:

import ArgumentParser

struct GlobalOptions: ParsableArguments {
    @Option(name: .long, help: "Path to the vault file.")
    var vault: String?
}

struct Keycask: ParsableCommand {
    static let configuration = CommandConfiguration(
        commandName: "keycask",
        abstract: "Command-line password manager. One passphrase-encrypted vault file.",
        subcommands: [Init.self]
    )
}

Sources/keycask/Commands/Init.swift:

import ArgumentParser

struct Init: ParsableCommand {
    static let configuration = CommandConfiguration(abstract: "Create an empty vault.")

    @OptionGroup var global: GlobalOptions

    func run() throws {
        let url = try OpenVault.create(global)
        print("created \(url.path)")
    }
}
  • Step 11: Write main.swift
import ArgumentParser
import Foundation
import KeycaskCore

func fail(_ text: String, code: Int32) -> Never {
    FileHandle.standardError.write(Data((text + "\n").utf8))
    exit(code)
}

do {
    var command = try Keycask.parseAsRoot()
    try command.run()
} catch let error as KeycaskError {
    fail(error.message, code: error.exitCode)
} catch {
    let text = Keycask.fullMessage(for: error)
    if Keycask.exitCode(for: error).isSuccess {
        print(text)
        exit(0)
    }
    fail(text, code: 2)
}
  • Step 12: Run tests

Run: swift test --filter 'InitTests|PathsTests' Expected: all pass. The init tests take about half a second each because of 600000 PBKDF2 rounds; that is expected.

  • Step 13: Lint and commit
swift format lint --strict --recursive Sources Tests
git add Sources/keycask Tests/KeycaskCLITests
git commit -m "Add CLI skeleton with init, paths, passphrase, atomic write"

Task 10: add, show, and output formatting

Files:

  • Create: Sources/keycask/Output.swift
  • Create: Sources/keycask/Commands/Add.swift
  • Create: Sources/keycask/Commands/Show.swift
  • Modify: Sources/keycask/Keycask.swift (register subcommands)
  • Create: Tests/KeycaskCLITests/AddShowTests.swift

Interfaces:

  • Consumes: OpenVault, Generator, Entry, Terminal, VaultCodec.makeEncoder().
  • Produces:
enum Output {
    static let mask = "********"
    static func masked(_ entry: Entry, reveal: Bool) -> Entry
    static func text(_ entry: Entry, reveal: Bool) -> String       // "field: value" lines
    static func table(_ entries: [Entry]) -> String                // id name username url
    static func json(_ entries: [Entry], reveal: Bool) throws -> String
    static func json(_ entry: Entry, reveal: Bool) throws -> String
    static func field(_ entry: Entry, named: String) throws -> String  // usage error for unknown field
}
enum PasswordInput {
    static func read(prompt: String) throws -> String   // TTY: hidden prompt; else first line of stdin
}
struct PasswordOptions: ParsableArguments { var generate: Bool; var length: Int?; var words: Int?; func validate(); func newPassword() throws -> String? }

Non-TTY password entry: when stdin is not a terminal, add and edit --password read the password as the first line of stdin. Add this sentence to the spec's CLI section in this task.

  • Step 1: Write the failing tests

Tests/KeycaskCLITests/AddShowTests.swift:

import Foundation
import Testing

@Suite struct AddShowTests {
    @Test func addReadsPasswordFromStdinAndPrintsID() throws {
        let cli = try CLI.initialized()
        let r = try cli.run(["add", "github", "-u", "cmc", "--url", "https://github.com", "--tag", "dev"],
                            stdin: "hunter2\n")
        #expect(r.status == 0)
        let id = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
        #expect(id.count == 8)

        let shown = try cli.run(["show", id])
        #expect(shown.status == 0)
        #expect(shown.stdout.contains("name: github"))
        #expect(shown.stdout.contains("username: cmc"))
        #expect(shown.stdout.contains("password: ********"))
        #expect(shown.stdout.contains("tags: dev"))
        #expect(!shown.stdout.contains("hunter2"))
    }

    @Test func showByNameRevealAndField() throws {
        let cli = try CLI.initialized()
        try cli.run(["add", "github"], stdin: "hunter2\n")
        let revealed = try cli.run(["show", "github", "--reveal"])
        #expect(revealed.stdout.contains("password: hunter2"))
        let field = try cli.run(["show", "github", "--field", "password"])
        #expect(field.stdout == "hunter2\n")
        let missing = try cli.run(["show", "github", "--field", "url"])
        #expect(missing.status == 0)
        #expect(missing.stdout == "\n")
        let unknown = try cli.run(["show", "github", "--field", "nope"])
        #expect(unknown.status == 2)
    }

    @Test func jsonMasksUnlessReveal() throws {
        let cli = try CLI.initialized()
        try cli.run(["add", "github", "-u", "cmc"], stdin: "hunter2\n")
        let masked = try cli.run(["show", "github", "--json"])
        let obj = try JSONSerialization.jsonObject(with: Data(masked.stdout.utf8)) as! [String: Any]
        #expect(obj["name"] as? String == "github")
        #expect(obj["username"] as? String == "cmc")
        #expect(obj["password"] as? String == "********")
        #expect((obj["id"] as? String)?.count == 8)
        #expect((obj["created"] as? String)?.hasSuffix("Z") == true)
        let revealed = try cli.run(["show", "github", "--json", "--reveal"])
        let obj2 = try JSONSerialization.jsonObject(with: Data(revealed.stdout.utf8)) as! [String: Any]
        #expect(obj2["password"] as? String == "hunter2")
    }

    @Test func addGenerateAndWords() throws {
        let cli = try CLI.initialized()
        try cli.run(["add", "a", "--generate"])
        try cli.run(["add", "b", "--generate", "--length", "40"])
        try cli.run(["add", "c", "--words", "4"])
        #expect(try cli.run(["show", "a", "--field", "password"]).stdout.count == 25)
        #expect(try cli.run(["show", "b", "--field", "password"]).stdout.count == 41)
        let words = try cli.run(["show", "c", "--field", "password"]).stdout
            .trimmingCharacters(in: .newlines).split(separator: "-")
        #expect(words.count == 4)
    }

    @Test func generateAndWordsTogetherIsUsageError() throws {
        let cli = try CLI.initialized()
        let r = try cli.run(["add", "a", "--generate", "--words", "3"])
        #expect(r.status == 2)
    }

    @Test func duplicateNamesAreAllowedAndAmbiguousOnShow() throws {
        let cli = try CLI.initialized()
        let a = try cli.run(["add", "gh", "-u", "one", "--generate"]).stdout.trimmingCharacters(in: .newlines)
        let b = try cli.run(["add", "gh", "-u", "two", "--generate"]).stdout.trimmingCharacters(in: .newlines)
        let r = try cli.run(["show", "gh"])
        #expect(r.status == 5)
        #expect(r.stderr.contains(a) && r.stderr.contains(b))
        #expect(try cli.run(["show", a]).stdout.contains("username: one"))
    }

    @Test func missingEntryIsNotFound() throws {
        let cli = try CLI.initialized()
        let r = try cli.run(["show", "nope"])
        #expect(r.status == 3)
        #expect(r.stderr == "nope: not found\n")
    }

    @Test func wrongPassphraseCannotDecrypt() throws {
        let cli = try CLI.initialized()
        let r = try cli.run(["show", "x"], passphrase: "wrong")
        #expect(r.status == 4)
        #expect(r.stderr.contains("cannot decrypt"))
    }

    @Test func missingVaultIsNotFound() throws {
        let cli = try CLI()
        let r = try cli.run(["show", "x"])
        #expect(r.status == 3)
        #expect(r.stderr.contains("keycask init"))
    }

    @Test func corruptVaultIsFailure() throws {
        let cli = try CLI.initialized()
        try Data("{}".utf8).write(to: cli.vault)
        let r = try cli.run(["show", "x"])
        #expect(r.status == 1)
        #expect(r.stderr.hasPrefix("vault is corrupt"))
    }
}
  • Step 2: Run tests to verify they fail

Run: swift test --filter AddShowTests Expected: failures, add is an unknown subcommand (exit 2).

  • Step 3: Write Output.swift
import Foundation
import KeycaskCore

enum Output {
    static let mask = "********"

    static func masked(_ entry: Entry, reveal: Bool) -> Entry {
        guard !reveal else { return entry }
        var copy = entry
        copy.password = mask
        return copy
    }

    static func text(_ entry: Entry, reveal: Bool) -> String {
        let e = masked(entry, reveal: reveal)
        var lines = ["id: \(e.id.rawValue)", "name: \(e.name)"]
        if let u = e.username { lines.append("username: \(u)") }
        lines.append("password: \(e.password)")
        if let u = e.url { lines.append("url: \(u)") }
        if !e.tags.isEmpty { lines.append("tags: \(e.tags.joined(separator: ", "))") }
        if let n = e.notes { lines.append("notes: \(n)") }
        lines.append("created: \(iso(e.created))")
        lines.append("updated: \(iso(e.updated))")
        return lines.joined(separator: "\n") + "\n"
    }

    static func table(_ entries: [Entry]) -> String {
        guard !entries.isEmpty else { return "" }
        let rows = entries.map { [$0.id.rawValue, $0.name, $0.username ?? "", $0.url ?? ""] }
        let widths = (0..<3).map { col in rows.map { $0[col].count }.max() ?? 0 }
        return rows.map { row in
            let padded = (0..<3).map { row[$0].padding(toLength: widths[$0], withPad: " ", startingAt: 0) }
            return (padded + [row[3]]).joined(separator: "  ")
                .trimmingCharacters(in: .whitespaces)
        }.joined(separator: "\n") + "\n"
    }

    static func json(_ entries: [Entry], reveal: Bool) throws -> String {
        try encode(entries.map { masked($0, reveal: reveal) })
    }

    static func json(_ entry: Entry, reveal: Bool) throws -> String {
        try encode(masked(entry, reveal: reveal))
    }

    static func field(_ entry: Entry, named name: String) throws -> String {
        switch name {
        case "id": entry.id.rawValue
        case "name": entry.name
        case "username": entry.username ?? ""
        case "password": entry.password
        case "url": entry.url ?? ""
        case "notes": entry.notes ?? ""
        case "tags": entry.tags.joined(separator: ",")
        case "created": iso(entry.created)
        case "updated": iso(entry.updated)
        default: throw KeycaskError.usage("unknown field \(name)")
        }
    }

    private static func encode(_ value: some Encodable) throws -> String {
        let encoder = VaultCodec.makeEncoder()
        encoder.outputFormatting.insert(.prettyPrinted)
        do {
            return String(decoding: try encoder.encode(value), as: UTF8.self) + "\n"
        } catch {
            throw KeycaskError.io("encode json: \(error)")
        }
    }

    private static func iso(_ date: Date) -> String {
        date.formatted(.iso8601)
    }
}
  • Step 4: Write password input and the shared password options

Add to Sources/keycask/Commands/Add.swift:

import ArgumentParser
import Foundation
import KeycaskCore

enum PasswordInput {
    static func read(prompt: String) throws -> String {
        if Terminal.stdinIsTTY {
            return try Terminal.readSecretLine(prompt: prompt)
        }
        guard let line = Swift.readLine(strippingNewline: true) else {
            throw KeycaskError.usage("no password: pass one on stdin or run on a terminal")
        }
        return line
    }
}

struct PasswordOptions: ParsableArguments {
    @Flag(name: .long, help: "Generate a random password.")
    var generate = false

    @Option(name: .long, help: "Length of the generated password (default 24).")
    var length: Int?

    @Option(name: .long, help: "Generate a passphrase of this many words instead.")
    var words: Int?

    mutating func validate() throws {
        if generate, words != nil {
            throw ValidationError("--generate and --words are mutually exclusive")
        }
        if let length, length < 1 { throw ValidationError("--length must be at least 1") }
        if let words, words < 1 { throw ValidationError("--words must be at least 1") }
        if length != nil, !generate, words == nil {
            throw ValidationError("--length requires --generate")
        }
    }

    /// nil means the caller must prompt.
    func newPassword() -> String? {
        if let words { return Generator.passphrase(words: words) }
        if generate { return Generator.password(length: length ?? Generator.defaultLength) }
        return nil
    }
}

struct Add: ParsableCommand {
    static let configuration = CommandConfiguration(abstract: "Add an entry.")

    @OptionGroup var global: GlobalOptions
    @Argument(help: "Entry name. Names may repeat; the printed id is unique.") var name: String
    @Option(name: [.short, .customLong("username")], help: "Username.") var username: String?
    @Option(name: .long, help: "URL.") var url: String?
    @Option(name: .long, help: "Notes.") var notes: String?
    @Option(name: .long, help: "Tag. Repeatable.") var tag: [String] = []
    @OptionGroup var password: PasswordOptions

    func run() throws {
        var open = try OpenVault.load(global)
        let secret = try password.newPassword() ?? PasswordInput.read(prompt: "Password: ")
        var entry = Entry(name: name, username: username, password: secret, url: url,
                          notes: notes, tags: tag)
        while open.vault.entry(id: entry.id) != nil {
            entry = Entry(name: name, username: username, password: secret, url: url,
                          notes: notes, tags: tag)
        }
        try open.vault.add(entry)
        try open.save()
        print(entry.id.rawValue)
    }
}
  • Step 5: Write Commands/Show.swift
import ArgumentParser
import KeycaskCore

struct Show: ParsableCommand {
    static let configuration = CommandConfiguration(abstract: "Show an entry.")

    @OptionGroup var global: GlobalOptions
    @Argument(help: "Entry id or name.") var ref: String
    @Flag(name: .long, help: "Show the password.") var reveal = false
    @Option(name: .long, help: "Print one field, unmasked.") var field: String?
    @Flag(name: .long, help: "JSON output.") var json = false

    func run() throws {
        let open = try OpenVault.load(global)
        let entry = try open.vault.resolve(ref)
        if let field {
            print(try Output.field(entry, named: field))
        } else if json {
            print(try Output.json(entry, reveal: reveal), terminator: "")
        } else {
            print(Output.text(entry, reveal: reveal), terminator: "")
        }
    }
}
  • Step 6: Register the subcommands

In Keycask.swift: subcommands: [Init.self, Add.self, Show.self].

  • Step 7: Amend the spec

In docs/superpowers/specs/2026-09-17-keycask-design.md, after the sentence beginning "Passphrase input:", add a paragraph:

Password input for `add` and `edit --password`: on a terminal, a hidden
prompt. Without a terminal, the first line of stdin. Neither available is
exit 2.
  • Step 8: Run tests

Run: swift test --filter AddShowTests Expected: 10 tests pass.

  • Step 9: Lint and commit
swift format lint --strict --recursive Sources Tests
git add Sources/keycask Tests/KeycaskCLITests docs
git commit -m "Add add and show commands with masked output"

Task 11: ls and find

Files:

  • Create: Sources/keycask/Commands/Ls.swift
  • Create: Sources/keycask/Commands/Find.swift
  • Modify: Sources/keycask/Keycask.swift
  • Create: Tests/KeycaskCLITests/LsFindTests.swift

Interfaces:

  • Consumes: OpenVault, Output.table, Output.json(_:[Entry]), Vault.filter(tag:), Vault.search, Vault.sortedEntries.

  • Step 1: Write the failing tests

import Foundation
import Testing

@Suite struct LsFindTests {
    func seeded() throws -> CLI {
        let cli = try CLI.initialized()
        try cli.run(["add", "github", "-u", "cmc", "--url", "https://github.com", "--tag", "Dev", "--generate"])
        try cli.run(["add", "bank", "--url", "https://bank.example", "--notes", "downtown branch", "--generate"])
        try cli.run(["add", "Alpha", "--tag", "dev", "--generate"])
        return cli
    }

    @Test func lsSortsByNameAndShowsColumns() throws {
        let cli = try seeded()
        let r = try cli.run(["ls"])
        #expect(r.status == 0)
        let names = r.lines.map { String($0.split(separator: " ", omittingEmptySubsequences: true)[1]) }
        #expect(names == ["Alpha", "bank", "github"])
        #expect(r.stdout.contains("cmc"))
        #expect(r.stdout.contains("https://github.com"))
    }

    @Test func lsTagFilterIsCaseInsensitive() throws {
        let cli = try seeded()
        let r = try cli.run(["ls", "--tag", "DEV"])
        #expect(r.lines.count == 2)
        #expect(!r.stdout.contains("bank"))
    }

    @Test func lsJsonIsAnArrayWithMaskedPasswords() throws {
        let cli = try seeded()
        let r = try cli.run(["ls", "--json"])
        let arr = try JSONSerialization.jsonObject(with: Data(r.stdout.utf8)) as! [[String: Any]]
        #expect(arr.count == 3)
        #expect(arr.allSatisfy { $0["password"] as? String == "********" })
    }

    @Test func emptyVaultListsNothing() throws {
        let cli = try CLI.initialized()
        let r = try cli.run(["ls"])
        #expect(r.status == 0)
        #expect(r.stdout == "")
        let j = try cli.run(["ls", "--json"])
        #expect(j.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == "[]")
    }

    @Test func findMatchesNotesURLTagsCaseInsensitively() throws {
        let cli = try seeded()
        #expect(try cli.run(["find", "DOWNTOWN"]).lines.count == 1)
        #expect(try cli.run(["find", "github.com"]).lines.count == 1)
        #expect(try cli.run(["find", "dev"]).lines.count == 2)
        let none = try cli.run(["find", "zzz"])
        #expect(none.status == 0)
        #expect(none.stdout == "")
    }

    @Test func findJson() throws {
        let cli = try seeded()
        let r = try cli.run(["find", "bank", "--json"])
        let arr = try JSONSerialization.jsonObject(with: Data(r.stdout.utf8)) as! [[String: Any]]
        #expect(arr.count == 1)
        #expect(arr[0]["name"] as? String == "bank")
    }
}
  • Step 2: Run tests to verify they fail

Run: swift test --filter LsFindTests Expected: failures, unknown subcommand.

  • Step 3: Write Ls.swift and Find.swift

Commands/Ls.swift:

import ArgumentParser
import KeycaskCore

struct Ls: ParsableCommand {
    static let configuration = CommandConfiguration(abstract: "List entries.")

    @OptionGroup var global: GlobalOptions
    @Option(name: .long, help: "Only entries with this tag.") var tag: String?
    @Flag(name: .long, help: "JSON output.") var json = false

    func run() throws {
        let open = try OpenVault.load(global)
        let entries = tag.map { open.vault.filter(tag: $0) } ?? open.vault.sortedEntries
        if json {
            print(try Output.json(entries, reveal: false), terminator: "")
        } else {
            print(Output.table(entries), terminator: "")
        }
    }
}

Commands/Find.swift:

import ArgumentParser
import KeycaskCore

struct Find: ParsableCommand {
    static let configuration = CommandConfiguration(abstract: "Search entries.")

    @OptionGroup var global: GlobalOptions
    @Argument(help: "Case-insensitive substring.") var query: String
    @Flag(name: .long, help: "JSON output.") var json = false

    func run() throws {
        let open = try OpenVault.load(global)
        let entries = open.vault.search(query)
        if json {
            print(try Output.json(entries, reveal: false), terminator: "")
        } else {
            print(Output.table(entries), terminator: "")
        }
    }
}

Register both: subcommands: [Init.self, Add.self, Show.self, Ls.self, Find.self].

  • Step 4: Run tests

Run: swift test --filter LsFindTests Expected: 6 tests pass.

  • Step 5: Lint and commit
swift format lint --strict --recursive Sources Tests
git add Sources/keycask Tests/KeycaskCLITests
git commit -m "Add ls and find commands"

Task 12: edit and rm

Files:

  • Create: Sources/keycask/Commands/Edit.swift
  • Create: Sources/keycask/Commands/Rm.swift
  • Modify: Sources/keycask/Keycask.swift
  • Create: Tests/KeycaskCLITests/EditRmTests.swift

Interfaces:

  • Consumes: OpenVault, Vault.update, Vault.remove, PasswordOptions, PasswordInput, Terminal.confirm, Terminal.stdinIsTTY.

  • Step 1: Write the failing tests

import Foundation
import Testing

@Suite struct EditRmTests {
    @Test func editChangesFieldsAndBumpsUpdated() throws {
        let cli = try CLI.initialized()
        try cli.run(["add", "gh", "--tag", "a", "--generate"])
        let before = try JSONSerialization.jsonObject(
            with: Data(try cli.run(["show", "gh", "--json"]).stdout.utf8)) as! [String: Any]
        let r = try cli.run([
            "edit", "gh", "--name", "github", "-u", "cmc", "--url", "https://x", "--notes", "n",
            "--tag", "b", "--untag", "a",
        ])
        #expect(r.status == 0)
        let after = try JSONSerialization.jsonObject(
            with: Data(try cli.run(["show", "github", "--json"]).stdout.utf8)) as! [String: Any]
        #expect(after["name"] as? String == "github")
        #expect(after["username"] as? String == "cmc")
        #expect(after["url"] as? String == "https://x")
        #expect(after["notes"] as? String == "n")
        #expect(after["tags"] as? [String] == ["b"])
        #expect(after["created"] as? String == before["created"] as? String)
        #expect(after["id"] as? String == before["id"] as? String)
    }

    @Test func editPasswordFromStdinAndGenerate() throws {
        let cli = try CLI.initialized()
        try cli.run(["add", "gh", "--generate"])
        try cli.run(["edit", "gh", "--password"], stdin: "newpass\n")
        #expect(try cli.run(["show", "gh", "--field", "password"]).stdout == "newpass\n")
        try cli.run(["edit", "gh", "--generate", "--length", "30"])
        #expect(try cli.run(["show", "gh", "--field", "password"]).stdout.count == 31)
    }

    @Test func editWithNoChangesIsUsageError() throws {
        let cli = try CLI.initialized()
        try cli.run(["add", "gh", "--generate"])
        let r = try cli.run(["edit", "gh"])
        #expect(r.status == 2)
    }

    @Test func editUnknownIsNotFound() throws {
        let cli = try CLI.initialized()
        #expect(try cli.run(["edit", "nope", "--url", "x"]).status == 3)
    }

    @Test func rmWithYesRemoves() throws {
        let cli = try CLI.initialized()
        let id = try cli.run(["add", "gh", "--generate"]).stdout.trimmingCharacters(in: .newlines)
        let r = try cli.run(["rm", id, "--yes"])
        #expect(r.status == 0)
        #expect(try cli.run(["show", id]).status == 3)
        #expect(try cli.run(["ls"]).stdout == "")
    }

    @Test func rmWithoutYesAndWithoutTTYIsUsageError() throws {
        let cli = try CLI.initialized()
        try cli.run(["add", "gh", "--generate"])
        let r = try cli.run(["rm", "gh"], stdin: "y\n")
        #expect(r.status == 2)
        #expect(r.stderr.contains("--yes"))
        #expect(try cli.run(["ls"]).lines.count == 1)
    }

    @Test func rmAmbiguousNameLists() throws {
        let cli = try CLI.initialized()
        try cli.run(["add", "gh", "--generate"])
        try cli.run(["add", "gh", "--generate"])
        let r = try cli.run(["rm", "gh", "--yes"])
        #expect(r.status == 5)
        #expect(try cli.run(["ls"]).lines.count == 2)
    }
}
  • Step 2: Run tests to verify they fail

Run: swift test --filter EditRmTests Expected: failures, unknown subcommand.

  • Step 3: Write Edit.swift
import ArgumentParser
import KeycaskCore

struct Edit: ParsableCommand {
    static let configuration = CommandConfiguration(abstract: "Change an entry.")

    @OptionGroup var global: GlobalOptions
    @Argument(help: "Entry id or name.") var ref: String
    @Option(name: .long, help: "New name.") var name: String?
    @Option(name: [.short, .customLong("username")], help: "New username.") var username: String?
    @Option(name: .long, help: "New URL.") var url: String?
    @Option(name: .long, help: "New notes.") var notes: String?
    @Option(name: .long, help: "Add a tag. Repeatable.") var tag: [String] = []
    @Option(name: .long, help: "Remove a tag. Repeatable.") var untag: [String] = []
    @Flag(name: .long, help: "Prompt for a new password.") var password = false
    @OptionGroup var generated: PasswordOptions

    mutating func validate() throws {
        let changes = [name, username, url, notes].contains { $0 != nil }
            || !tag.isEmpty || !untag.isEmpty || password || generated.generate || generated.words != nil
        guard changes else { throw ValidationError("nothing to change") }
        if password, generated.newPassword() != nil {
            throw ValidationError("--password cannot be combined with --generate or --words")
        }
    }

    func run() throws {
        var open = try OpenVault.load(global)
        let target = try open.vault.resolve(ref)
        let newSecret: String? =
            password ? try PasswordInput.read(prompt: "New password: ") : generated.newPassword()
        try open.vault.update(id: target.id) { e in
            if let name { e.name = name }
            if let username { e.username = username }
            if let url { e.url = url }
            if let notes { e.notes = notes }
            if let newSecret { e.password = newSecret }
            let drop = Set(untag.map { $0.lowercased() })
            e.tags = e.tags.filter { !drop.contains($0.lowercased()) } + tag
        }
        try open.save()
    }
}
  • Step 4: Write Rm.swift
import ArgumentParser
import KeycaskCore

struct Rm: ParsableCommand {
    static let configuration = CommandConfiguration(abstract: "Remove an entry.")

    @OptionGroup var global: GlobalOptions
    @Argument(help: "Entry id or name.") var ref: String
    @Flag(name: .long, help: "Do not ask for confirmation.") var yes = false

    func run() throws {
        var open = try OpenVault.load(global)
        let target = try open.vault.resolve(ref)
        if !yes {
            guard Terminal.stdinIsTTY else {
                throw KeycaskError.usage("refusing to remove without --yes when not on a terminal")
            }
            guard Terminal.confirm("remove \(target.name) (\(target.id.rawValue))?") else {
                throw KeycaskError.failure("aborted")
            }
        }
        try open.vault.remove(id: target.id)
        try open.save()
    }
}

Register: subcommands: [Init.self, Add.self, Show.self, Ls.self, Find.self, Edit.self, Rm.self].

  • Step 5: Run tests

Run: swift test --filter EditRmTests Expected: 7 tests pass.

  • Step 6: Lint and commit
swift format lint --strict --recursive Sources Tests
git add Sources/keycask Tests/KeycaskCLITests
git commit -m "Add edit and rm commands"

Task 13: generate

Files:

  • Create: Sources/keycask/Commands/Generate.swift
  • Modify: Sources/keycask/Keycask.swift
  • Create: Tests/KeycaskCLITests/GenerateTests.swift

Interfaces:

  • Consumes: Generator. --copy calls Clipboard.copyWithTimeout, which does not exist until Task 14; in this task --copy is declared but run() throws KeycaskError.failure("clipboard not available") when it is set. Task 14 replaces that line.

  • Step 1: Write the failing tests

import Foundation
import Testing

@Suite struct GenerateTests {
    @Test func defaultIs24Characters() throws {
        let cli = try CLI()
        let r = try cli.run(["generate"], passphrase: nil)
        #expect(r.status == 0)
        #expect(r.stdout.count == 25)
    }

    @Test func lengthAndWords() throws {
        let cli = try CLI()
        #expect(try cli.run(["generate", "--length", "12"], passphrase: nil).stdout.count == 13)
        let w = try cli.run(["generate", "--words", "6"], passphrase: nil).stdout
            .trimmingCharacters(in: .newlines).split(separator: "-")
        #expect(w.count == 6)
    }

    @Test func doesNotNeedAVault() throws {
        let cli = try CLI()
        #expect(!FileManager.default.fileExists(atPath: cli.vault.path))
        #expect(try cli.run(["generate"], passphrase: nil).status == 0)
    }

    @Test func lengthAndWordsTogetherIsUsageError() throws {
        let cli = try CLI()
        #expect(try cli.run(["generate", "--length", "3", "--words", "3"], passphrase: nil).status == 2)
    }
}
  • Step 2: Run tests to verify they fail

Run: swift test --filter GenerateTests Expected: failures, unknown subcommand.

  • Step 3: Write Generate.swift
import ArgumentParser
import KeycaskCore

struct Generate: ParsableCommand {
    static let configuration = CommandConfiguration(abstract: "Generate a password.")

    @Option(name: .long, help: "Password length (default 24).") var length: Int?
    @Option(name: .long, help: "Passphrase of this many words instead.") var words: Int?
    @Flag(name: .long, help: "Copy to the clipboard instead of printing.") var copy = false

    mutating func validate() throws {
        if length != nil, words != nil {
            throw ValidationError("--length and --words are mutually exclusive")
        }
        if let length, length < 1 { throw ValidationError("--length must be at least 1") }
        if let words, words < 1 { throw ValidationError("--words must be at least 1") }
    }

    func run() throws {
        let secret =
            words.map { Generator.passphrase(words: $0) }
            ?? Generator.password(length: length ?? Generator.defaultLength)
        if copy {
            throw KeycaskError.failure("clipboard not available")
        }
        print(secret)
    }
}

Register: add Generate.self to the subcommand list.

  • Step 4: Run tests

Run: swift test --filter GenerateTests Expected: 4 tests pass.

  • Step 5: Lint and commit
swift format lint --strict --recursive Sources Tests
git add Sources/keycask Tests/KeycaskCLITests
git commit -m "Add generate command"

Task 14: Clipboard, clip, daemon, generate --copy

Files:

  • Create: Sources/keycask/Clipboard.swift
  • Create: Sources/keycask/Commands/Clip.swift
  • Create: Sources/keycask/Commands/ClipboardDaemon.swift
  • Modify: Sources/keycask/Commands/Generate.swift
  • Modify: Sources/keycask/Keycask.swift
  • Create: Tests/KeycaskCLITests/ClipboardTests.swift

Interfaces:

  • Produces:
enum Clipboard {
    struct Handoff: Codable, Equatable { var secret: String; var previous: String }
    struct Tool { let copy: [String]; let paste: [String] }
    static let timeoutSeconds = 45
    static func shouldRestore(secret: String, current: String?) -> Bool
    static func findTool(path: String, fileManager: FileManager = .default) -> Tool?
    static func read() throws -> String
    static func write(_ text: String) throws
    static func copyWithTimeout(_ secret: String, seconds: Int = timeoutSeconds) throws
    static func runDaemon(seconds: Int) throws
}

The tests never touch the real clipboard. They cover the restore decision, tool discovery against a fake PATH, and the daemon's handoff parsing.

  • Step 1: Write the failing tests

Tests/KeycaskCLITests/ClipboardTests.swift:

import Foundation
import Testing

@testable import keycask

@Suite struct ClipboardTests {
    @Test func restoresOnlyWhenClipboardStillHoldsTheSecret() {
        #expect(Clipboard.shouldRestore(secret: "s", current: "s"))
        #expect(!Clipboard.shouldRestore(secret: "s", current: "user pasted"))
        #expect(!Clipboard.shouldRestore(secret: "s", current: nil))
    }

    @Test func handoffRoundTrips() throws {
        let h = Clipboard.Handoff(secret: "s3cret", previous: "old")
        let data = try JSONEncoder().encode(h)
        #expect(try JSONDecoder().decode(Clipboard.Handoff.self, from: data) == h)
    }

    @Test func findToolScansPathInOrder() throws {
        let dir = FileManager.default.temporaryDirectory
            .appendingPathComponent("keycask-clip-\(UUID().uuidString)")
        try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        #expect(Clipboard.findTool(path: dir.path) == nil)

        #if os(macOS)
            let names = ["pbcopy", "pbpaste"]
        #elseif os(Windows)
            let names = ["clip.exe", "powershell.exe"]
        #else
            let names = ["xclip"]
        #endif
        for n in names {
            let f = dir.appendingPathComponent(n)
            try Data("#!/bin/sh\n".utf8).write(to: f)
            try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: f.path)
        }
        let tool = Clipboard.findTool(path: dir.path)
        #expect(tool != nil)
        #expect(tool?.copy.first?.hasPrefix(dir.path) == true)
    }

    @Test func daemonWithoutHandoffFails() throws {
        let cli = try CLI()
        let r = try cli.run(["clipboard-daemon", "1"], stdin: "not json", passphrase: nil)
        #expect(r.status == 1)
    }

    @Test func daemonIsHiddenFromHelp() throws {
        let cli = try CLI()
        let r = try cli.run(["--help"], passphrase: nil)
        #expect(!r.stdout.contains("clipboard-daemon"))
        #expect(r.stdout.contains("clip"))
    }

    @Test func clipOfMissingEntryIsNotFoundBeforeTouchingClipboard() throws {
        let cli = try CLI.initialized()
        let r = try cli.run(["clip", "nope"])
        #expect(r.status == 3)
    }
}
  • Step 2: Run tests to verify they fail

Run: swift test --filter ClipboardTests Expected: compile error, Clipboard not found.

  • Step 3: Write Clipboard.swift
import Foundation
import KeycaskCore

enum Clipboard {
    struct Handoff: Codable, Equatable {
        var secret: String
        var previous: String
    }

    struct Tool: Equatable {
        let copy: [String]
        let paste: [String]
    }

    static let timeoutSeconds = 45

    static func shouldRestore(secret: String, current: String?) -> Bool {
        current == secret
    }

    static func findTool(
        path: String = ProcessInfo.processInfo.environment["PATH"] ?? "",
        fileManager: FileManager = .default
    ) -> Tool? {
        #if os(Windows)
            let separator: Character = ";"
            let candidates: [(copy: [String], paste: [String])] = [
                (["clip.exe"], ["powershell.exe", "-NoProfile", "-Command", "Get-Clipboard -Raw"])
            ]
        #elseif os(macOS)
            let separator: Character = ":"
            let candidates: [(copy: [String], paste: [String])] = [(["pbcopy"], ["pbpaste"])]
        #else
            let separator: Character = ":"
            let candidates: [(copy: [String], paste: [String])] = [
                (["wl-copy"], ["wl-paste", "--no-newline"]),
                (["xclip", "-selection", "clipboard"], ["xclip", "-selection", "clipboard", "-o"]),
            ]
        #endif
        let dirs = path.split(separator: separator).map(String.init)
        func locate(_ name: String) -> String? {
            for dir in dirs {
                let full = URL(fileURLWithPath: dir).appendingPathComponent(name).path
                if fileManager.isExecutableFile(atPath: full) { return full }
            }
            return nil
        }
        for candidate in candidates {
            guard let copy = locate(candidate.copy[0]), let paste = locate(candidate.paste[0]) else {
                continue
            }
            return Tool(
                copy: [copy] + candidate.copy.dropFirst(),
                paste: [paste] + candidate.paste.dropFirst())
        }
        return nil
    }

    static func read() throws -> String {
        let tool = try requireTool()
        let (status, output) = try runTool(tool.paste, input: nil)
        guard status == 0 else { return "" }
        return output
    }

    static func write(_ text: String) throws {
        let tool = try requireTool()
        let (status, _) = try runTool(tool.copy, input: text)
        guard status == 0 else { throw KeycaskError.failure("clipboard tool failed") }
    }

    static func copyWithTimeout(_ secret: String, seconds: Int = timeoutSeconds) throws {
        _ = try requireTool()
        let handoff = Handoff(secret: secret, previous: try read())
        let process = Process()
        process.executableURL = Bundle.main.executableURL
        process.arguments = ["clipboard-daemon", String(seconds)]
        process.standardOutput = FileHandle.nullDevice
        process.standardError = FileHandle.nullDevice
        let input = Pipe()
        process.standardInput = input
        do {
            try process.run()
            input.fileHandleForWriting.write(try JSONEncoder().encode(handoff))
            try input.fileHandleForWriting.close()
        } catch {
            throw KeycaskError.failure("start clipboard daemon: \(error)")
        }
    }

    static func runDaemon(seconds: Int) throws {
        let data = FileHandle.standardInput.readDataToEndOfFile()
        let handoff: Handoff
        do {
            handoff = try JSONDecoder().decode(Handoff.self, from: data)
        } catch {
            throw KeycaskError.failure("bad handoff")
        }
        try write(handoff.secret)
        Thread.sleep(forTimeInterval: TimeInterval(seconds))
        let current = try? read()
        guard shouldRestore(secret: handoff.secret, current: current) else { return }
        try write(handoff.previous)
    }

    private static func requireTool() throws -> Tool {
        guard let tool = findTool() else {
            #if os(Windows)
                let hint = "clip.exe and powershell.exe"
            #elseif os(macOS)
                let hint = "pbcopy and pbpaste"
            #else
                let hint = "wl-clipboard or xclip"
            #endif
            throw KeycaskError.failure("no clipboard tool found: install \(hint)")
        }
        return tool
    }

    private static func runTool(_ argv: [String], input: String?) throws -> (Int32, String) {
        let process = Process()
        process.executableURL = URL(fileURLWithPath: argv[0])
        process.arguments = Array(argv.dropFirst())
        let out = Pipe()
        process.standardOutput = out
        process.standardError = FileHandle.nullDevice
        let inPipe = Pipe()
        process.standardInput = inPipe
        do {
            try process.run()
        } catch {
            throw KeycaskError.failure("run \(argv[0]): \(error)")
        }
        if let input { inPipe.fileHandleForWriting.write(Data(input.utf8)) }
        try? inPipe.fileHandleForWriting.close()
        let data = out.fileHandleForReading.readDataToEndOfFile()
        process.waitUntilExit()
        return (process.terminationStatus, String(decoding: data, as: UTF8.self))
    }
}
  • Step 4: Write Clip.swift and ClipboardDaemon.swift

Commands/Clip.swift:

import ArgumentParser
import KeycaskCore

struct Clip: ParsableCommand {
    static let configuration = CommandConfiguration(
        abstract: "Copy a field to the clipboard. Clears after \(Clipboard.timeoutSeconds) seconds.")

    @OptionGroup var global: GlobalOptions
    @Argument(help: "Entry id or name.") var ref: String
    @Option(name: .long, help: "Field to copy (default password).") var field = "password"

    func run() throws {
        let open = try OpenVault.load(global)
        let entry = try open.vault.resolve(ref)
        let value = try Output.field(entry, named: field)
        try Clipboard.copyWithTimeout(value)
        print("copied \(field) of \(entry.name); clears in \(Clipboard.timeoutSeconds)s")
    }
}

Commands/ClipboardDaemon.swift:

import ArgumentParser

struct ClipboardDaemon: ParsableCommand {
    static let configuration = CommandConfiguration(
        commandName: "clipboard-daemon", shouldDisplay: false)

    @Argument var seconds: Int

    func run() throws {
        try Clipboard.runDaemon(seconds: seconds)
    }
}

In Generate.swift replace the throw KeycaskError.failure("clipboard not available") line with:

try Clipboard.copyWithTimeout(secret)
print("copied; clears in \(Clipboard.timeoutSeconds)s")
return

Register: subcommands: [Init.self, Add.self, Show.self, Ls.self, Find.self, Edit.self, Rm.self, Generate.self, Clip.self, ClipboardDaemon.self].

  • Step 5: Run tests

Run: swift test --filter ClipboardTests Expected: 6 tests pass.

  • Step 6: Manual check on macOS
swift build && KEYCASK_VAULT=/tmp/kc-manual.kc KEYCASK_PASSPHRASE=pw .build/debug/keycask init
KEYCASK_VAULT=/tmp/kc-manual.kc KEYCASK_PASSPHRASE=pw .build/debug/keycask add t --generate
KEYCASK_VAULT=/tmp/kc-manual.kc KEYCASK_PASSPHRASE=pw .build/debug/keycask clip t && pbpaste | wc -c
sleep 46 && pbpaste | wc -c
rm /tmp/kc-manual.kc

Expected: first wc -c prints 24, second prints the length of whatever was on the clipboard before (0 if it was empty). Record the actual output in the commit message body if it differs.

  • Step 7: Lint and commit
swift format lint --strict --recursive Sources Tests
git add Sources/keycask Tests/KeycaskCLITests
git commit -m "Add clipboard support: clip, generate --copy, timed clear"

Task 15: README, full verification, CLI merge request

Files:

  • Modify: README.md

  • Step 1: Write the README

# keycask

Command-line password manager. One passphrase-encrypted vault file.
Swift, runs on macOS, Linux, and Windows.

## install

```sh
swift build -c release
cp .build/release/keycask ~/.local/bin/
```

## use

```sh
keycask init
keycask add github -u cmc --url https://github.com --tag dev --generate
keycask add mail --words 6
keycask add bank                     # prompts for the password
keycask show github                  # password masked
keycask show github --reveal
keycask show github --field password # raw value, for scripts
keycask clip github                  # clipboard, clears after 45s
keycask ls --tag dev
keycask find example
keycask edit github --tag work --untag dev
keycask rm github --yes
keycask generate --words 5 --copy
```

Every read command takes `--json`. Passwords are masked unless `--reveal`.

Names are labels and may repeat. Every command that takes a name also
takes the entry's 8-character id, which `ls` and `add` print. An
ambiguous name lists the candidates.

## files

| what | default | override |
|---|---|---|
| vault | `~/.local/share/keycask/vault.kc` (`%LOCALAPPDATA%\keycask\vault.kc` on Windows) | `KEYCASK_VAULT`, `--vault` |
| passphrase | prompted | `KEYCASK_PASSPHRASE` |

The vault is a JSON envelope: PBKDF2-HMAC-SHA256 (600000 rounds) over
the passphrase, ChaCha20-Poly1305 over the entries. Writes are atomic.

## exit codes

0 ok, 1 failure, 2 usage, 3 not found, 4 cannot decrypt, 5 ambiguous name.

## develop

```sh
swift build
swift test
swift format lint --strict --recursive Sources Tests
```

Design: `docs/superpowers/specs/2026-09-17-keycask-design.md`.
  • Step 2: Full suite, lint, release build

Run: swift test && swift format lint --strict --recursive Sources Tests Package.swift && swift build -c release Expected: all tests pass, no lint output, release binary at .build/release/keycask.

  • Step 3: Commit, push, open MR
git add README.md
git commit -m "Write README for the CLI"
git push -u origin cli
gitbay mr create --source cli --target main --title "CLI: init, add, show, ls, find, edit, rm, generate, clip" --file - <<'EOF'
ArgumentParser commands over KeycaskCore. Paths, hidden passphrase prompt,
atomic writes, shell-out clipboard with timed clear. Black-box CLI tests
cover every command and exit code.
EOF
  • Step 4: Wait for CI, merge, clean up

Run gitbay build list --json until green. Then:

gitbay mr merge <n> --strategy squash
git switch main && git pull && git branch -D cli && git push origin --delete cli

Do not merge red. If Linux CI fails on something platform-specific (a Glibc import, posixPermissions), fix it on the branch and push again.