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

   1# keycask core + CLI Implementation Plan
   2
   3> **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.
   4
   5**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.
   6
   7**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.
   8
   9**Tech Stack:** Swift 6.4, SwiftPM, Swift Testing, swift-crypto 3.15 (`Crypto`, `_CryptoExtras`), swift-argument-parser 1.8.
  10
  11**Spec:** `docs/superpowers/specs/2026-09-17-keycask-design.md`
  12
  13## Global Constraints
  14
  15- `// swift-tools-version:6.4`, Swift 6 language mode, `platforms: [.macOS(.v14), .iOS(.v17)]`.
  16- Dependencies are exactly `apple/swift-crypto` and `apple/swift-argument-parser`. `Package.resolved` is committed.
  17- `KeycaskCore` imports only `Foundation`, `Crypto`, and `_CryptoExtras`. It never imports ArgumentParser, never spawns a process, never reads the environment.
  18- 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.
  19- `EntryID`: 8 characters from `abcdefghijkmnpqrstuvwxyz23456789`.
  20- Dates: ISO 8601 UTC, whole seconds. JSON: sorted keys.
  21- Exit codes: 0 ok, 1 failure, 2 usage, 3 not found, 4 cannot decrypt, 5 ambiguous.
  22- Masked secret string is exactly `********`.
  23- Clipboard timeout is 45 seconds. Password default length 24. Passphrase separator `-`.
  24- No code comments that mention the history of the project or how it used to work. No attribution trailers in commits.
  25- Every file passes `swift format lint --strict`.
  26- 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.
  27
  28## File structure
  29
  30```
  31Package.swift
  32Package.resolved
  33NOTICE
  34.gitbay/ci.yml
  35Sources/KeycaskCore/
  36  KeycaskError.swift      error enum, exit codes, messages
  37  EntryID.swift           8-char random ID, Codable as a string
  38  Entry.swift             entry struct, tag normalization, second-truncated dates
  39  Vault.swift             entries, add/remove/update, resolve, filter, search
  40  VaultCodec.swift        deterministic JSON encode/decode of Vault
  41  Envelope.swift          file envelope, PBKDF2 + ChaChaPoly seal/open, parse/encode
  42  Generator.swift         random password and passphrase
  43  Wordlist.swift          EFF long list as one string literal (generated file)
  44Sources/keycask/
  45  main.swift              parse, run, map errors to exit codes
  46  Keycask.swift           root command, GlobalOptions
  47  Paths.swift             vault path resolution
  48  Terminal.swift          isatty, echo-off line read, y/N confirm
  49  Passphrase.swift        env var or prompt
  50  AtomicFile.swift        temp + fsync + rename, 0600 on Unix, MoveFileExW on Windows
  51  OpenVault.swift         load/save/create: ties paths, passphrase, envelope, codec, atomic write
  52  Output.swift            text, table, JSON, masking, --field
  53  Clipboard.swift         tool discovery, read/write, daemon handoff
  54  Commands/Init.swift
  55  Commands/Add.swift
  56  Commands/Show.swift
  57  Commands/Ls.swift
  58  Commands/Find.swift
  59  Commands/Edit.swift
  60  Commands/Rm.swift
  61  Commands/Generate.swift
  62  Commands/Clip.swift
  63  Commands/ClipboardDaemon.swift
  64Tests/KeycaskCoreTests/
  65  EntryIDTests.swift
  66  EntryTests.swift
  67  VaultTests.swift
  68  VaultCodecTests.swift
  69  EnvelopeTests.swift
  70  GeneratorTests.swift
  71  KeycaskErrorTests.swift
  72Tests/KeycaskCLITests/
  73  CLI.swift               harness: locate binary, temp vault, run with env
  74  InitTests.swift
  75  AddShowTests.swift
  76  LsFindTests.swift
  77  EditRmTests.swift
  78  GenerateTests.swift
  79  ClipboardTests.swift
  80  PathsTests.swift
  81```
  82
  83---
  84
  85### Task 1: Package scaffold and CI
  86
  87**Files:**
  88- Create: `Package.swift`
  89- Create: `Sources/KeycaskCore/KeycaskCore.swift` (temporary, deleted in Task 2)
  90- Create: `Sources/keycask/main.swift` (replaced in Task 9)
  91- Create: `Tests/KeycaskCoreTests/SmokeTests.swift` (deleted in Task 2)
  92- Create: `.gitbay/ci.yml`
  93- Create: `.swift-format`
  94
  95**Interfaces:**
  96- Produces: the package layout every later task adds files to.
  97
  98- [ ] **Step 1: Create the branch**
  99
 100```bash
 101cd /Users/cmc/git/krz/keycask && git switch -c core
 102```
 103
 104- [ ] **Step 2: Write Package.swift**
 105
 106```swift
 107// swift-tools-version:6.4
 108import PackageDescription
 109
 110let package = Package(
 111    name: "keycask",
 112    platforms: [.macOS(.v14), .iOS(.v17)],
 113    products: [
 114        .library(name: "KeycaskCore", targets: ["KeycaskCore"]),
 115        .executable(name: "keycask", targets: ["keycask"]),
 116    ],
 117    dependencies: [
 118        .package(url: "https://github.com/apple/swift-crypto", from: "3.15.0"),
 119        .package(url: "https://github.com/apple/swift-argument-parser", from: "1.8.0"),
 120    ],
 121    targets: [
 122        .target(
 123            name: "KeycaskCore",
 124            dependencies: [
 125                .product(name: "Crypto", package: "swift-crypto"),
 126                .product(name: "_CryptoExtras", package: "swift-crypto"),
 127            ]
 128        ),
 129        .executableTarget(
 130            name: "keycask",
 131            dependencies: [
 132                "KeycaskCore",
 133                .product(name: "ArgumentParser", package: "swift-argument-parser"),
 134            ]
 135        ),
 136        .testTarget(name: "KeycaskCoreTests", dependencies: ["KeycaskCore"]),
 137        .testTarget(name: "KeycaskCLITests", dependencies: ["keycask"]),
 138    ]
 139)
 140```
 141
 142- [ ] **Step 3: Write placeholder sources so the package builds**
 143
 144`Sources/KeycaskCore/KeycaskCore.swift`:
 145
 146```swift
 147public enum KeycaskCore {
 148    public static let name = "keycask"
 149}
 150```
 151
 152`Sources/keycask/main.swift`:
 153
 154```swift
 155import KeycaskCore
 156
 157print(KeycaskCore.name)
 158```
 159
 160`Tests/KeycaskCoreTests/SmokeTests.swift`:
 161
 162```swift
 163import Testing
 164
 165@testable import KeycaskCore
 166
 167@Test func packageBuilds() {
 168    #expect(KeycaskCore.name == "keycask")
 169}
 170```
 171
 172`Tests/KeycaskCLITests/CLI.swift` (a real file, extended in Task 9; this version only locates the binary):
 173
 174```swift
 175import Foundation
 176import Testing
 177
 178enum Binary {
 179    static let url: URL = {
 180        #if os(macOS)
 181            for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") {
 182                return bundle.bundleURL.deletingLastPathComponent().appendingPathComponent("keycask")
 183            }
 184            fatalError("test bundle not found")
 185        #elseif os(Windows)
 186            return Bundle.main.bundleURL.appendingPathComponent("keycask.exe")
 187        #else
 188            return Bundle.main.bundleURL.appendingPathComponent("keycask")
 189        #endif
 190    }()
 191}
 192
 193@Test func binaryIsBuilt() {
 194    #expect(FileManager.default.isExecutableFile(atPath: Binary.url.path))
 195}
 196```
 197
 198- [ ] **Step 4: Write .swift-format**
 199
 200```json
 201{
 202  "version": 1,
 203  "indentation": { "spaces": 4 },
 204  "lineLength": 100,
 205  "maximumBlankLines": 1,
 206  "respectsExistingLineBreaks": true,
 207  "rules": {
 208    "AlwaysUseLowerCamelCase": true,
 209    "NeverForceUnwrap": false,
 210    "NeverUseImplicitlyUnwrappedOptionals": true
 211  }
 212}
 213```
 214
 215- [ ] **Step 5: Build and test**
 216
 217Run: `swift build && swift test`
 218Expected: `Build complete`, two tests pass. `Package.resolved` now exists.
 219
 220- [ ] **Step 6: Lint**
 221
 222Run: `swift format lint --strict --recursive Sources Tests Package.swift`
 223Expected: no output. If it reports findings, run `swift format --in-place --recursive Sources Tests Package.swift` and re-lint.
 224
 225- [ ] **Step 7: Write .gitbay/ci.yml**
 226
 227```yaml
 228# Each step runs in its own `sh -c`; exports do not survive between steps.
 229# swiftly installs into $HOME, which persists across builds.
 230jobs:
 231  build:
 232    steps:
 233      - |
 234        set -eu
 235        command -v curl >/dev/null || { echo "runner is missing: curl"; exit 1; }
 236        if ! command -v "$HOME/.local/bin/swiftly" >/dev/null 2>&1; then
 237          curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz" | tar -xz -C /tmp
 238          /tmp/swiftly init --assume-yes --skip-install --quiet-shell-followup
 239        fi
 240        . "$HOME/.local/share/swiftly/env.sh"
 241        swiftly install --use 6.4
 242        swift format lint --strict --recursive Sources Tests Package.swift
 243        swift build
 244  test:
 245    steps:
 246      - |
 247        set -eu
 248        . "$HOME/.local/share/swiftly/env.sh"
 249        swiftly install --use 6.4
 250        swift test
 251    paths-ignore:
 252      - docs/**
 253```
 254
 255- [ ] **Step 8: Commit**
 256
 257```bash
 258git add Package.swift Package.resolved .swift-format .gitbay/ci.yml Sources Tests
 259git commit -m "Add package scaffold and CI"
 260```
 261
 262---
 263
 264### Task 2: KeycaskError
 265
 266**Files:**
 267- Create: `Sources/KeycaskCore/KeycaskError.swift`
 268- Create: `Tests/KeycaskCoreTests/KeycaskErrorTests.swift`
 269- Delete: `Sources/KeycaskCore/KeycaskCore.swift`, `Tests/KeycaskCoreTests/SmokeTests.swift`
 270
 271**Interfaces:**
 272- 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`.
 273- 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.
 274
 275- [ ] **Step 1: Delete placeholders**
 276
 277```bash
 278git rm -q Sources/KeycaskCore/KeycaskCore.swift Tests/KeycaskCoreTests/SmokeTests.swift
 279```
 280
 281- [ ] **Step 2: Write the failing test**
 282
 283`Tests/KeycaskCoreTests/KeycaskErrorTests.swift`:
 284
 285```swift
 286import Testing
 287
 288@testable import KeycaskCore
 289
 290@Suite struct KeycaskErrorTests {
 291    @Test func exitCodesFollowTheSpec() {
 292        #expect(KeycaskError.failure("x").exitCode == 1)
 293        #expect(KeycaskError.io("x").exitCode == 1)
 294        #expect(KeycaskError.corrupt("x").exitCode == 1)
 295        #expect(KeycaskError.vaultExists("x").exitCode == 1)
 296        #expect(KeycaskError.duplicateID("abcd2345").exitCode == 1)
 297        #expect(KeycaskError.usage("x").exitCode == 2)
 298        #expect(KeycaskError.notFound("x").exitCode == 3)
 299        #expect(KeycaskError.noVault("/p").exitCode == 3)
 300        #expect(KeycaskError.cannotDecrypt.exitCode == 4)
 301        #expect(KeycaskError.ambiguous(name: "gh", candidates: []).exitCode == 5)
 302    }
 303
 304    @Test func messagesNameTheSubject() {
 305        #expect(KeycaskError.notFound("gh").message == "gh: not found")
 306        #expect(KeycaskError.noVault("/v").message == "vault /v not found (run `keycask init`)")
 307        #expect(KeycaskError.vaultExists("/v").message == "vault /v already exists")
 308        #expect(KeycaskError.cannotDecrypt.message == "cannot decrypt: wrong passphrase or damaged vault")
 309        #expect(KeycaskError.corrupt("bad json").message == "vault is corrupt: bad json")
 310    }
 311}
 312```
 313
 314- [ ] **Step 3: Run test to verify it fails**
 315
 316Run: `swift test --filter KeycaskErrorTests`
 317Expected: compile error, `KeycaskError` not found.
 318
 319- [ ] **Step 4: Write the implementation**
 320
 321`Sources/KeycaskCore/KeycaskError.swift`:
 322
 323```swift
 324public enum KeycaskError: Error, Equatable, Sendable {
 325    case notFound(String)
 326    case ambiguous(name: String, candidates: [String])
 327    case cannotDecrypt
 328    case corrupt(String)
 329    case vaultExists(String)
 330    case noVault(String)
 331    case duplicateID(String)
 332    case io(String)
 333    case usage(String)
 334    case failure(String)
 335
 336    public var exitCode: Int32 {
 337        switch self {
 338        case .failure, .io, .corrupt, .vaultExists, .duplicateID: 1
 339        case .usage: 2
 340        case .notFound, .noVault: 3
 341        case .cannotDecrypt: 4
 342        case .ambiguous: 5
 343        }
 344    }
 345
 346    public var message: String {
 347        switch self {
 348        case .notFound(let what): "\(what): not found"
 349        case .ambiguous(let name, let candidates):
 350            (["\(name): ambiguous, use an id:"] + candidates).joined(separator: "\n")
 351        case .cannotDecrypt: "cannot decrypt: wrong passphrase or damaged vault"
 352        case .corrupt(let why): "vault is corrupt: \(why)"
 353        case .vaultExists(let path): "vault \(path) already exists"
 354        case .noVault(let path): "vault \(path) not found (run `keycask init`)"
 355        case .duplicateID(let id): "duplicate id \(id)"
 356        case .io(let why): why
 357        case .usage(let why): why
 358        case .failure(let why): why
 359        }
 360    }
 361}
 362```
 363
 364- [ ] **Step 5: Run tests**
 365
 366Run: `swift test --filter KeycaskErrorTests`
 367Expected: 2 tests pass.
 368
 369- [ ] **Step 6: Commit**
 370
 371```bash
 372git add -A Sources/KeycaskCore Tests/KeycaskCoreTests
 373git commit -m "Add KeycaskError with exit codes"
 374```
 375
 376---
 377
 378### Task 3: EntryID
 379
 380**Files:**
 381- Create: `Sources/KeycaskCore/EntryID.swift`
 382- Create: `Tests/KeycaskCoreTests/EntryIDTests.swift`
 383- Modify: `Sources/KeycaskCore/KeycaskError.swift` (`duplicateID(EntryID)`)
 384- Modify: `Tests/KeycaskCoreTests/KeycaskErrorTests.swift`
 385
 386**Interfaces:**
 387- 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.
 388
 389- [ ] **Step 1: Write the failing test**
 390
 391`Tests/KeycaskCoreTests/EntryIDTests.swift`:
 392
 393```swift
 394import Foundation
 395import Testing
 396
 397@testable import KeycaskCore
 398
 399@Suite struct EntryIDTests {
 400    @Test func randomIDsHaveLengthEightFromTheAlphabet() {
 401        let allowed = Set(EntryID.alphabet)
 402        for _ in 0..<200 {
 403            let id = EntryID.random()
 404            #expect(id.rawValue.count == 8)
 405            #expect(id.rawValue.allSatisfy { allowed.contains($0) })
 406        }
 407    }
 408
 409    @Test func alphabetExcludesAmbiguousCharacters() {
 410        let alphabet = Set(EntryID.alphabet)
 411        #expect(alphabet.count == 32)
 412        for bad in ["l", "o", "0", "1"] {
 413            #expect(!alphabet.contains(Character(bad)))
 414        }
 415    }
 416
 417    @Test func parsingValidatesLengthAndAlphabet() {
 418        #expect(EntryID("abcd2345") != nil)
 419        #expect(EntryID("abcd234") == nil)
 420        #expect(EntryID("abcd23456") == nil)
 421        #expect(EntryID("abcd234l") == nil)
 422        #expect(EntryID("ABCD2345") == nil)
 423    }
 424
 425    @Test func codableIsABareString() throws {
 426        let id = EntryID("abcd2345")!
 427        let data = try JSONEncoder().encode([id])
 428        #expect(String(decoding: data, as: UTF8.self) == "[\"abcd2345\"]")
 429        let back = try JSONDecoder().decode([EntryID].self, from: data)
 430        #expect(back == [id])
 431        #expect(throws: DecodingError.self) {
 432            try JSONDecoder().decode([EntryID].self, from: Data("[\"bad\"]".utf8))
 433        }
 434    }
 435
 436    @Test func seededGeneratorIsDeterministic() {
 437        struct Counter: RandomNumberGenerator {
 438            var n: UInt64 = 0
 439            mutating func next() -> UInt64 {
 440                n += 1
 441                return n
 442            }
 443        }
 444        var a = Counter()
 445        var b = Counter()
 446        #expect(EntryID.random(using: &a) == EntryID.random(using: &b))
 447    }
 448}
 449```
 450
 451- [ ] **Step 2: Run test to verify it fails**
 452
 453Run: `swift test --filter EntryIDTests`
 454Expected: compile error, `EntryID` not found.
 455
 456- [ ] **Step 3: Write the implementation**
 457
 458`Sources/KeycaskCore/EntryID.swift`:
 459
 460```swift
 461public struct EntryID: Hashable, Sendable, CustomStringConvertible {
 462    public static let alphabet: [Character] = Array("abcdefghijkmnpqrstuvwxyz23456789")
 463    public static let length = 8
 464
 465    public let rawValue: String
 466
 467    public init?(_ raw: String) {
 468        guard raw.count == Self.length else { return nil }
 469        let allowed = Set(Self.alphabet)
 470        guard raw.allSatisfy({ allowed.contains($0) }) else { return nil }
 471        rawValue = raw
 472    }
 473
 474    public static func random() -> EntryID {
 475        var rng = SystemRandomNumberGenerator()
 476        return random(using: &rng)
 477    }
 478
 479    public static func random(using rng: inout some RandomNumberGenerator) -> EntryID {
 480        var chars: [Character] = []
 481        chars.reserveCapacity(length)
 482        for _ in 0..<length {
 483            chars.append(alphabet[Int(rng.next(upperBound: UInt32(alphabet.count)))])
 484        }
 485        return EntryID(String(chars))!
 486    }
 487
 488    public var description: String { rawValue }
 489}
 490
 491extension EntryID: Codable {
 492    public init(from decoder: any Decoder) throws {
 493        let raw = try decoder.singleValueContainer().decode(String.self)
 494        guard let id = EntryID(raw) else {
 495            throw DecodingError.dataCorrupted(
 496                .init(codingPath: decoder.codingPath, debugDescription: "invalid entry id \(raw)"))
 497        }
 498        self = id
 499    }
 500
 501    public func encode(to encoder: any Encoder) throws {
 502        var container = encoder.singleValueContainer()
 503        try container.encode(rawValue)
 504    }
 505}
 506```
 507
 508- [ ] **Step 4: Switch `duplicateID` to the real type**
 509
 510In `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")!)`.
 511
 512- [ ] **Step 5: Run tests**
 513
 514Run: `swift test --filter 'EntryIDTests|KeycaskErrorTests'`
 515Expected: 7 tests pass.
 516
 517- [ ] **Step 6: Commit**
 518
 519```bash
 520git add Sources/KeycaskCore Tests/KeycaskCoreTests
 521git commit -m "Add EntryID"
 522```
 523
 524---
 525
 526### Task 4: Entry
 527
 528**Files:**
 529- Create: `Sources/KeycaskCore/Entry.swift`
 530- Create: `Tests/KeycaskCoreTests/EntryTests.swift`
 531- Modify: `Sources/KeycaskCore/KeycaskError.swift` (`ambiguous(name:candidates: [Entry])`)
 532- Modify: `Tests/KeycaskCoreTests/KeycaskErrorTests.swift`
 533
 534**Interfaces:**
 535- Consumes: `EntryID`.
 536- Produces:
 537
 538```swift
 539public struct Entry: Codable, Equatable, Sendable {
 540    public let id: EntryID
 541    public var name: String
 542    public var username: String?
 543    public var password: String
 544    public var url: String?
 545    public var notes: String?
 546    public var tags: [String]
 547    public let created: Date
 548    public var updated: Date
 549
 550    public init(id: EntryID = .random(), name: String, username: String? = nil,
 551                password: String, url: String? = nil, notes: String? = nil,
 552                tags: [String] = [], now: Date = .now)
 553    public static func normalize(tags: [String]) -> [String]
 554    public static func truncateToSeconds(_ date: Date) -> Date
 555    public func hasTag(_ tag: String) -> Bool
 556    public func matches(_ query: String) -> Bool
 557}
 558```
 559
 560- [ ] **Step 1: Write the failing test**
 561
 562`Tests/KeycaskCoreTests/EntryTests.swift`:
 563
 564```swift
 565import Foundation
 566import Testing
 567
 568@testable import KeycaskCore
 569
 570@Suite struct EntryTests {
 571    @Test func initNormalizesTagsAndTruncatesDates() {
 572        let now = Date(timeIntervalSince1970: 1_700_000_000.75)
 573        let e = Entry(name: "gh", password: "p", tags: [" work", "Dev", "dev", "", "alpha"], now: now)
 574        #expect(e.tags == ["alpha", "Dev", "work"])
 575        #expect(e.created == Date(timeIntervalSince1970: 1_700_000_000))
 576        #expect(e.updated == e.created)
 577    }
 578
 579    @Test func normalizeSortsCaseInsensitivelyAndKeepsFirstSpelling() {
 580        #expect(Entry.normalize(tags: ["b", "A", "a", "B"]) == ["A", "b"])
 581        #expect(Entry.normalize(tags: []) == [])
 582    }
 583
 584    @Test func hasTagIsCaseInsensitive() {
 585        let e = Entry(name: "gh", password: "p", tags: ["Dev"])
 586        #expect(e.hasTag("dev"))
 587        #expect(e.hasTag("DEV"))
 588        #expect(!e.hasTag("ops"))
 589    }
 590
 591    @Test func matchesSearchesEveryTextFieldExceptPassword() {
 592        let e = Entry(
 593            name: "GitHub", username: "cmc", password: "hunter2", url: "https://github.com",
 594            notes: "downtown office", tags: ["Dev"])
 595        #expect(e.matches("github"))
 596        #expect(e.matches("CMC"))
 597        #expect(e.matches("github.com"))
 598        #expect(e.matches("downtown"))
 599        #expect(e.matches("dev"))
 600        #expect(!e.matches("hunter2"))
 601        #expect(!e.matches("nothing"))
 602    }
 603}
 604```
 605
 606- [ ] **Step 2: Run test to verify it fails**
 607
 608Run: `swift test --filter EntryTests`
 609Expected: compile error, `Entry` not found.
 610
 611- [ ] **Step 3: Write the implementation**
 612
 613`Sources/KeycaskCore/Entry.swift`:
 614
 615```swift
 616import Foundation
 617
 618public struct Entry: Codable, Equatable, Sendable {
 619    public let id: EntryID
 620    public var name: String
 621    public var username: String?
 622    public var password: String
 623    public var url: String?
 624    public var notes: String?
 625    public var tags: [String]
 626    public let created: Date
 627    public var updated: Date
 628
 629    public init(
 630        id: EntryID = .random(),
 631        name: String,
 632        username: String? = nil,
 633        password: String,
 634        url: String? = nil,
 635        notes: String? = nil,
 636        tags: [String] = [],
 637        now: Date = .now
 638    ) {
 639        self.id = id
 640        self.name = name
 641        self.username = username
 642        self.password = password
 643        self.url = url
 644        self.notes = notes
 645        self.tags = Self.normalize(tags: tags)
 646        let stamp = Self.truncateToSeconds(now)
 647        created = stamp
 648        updated = stamp
 649    }
 650
 651    public static func normalize(tags: [String]) -> [String] {
 652        var seen: Set<String> = []
 653        var out: [String] = []
 654        for raw in tags {
 655            let tag = raw.trimmingCharacters(in: .whitespaces)
 656            guard !tag.isEmpty, seen.insert(tag.lowercased()).inserted else { continue }
 657            out.append(tag)
 658        }
 659        return out.sorted { a, b in
 660            let (la, lb) = (a.lowercased(), b.lowercased())
 661            return la == lb ? a < b : la < lb
 662        }
 663    }
 664
 665    public static func truncateToSeconds(_ date: Date) -> Date {
 666        Date(timeIntervalSince1970: date.timeIntervalSince1970.rounded(.down))
 667    }
 668
 669    public func hasTag(_ tag: String) -> Bool {
 670        let needle = tag.lowercased()
 671        return tags.contains { $0.lowercased() == needle }
 672    }
 673
 674    public func matches(_ query: String) -> Bool {
 675        let needle = query.lowercased()
 676        guard !needle.isEmpty else { return false }
 677        let haystacks = [name, username ?? "", url ?? "", notes ?? ""] + tags
 678        return haystacks.contains { $0.lowercased().contains(needle) }
 679    }
 680}
 681```
 682
 683- [ ] **Step 4: Switch `ambiguous` to carry entries**
 684
 685In `KeycaskError.swift` change the case to `case ambiguous(name: String, candidates: [Entry])` and the message body to:
 686
 687```swift
 688case .ambiguous(let name, let candidates):
 689    (["\(name): ambiguous, use an id:"]
 690        + candidates.map { "  \($0.id.rawValue)  \($0.username ?? "")  \($0.url ?? "")" })
 691        .joined(separator: "\n")
 692```
 693
 694`KeycaskErrorTests.swift` already passes `candidates: []`, which now infers `[Entry]`. Add one test there:
 695
 696```swift
 697@Test func ambiguousListsCandidateIDs() {
 698    let a = Entry(id: EntryID("aaaa2222")!, name: "gh", username: "one", password: "p")
 699    let b = Entry(id: EntryID("bbbb3333")!, name: "gh", password: "p", url: "https://x")
 700    let m = KeycaskError.ambiguous(name: "gh", candidates: [a, b]).message
 701    #expect(m.hasPrefix("gh: ambiguous, use an id:\n"))
 702    #expect(m.contains("aaaa2222"))
 703    #expect(m.contains("bbbb3333"))
 704    #expect(m.contains("https://x"))
 705}
 706```
 707
 708- [ ] **Step 5: Run tests**
 709
 710Run: `swift test --filter 'EntryTests|KeycaskErrorTests'`
 711Expected: all pass.
 712
 713- [ ] **Step 6: Commit**
 714
 715```bash
 716git add Sources/KeycaskCore Tests/KeycaskCoreTests
 717git commit -m "Add Entry with tag normalization and search"
 718```
 719
 720---
 721
 722### Task 5: Vault and VaultCodec
 723
 724**Files:**
 725- Create: `Sources/KeycaskCore/Vault.swift`
 726- Create: `Sources/KeycaskCore/VaultCodec.swift`
 727- Create: `Tests/KeycaskCoreTests/VaultTests.swift`
 728- Create: `Tests/KeycaskCoreTests/VaultCodecTests.swift`
 729
 730**Interfaces:**
 731- Consumes: `Entry`, `EntryID`, `KeycaskError`.
 732- Produces:
 733
 734```swift
 735public struct Vault: Codable, Equatable, Sendable {
 736    public var entries: [Entry]
 737    public init(entries: [Entry] = [])
 738    public func entry(id: EntryID) -> Entry?
 739    public mutating func add(_ entry: Entry) throws            // duplicateID
 740    public mutating func remove(id: EntryID) throws            // notFound(id)
 741    public mutating func update(id: EntryID, now: Date = .now,
 742                                _ change: (inout Entry) -> Void) throws  // notFound(id); normalizes tags, sets updated
 743    public func resolve(_ ref: String) throws -> Entry         // id, unique name, ambiguous, notFound
 744    public func filter(tag: String) -> [Entry]
 745    public func search(_ query: String) -> [Entry]
 746    public var sortedEntries: [Entry]                          // by name (case-insensitive), then id
 747}
 748
 749public enum VaultCodec {
 750    public static func encode(_ vault: Vault) throws -> Data   // sortedKeys, iso8601; io on failure
 751    public static func decode(_ data: Data) throws -> Vault    // corrupt on failure
 752    public static func makeEncoder() -> JSONEncoder            // shared settings, also used by CLI output
 753}
 754```
 755
 756- [ ] **Step 1: Write the failing tests**
 757
 758`Tests/KeycaskCoreTests/VaultTests.swift`:
 759
 760```swift
 761import Foundation
 762import Testing
 763
 764@testable import KeycaskCore
 765
 766@Suite struct VaultTests {
 767    func idA() -> EntryID { EntryID("aaaa2222")! }
 768    func idB() -> EntryID { EntryID("bbbb3333")! }
 769
 770    @Test func addRejectsDuplicateID() throws {
 771        var v = Vault()
 772        try v.add(Entry(id: idA(), name: "gh", password: "p"))
 773        #expect(throws: KeycaskError.duplicateID(idA())) {
 774            try v.add(Entry(id: idA(), name: "other", password: "p"))
 775        }
 776        #expect(v.entries.count == 1)
 777    }
 778
 779    @Test func removeUnknownIsNotFound() {
 780        var v = Vault()
 781        #expect(throws: KeycaskError.notFound("aaaa2222")) { try v.remove(id: idA()) }
 782    }
 783
 784    @Test func updateSetsUpdatedAndNormalizesTags() throws {
 785        let t0 = Date(timeIntervalSince1970: 1_000)
 786        let t1 = Date(timeIntervalSince1970: 2_000.9)
 787        var v = Vault()
 788        try v.add(Entry(id: idA(), name: "gh", password: "p", now: t0))
 789        try v.update(id: idA(), now: t1) { e in
 790            e.tags = ["z", "A", "a"]
 791            e.password = "q"
 792        }
 793        let e = v.entry(id: idA())!
 794        #expect(e.password == "q")
 795        #expect(e.tags == ["A", "z"])
 796        #expect(e.created == t0)
 797        #expect(e.updated == Date(timeIntervalSince1970: 2_000))
 798    }
 799
 800    @Test func resolvePrefersIDThenUniqueName() throws {
 801        var v = Vault()
 802        try v.add(Entry(id: idA(), name: "gh", password: "p"))
 803        try v.add(Entry(id: idB(), name: "aaaa2222", password: "p"))
 804        #expect(try v.resolve("aaaa2222").id == idA())
 805        #expect(try v.resolve("gh").id == idA())
 806        #expect(try v.resolve("bbbb3333").id == idB())
 807    }
 808
 809    @Test func resolveReportsAmbiguousWithAllCandidates() throws {
 810        var v = Vault()
 811        let a = Entry(id: idA(), name: "gh", password: "p")
 812        let b = Entry(id: idB(), name: "gh", password: "p")
 813        try v.add(a)
 814        try v.add(b)
 815        #expect(throws: KeycaskError.ambiguous(name: "gh", candidates: [a, b])) {
 816            try v.resolve("gh")
 817        }
 818    }
 819
 820    @Test func resolveUnknownIsNotFound() {
 821        #expect(throws: KeycaskError.notFound("nope")) { try Vault().resolve("nope") }
 822    }
 823
 824    @Test func filterAndSearch() throws {
 825        var v = Vault()
 826        try v.add(Entry(id: idA(), name: "GitHub", password: "p", tags: ["dev"]))
 827        try v.add(Entry(id: idB(), name: "bank", password: "p", url: "https://bank.example"))
 828        #expect(v.filter(tag: "DEV").map(\.id) == [idA()])
 829        #expect(v.search("example").map(\.id) == [idB()])
 830        #expect(v.search("zzz").isEmpty)
 831    }
 832
 833    @Test func sortedEntriesOrderByNameThenID() throws {
 834        var v = Vault()
 835        try v.add(Entry(id: idB(), name: "gh", password: "p"))
 836        try v.add(Entry(id: idA(), name: "gh", password: "p"))
 837        try v.add(Entry(id: EntryID("cccc4444")!, name: "Alpha", password: "p"))
 838        #expect(v.sortedEntries.map(\.id.rawValue) == ["cccc4444", "aaaa2222", "bbbb3333"])
 839    }
 840}
 841```
 842
 843`Tests/KeycaskCoreTests/VaultCodecTests.swift`:
 844
 845```swift
 846import Foundation
 847import Testing
 848
 849@testable import KeycaskCore
 850
 851@Suite struct VaultCodecTests {
 852    @Test func roundTripsAndIsDeterministic() throws {
 853        var v = Vault()
 854        try v.add(
 855            Entry(
 856                id: EntryID("aaaa2222")!, name: "gh", username: "cmc", password: "p",
 857                url: "https://github.com", notes: "n", tags: ["dev"],
 858                now: Date(timeIntervalSince1970: 1_700_000_000)))
 859        let a = try VaultCodec.encode(v)
 860        let b = try VaultCodec.encode(v)
 861        #expect(a == b)
 862        #expect(try VaultCodec.decode(a) == v)
 863    }
 864
 865    @Test func datesAreISO8601WholeSeconds() throws {
 866        var v = Vault()
 867        try v.add(
 868            Entry(id: EntryID("aaaa2222")!, name: "gh", password: "p",
 869                  now: Date(timeIntervalSince1970: 1_700_000_000)))
 870        let text = String(decoding: try VaultCodec.encode(v), as: UTF8.self)
 871        #expect(text.contains("\"created\":\"2023-11-14T22:13:20Z\""))
 872    }
 873
 874    @Test func keysAreSorted() throws {
 875        var v = Vault()
 876        try v.add(Entry(id: EntryID("aaaa2222")!, name: "gh", password: "p"))
 877        let text = String(decoding: try VaultCodec.encode(v), as: UTF8.self)
 878        let created = text.range(of: "\"created\"")!.lowerBound
 879        let id = text.range(of: "\"id\"")!.lowerBound
 880        let updated = text.range(of: "\"updated\"")!.lowerBound
 881        #expect(created < id && id < updated)
 882    }
 883
 884    @Test func garbageIsCorrupt() {
 885        #expect(throws: KeycaskError.self) { try VaultCodec.decode(Data("nope".utf8)) }
 886        do {
 887            _ = try VaultCodec.decode(Data("{\"entries\":[{\"id\":1}]}".utf8))
 888            Issue.record("expected corrupt")
 889        } catch let e as KeycaskError {
 890            #expect(e.exitCode == 1)
 891            #expect(e.message.hasPrefix("vault is corrupt:"))
 892        } catch {
 893            Issue.record("wrong error \(error)")
 894        }
 895    }
 896}
 897```
 898
 899- [ ] **Step 2: Run tests to verify they fail**
 900
 901Run: `swift test --filter 'VaultTests|VaultCodecTests'`
 902Expected: compile error, `Vault` not found.
 903
 904- [ ] **Step 3: Write Vault.swift**
 905
 906```swift
 907import Foundation
 908
 909public struct Vault: Codable, Equatable, Sendable {
 910    public var entries: [Entry]
 911
 912    public init(entries: [Entry] = []) {
 913        self.entries = entries
 914    }
 915
 916    public func entry(id: EntryID) -> Entry? {
 917        entries.first { $0.id == id }
 918    }
 919
 920    public mutating func add(_ entry: Entry) throws {
 921        guard self.entry(id: entry.id) == nil else { throw KeycaskError.duplicateID(entry.id) }
 922        entries.append(entry)
 923    }
 924
 925    public mutating func remove(id: EntryID) throws {
 926        guard let index = entries.firstIndex(where: { $0.id == id }) else {
 927            throw KeycaskError.notFound(id.rawValue)
 928        }
 929        entries.remove(at: index)
 930    }
 931
 932    public mutating func update(
 933        id: EntryID, now: Date = .now, _ change: (inout Entry) -> Void
 934    ) throws {
 935        guard let index = entries.firstIndex(where: { $0.id == id }) else {
 936            throw KeycaskError.notFound(id.rawValue)
 937        }
 938        change(&entries[index])
 939        entries[index].tags = Entry.normalize(tags: entries[index].tags)
 940        entries[index].updated = Entry.truncateToSeconds(now)
 941    }
 942
 943    public func resolve(_ ref: String) throws -> Entry {
 944        if let id = EntryID(ref), let hit = entry(id: id) {
 945            return hit
 946        }
 947        let byName = entries.filter { $0.name == ref }
 948        switch byName.count {
 949        case 0: throw KeycaskError.notFound(ref)
 950        case 1: return byName[0]
 951        default: throw KeycaskError.ambiguous(name: ref, candidates: byName)
 952        }
 953    }
 954
 955    public func filter(tag: String) -> [Entry] {
 956        sortedEntries.filter { $0.hasTag(tag) }
 957    }
 958
 959    public func search(_ query: String) -> [Entry] {
 960        sortedEntries.filter { $0.matches(query) }
 961    }
 962
 963    public var sortedEntries: [Entry] {
 964        entries.sorted { a, b in
 965            let (la, lb) = (a.name.lowercased(), b.name.lowercased())
 966            return la == lb ? a.id.rawValue < b.id.rawValue : la < lb
 967        }
 968    }
 969}
 970```
 971
 972- [ ] **Step 4: Write VaultCodec.swift**
 973
 974```swift
 975import Foundation
 976
 977public enum VaultCodec {
 978    public static func makeEncoder() -> JSONEncoder {
 979        let encoder = JSONEncoder()
 980        encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
 981        encoder.dateEncodingStrategy = .iso8601
 982        return encoder
 983    }
 984
 985    public static func makeDecoder() -> JSONDecoder {
 986        let decoder = JSONDecoder()
 987        decoder.dateDecodingStrategy = .iso8601
 988        return decoder
 989    }
 990
 991    public static func encode(_ vault: Vault) throws -> Data {
 992        do {
 993            return try makeEncoder().encode(vault)
 994        } catch {
 995            throw KeycaskError.io("encode vault: \(error)")
 996        }
 997    }
 998
 999    public static func decode(_ data: Data) throws -> Vault {
1000        do {
1001            return try makeDecoder().decode(Vault.self, from: data)
1002        } catch {
1003            throw KeycaskError.corrupt("\(error)")
1004        }
1005    }
1006}
1007```
1008
1009- [ ] **Step 5: Run tests**
1010
1011Run: `swift test --filter 'VaultTests|VaultCodecTests'`
1012Expected: 12 tests pass.
1013
1014- [ ] **Step 6: Commit**
1015
1016```bash
1017git add Sources/KeycaskCore Tests/KeycaskCoreTests
1018git commit -m "Add Vault operations and deterministic JSON codec"
1019```
1020
1021---
1022
1023### Task 6: Generator and word list
1024
1025**Files:**
1026- Create: `Sources/KeycaskCore/Wordlist.swift` (generated)
1027- Create: `Sources/KeycaskCore/Generator.swift`
1028- Create: `NOTICE`
1029- Create: `Tests/KeycaskCoreTests/GeneratorTests.swift`
1030
1031**Interfaces:**
1032- Produces:
1033
1034```swift
1035public enum Wordlist { public static let words: [String] }   // 7776 entries
1036public enum Generator {
1037    public static let alphabet: [Character]  // A-Z a-z 0-9 and !@#$%^&*()-_=+[]{};:,.<>?
1038    public static let defaultLength = 24
1039    public static let wordSeparator = "-"
1040    public static func password(length: Int) -> String
1041    public static func password(length: Int, using: inout some RandomNumberGenerator) -> String
1042    public static func passphrase(words: Int) -> String
1043    public static func passphrase(words: Int, using: inout some RandomNumberGenerator) -> String
1044}
1045```
1046
1047- [ ] **Step 1: Generate Wordlist.swift from the EFF list**
1048
1049```bash
1050cd /Users/cmc/git/krz/keycask
1051curl -fsSL https://www.eff.org/files/2016/07/18/eff_large_wordlist.txt -o /tmp/eff.txt
1052test "$(wc -l < /tmp/eff.txt)" -eq 7776
1053{
1054  printf '// EFF long word list, https://www.eff.org/dice. See NOTICE.\n'
1055  printf 'let effLongWordlist = """\n'
1056  cut -f2 /tmp/eff.txt
1057  printf '"""\n\npublic enum Wordlist {\n'
1058  printf '    public static let words: [String] = effLongWordlist.split(separator: "\\n").map(String.init)\n'
1059  printf '}\n'
1060} > Sources/KeycaskCore/Wordlist.swift
1061rm /tmp/eff.txt
1062```
1063
1064- [ ] **Step 2: Write NOTICE**
1065
1066```
1067The word list in Sources/KeycaskCore/Wordlist.swift is the EFF Long
1068Wordlist by the Electronic Frontier Foundation, licensed under the
1069Creative Commons Attribution 3.0 United States License.
1070https://www.eff.org/dice
1071https://creativecommons.org/licenses/by/3.0/us/
1072```
1073
1074- [ ] **Step 3: Write the failing test**
1075
1076`Tests/KeycaskCoreTests/GeneratorTests.swift`:
1077
1078```swift
1079import Testing
1080
1081@testable import KeycaskCore
1082
1083@Suite struct GeneratorTests {
1084    struct Counter: RandomNumberGenerator {
1085        var n: UInt64 = 0
1086        mutating func next() -> UInt64 {
1087            n &+= 0x9E37_79B9_7F4A_7C15
1088            return n
1089        }
1090    }
1091
1092    @Test func wordlistHas7776UniqueWords() {
1093        #expect(Wordlist.words.count == 7776)
1094        #expect(Set(Wordlist.words).count == 7776)
1095        #expect(Wordlist.words.first == "abacus")
1096        #expect(Wordlist.words.allSatisfy { !$0.isEmpty && !$0.contains(" ") })
1097    }
1098
1099    @Test func passwordHasRequestedLengthFromTheAlphabet() {
1100        let allowed = Set(Generator.alphabet)
1101        for length in [1, 8, 24, 64] {
1102            let p = Generator.password(length: length)
1103            #expect(p.count == length)
1104            #expect(p.allSatisfy { allowed.contains($0) })
1105        }
1106        #expect(Generator.password(length: 0) == "")
1107    }
1108
1109    @Test func alphabetCoversAllClasses() {
1110        let s = String(Generator.alphabet)
1111        #expect(s.contains("A") && s.contains("z") && s.contains("7") && s.contains("!"))
1112        #expect(Set(Generator.alphabet).count == Generator.alphabet.count)
1113    }
1114
1115    @Test func passphraseUsesWordsFromTheList() {
1116        let words = Set(Wordlist.words)
1117        let p = Generator.passphrase(words: 5)
1118        let parts = p.split(separator: "-").map(String.init)
1119        #expect(parts.count == 5)
1120        #expect(parts.allSatisfy { words.contains($0) })
1121        #expect(Generator.passphrase(words: 0) == "")
1122    }
1123
1124    @Test func seededOutputIsReproducible() {
1125        var a = Counter()
1126        var b = Counter()
1127        #expect(Generator.password(length: 16, using: &a) == Generator.password(length: 16, using: &b))
1128        #expect(Generator.passphrase(words: 3, using: &a) == Generator.passphrase(words: 3, using: &b))
1129    }
1130}
1131```
1132
1133- [ ] **Step 4: Run test to verify it fails**
1134
1135Run: `swift test --filter GeneratorTests`
1136Expected: compile error, `Generator` not found.
1137
1138- [ ] **Step 5: Write Generator.swift**
1139
1140```swift
1141public enum Generator {
1142    public static let alphabet: [Character] = Array(
1143        "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{};:,.<>?"
1144    )
1145    public static let defaultLength = 24
1146    public static let wordSeparator = "-"
1147
1148    public static func password(length: Int) -> String {
1149        var rng = SystemRandomNumberGenerator()
1150        return password(length: length, using: &rng)
1151    }
1152
1153    public static func password(length: Int, using rng: inout some RandomNumberGenerator) -> String {
1154        var chars: [Character] = []
1155        chars.reserveCapacity(max(length, 0))
1156        for _ in 0..<max(length, 0) {
1157            chars.append(alphabet[Int(rng.next(upperBound: UInt32(alphabet.count)))])
1158        }
1159        return String(chars)
1160    }
1161
1162    public static func passphrase(words: Int) -> String {
1163        var rng = SystemRandomNumberGenerator()
1164        return passphrase(words: words, using: &rng)
1165    }
1166
1167    public static func passphrase(words: Int, using rng: inout some RandomNumberGenerator) -> String {
1168        let list = Wordlist.words
1169        var picked: [String] = []
1170        for _ in 0..<max(words, 0) {
1171            picked.append(list[Int(rng.next(upperBound: UInt32(list.count)))])
1172        }
1173        return picked.joined(separator: wordSeparator)
1174    }
1175}
1176```
1177
1178- [ ] **Step 6: Run tests and lint**
1179
1180Run: `swift test --filter GeneratorTests && swift format lint --strict --recursive Sources Tests`
1181Expected: 5 tests pass. If the linter complains about the long string literal in `Wordlist.swift`, add `"// swift-format-ignore-file"` as its first line.
1182
1183- [ ] **Step 7: Commit**
1184
1185```bash
1186git add NOTICE Sources/KeycaskCore Tests/KeycaskCoreTests
1187git commit -m "Add password and passphrase generator with EFF word list"
1188```
1189
1190---
1191
1192### Task 7: Envelope
1193
1194**Files:**
1195- Create: `Sources/KeycaskCore/Envelope.swift`
1196- Create: `Tests/KeycaskCoreTests/EnvelopeTests.swift`
1197
1198**Interfaces:**
1199- Consumes: `KeycaskError`.
1200- Produces:
1201
1202```swift
1203public struct Envelope: Codable, Equatable, Sendable {
1204    public struct KDFParams: Codable, Equatable, Sendable {
1205        public var name: String
1206        public var iterations: Int
1207        public var salt: Data
1208        public init(name: String, iterations: Int, salt: Data)
1209        public static func fresh(iterations: Int = Envelope.defaultIterations) -> KDFParams
1210    }
1211    public static let currentFormat = 1
1212    public static let defaultIterations = 600_000
1213    public static let kdfName = "pbkdf2-hmac-sha256"
1214    public static let saltLength = 16
1215    public var format: Int
1216    public var kdf: KDFParams
1217    public var box: Data
1218
1219    public static func seal(_ plaintext: Data, passphrase: String, kdf: KDFParams) throws -> Envelope
1220    public func open(passphrase: String) throws -> Data       // cannotDecrypt / corrupt
1221    public init(parsing data: Data) throws                     // corrupt
1222    public func encoded() throws -> Data
1223    static func deriveKey(passphrase: String, kdf: KDFParams) throws -> SymmetricKey
1224}
1225```
1226
1227- [ ] **Step 1: Write the failing test**
1228
1229`Tests/KeycaskCoreTests/EnvelopeTests.swift`:
1230
1231```swift
1232import Crypto
1233import Foundation
1234import Testing
1235
1236@testable import KeycaskCore
1237
1238@Suite struct EnvelopeTests {
1239    // Low iteration count keeps the suite fast. Production uses Envelope.defaultIterations.
1240    let kdf = Envelope.KDFParams(
1241        name: Envelope.kdfName, iterations: 1_000, salt: Data(repeating: 7, count: 16))
1242
1243    func hex(_ key: SymmetricKey) -> String {
1244        key.withUnsafeBytes { $0.map { String(format: "%02x", $0) }.joined() }
1245    }
1246
1247    @Test func pbkdf2MatchesPublishedVectors() throws {
1248        let one = Envelope.KDFParams(name: Envelope.kdfName, iterations: 1, salt: Data("salt".utf8))
1249        #expect(
1250            hex(try Envelope.deriveKey(passphrase: "password", kdf: one))
1251                == "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b")
1252        let many = Envelope.KDFParams(name: Envelope.kdfName, iterations: 4096, salt: Data("salt".utf8))
1253        #expect(
1254            hex(try Envelope.deriveKey(passphrase: "password", kdf: many))
1255                == "c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a")
1256    }
1257
1258    @Test func sealThenOpenRoundTrips() throws {
1259        let env = try Envelope.seal(Data("hello vault".utf8), passphrase: "pw", kdf: kdf)
1260        #expect(env.format == 1)
1261        #expect(env.kdf == kdf)
1262        #expect(try env.open(passphrase: "pw") == Data("hello vault".utf8))
1263    }
1264
1265    @Test func wrongPassphraseCannotDecrypt() throws {
1266        let env = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
1267        #expect(throws: KeycaskError.cannotDecrypt) { try env.open(passphrase: "PW") }
1268    }
1269
1270    @Test func tamperedBoxCannotDecrypt() throws {
1271        var env = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
1272        env.box[env.box.count - 1] ^= 0x01
1273        #expect(throws: KeycaskError.cannotDecrypt) { try env.open(passphrase: "pw") }
1274    }
1275
1276    @Test func nonceIsFreshAndSaltIsKept() throws {
1277        let a = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
1278        let b = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
1279        #expect(a.box != b.box)
1280        #expect(a.kdf.salt == b.kdf.salt)
1281    }
1282
1283    @Test func freshParamsUseDefaults() {
1284        let p = Envelope.KDFParams.fresh()
1285        #expect(p.name == "pbkdf2-hmac-sha256")
1286        #expect(p.iterations == 600_000)
1287        #expect(p.salt.count == 16)
1288        #expect(p.salt != Envelope.KDFParams.fresh().salt)
1289    }
1290
1291    @Test func encodedShapeMatchesTheSpec() throws {
1292        let env = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
1293        let json = try JSONSerialization.jsonObject(with: env.encoded()) as! [String: Any]
1294        #expect(json["format"] as? Int == 1)
1295        let k = json["kdf"] as! [String: Any]
1296        #expect(k["name"] as? String == "pbkdf2-hmac-sha256")
1297        #expect(k["iterations"] as? Int == 1_000)
1298        #expect(Data(base64Encoded: k["salt"] as! String) == kdf.salt)
1299        #expect(Data(base64Encoded: json["box"] as! String) == env.box)
1300        #expect(try Envelope(parsing: env.encoded()) == env)
1301    }
1302
1303    @Test func malformedInputsAreCorrupt() throws {
1304        #expect(throws: KeycaskError.self) { try Envelope(parsing: Data("not json".utf8)) }
1305        #expect(throws: KeycaskError.self) { try Envelope(parsing: Data("{\"format\":1}".utf8)) }
1306
1307        var wrongFormat = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
1308        wrongFormat.format = 2
1309        #expect(throws: KeycaskError.corrupt("unsupported format 2")) {
1310            try wrongFormat.open(passphrase: "pw")
1311        }
1312
1313        var wrongKDF = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
1314        wrongKDF.kdf.name = "argon2id"
1315        #expect(throws: KeycaskError.corrupt("unsupported kdf argon2id")) {
1316            try wrongKDF.open(passphrase: "pw")
1317        }
1318
1319        var shortBox = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
1320        shortBox.box = Data([1, 2, 3])
1321        #expect(throws: KeycaskError.corrupt("box too short")) { try shortBox.open(passphrase: "pw") }
1322    }
1323
1324    @Test func passphraseIsNFCNormalized() throws {
1325        let composed = "caf\u{00E9}"
1326        let decomposed = "cafe\u{0301}"
1327        let env = try Envelope.seal(Data("x".utf8), passphrase: composed, kdf: kdf)
1328        #expect(try env.open(passphrase: decomposed) == Data("x".utf8))
1329    }
1330}
1331```
1332
1333- [ ] **Step 2: Run test to verify it fails**
1334
1335Run: `swift test --filter EnvelopeTests`
1336Expected: compile error, `Envelope` not found.
1337
1338- [ ] **Step 3: Write Envelope.swift**
1339
1340```swift
1341import Crypto
1342import Foundation
1343import _CryptoExtras
1344
1345public struct Envelope: Codable, Equatable, Sendable {
1346    public struct KDFParams: Codable, Equatable, Sendable {
1347        public var name: String
1348        public var iterations: Int
1349        public var salt: Data
1350
1351        public init(name: String, iterations: Int, salt: Data) {
1352            self.name = name
1353            self.iterations = iterations
1354            self.salt = salt
1355        }
1356
1357        public static func fresh(iterations: Int = Envelope.defaultIterations) -> KDFParams {
1358            var rng = SystemRandomNumberGenerator()
1359            let salt = Data((0..<Envelope.saltLength).map { _ in UInt8.random(in: .min ... .max, using: &rng) })
1360            return KDFParams(name: Envelope.kdfName, iterations: iterations, salt: salt)
1361        }
1362    }
1363
1364    public static let currentFormat = 1
1365    public static let defaultIterations = 600_000
1366    public static let kdfName = "pbkdf2-hmac-sha256"
1367    public static let saltLength = 16
1368    static let keyLength = 32
1369    static let minimumBoxLength = 12 + 16
1370
1371    public var format: Int
1372    public var kdf: KDFParams
1373    public var box: Data
1374
1375    public static func seal(_ plaintext: Data, passphrase: String, kdf: KDFParams) throws -> Envelope {
1376        let key = try deriveKey(passphrase: passphrase, kdf: kdf)
1377        do {
1378            let sealed = try ChaChaPoly.seal(plaintext, using: key)
1379            return Envelope(format: currentFormat, kdf: kdf, box: sealed.combined)
1380        } catch {
1381            throw KeycaskError.failure("encrypt: \(error)")
1382        }
1383    }
1384
1385    public func open(passphrase: String) throws -> Data {
1386        guard format == Self.currentFormat else {
1387            throw KeycaskError.corrupt("unsupported format \(format)")
1388        }
1389        guard kdf.name == Self.kdfName else {
1390            throw KeycaskError.corrupt("unsupported kdf \(kdf.name)")
1391        }
1392        guard box.count >= Self.minimumBoxLength else {
1393            throw KeycaskError.corrupt("box too short")
1394        }
1395        let key = try Self.deriveKey(passphrase: passphrase, kdf: kdf)
1396        let sealed: ChaChaPoly.SealedBox
1397        do {
1398            sealed = try ChaChaPoly.SealedBox(combined: box)
1399        } catch {
1400            throw KeycaskError.corrupt("box is malformed")
1401        }
1402        do {
1403            return try ChaChaPoly.open(sealed, using: key)
1404        } catch {
1405            throw KeycaskError.cannotDecrypt
1406        }
1407    }
1408
1409    public init(parsing data: Data) throws {
1410        do {
1411            self = try JSONDecoder().decode(Envelope.self, from: data)
1412        } catch {
1413            throw KeycaskError.corrupt("not a keycask vault: \(error)")
1414        }
1415    }
1416
1417    public func encoded() throws -> Data {
1418        let encoder = JSONEncoder()
1419        encoder.outputFormatting = [.sortedKeys, .prettyPrinted]
1420        do {
1421            return try encoder.encode(self)
1422        } catch {
1423            throw KeycaskError.io("encode envelope: \(error)")
1424        }
1425    }
1426
1427    init(format: Int, kdf: KDFParams, box: Data) {
1428        self.format = format
1429        self.kdf = kdf
1430        self.box = box
1431    }
1432
1433    static func deriveKey(passphrase: String, kdf: KDFParams) throws -> SymmetricKey {
1434        let normalized = Array(passphrase.precomposedStringWithCanonicalMapping.utf8)
1435        do {
1436            return try KDF.Insecure.PBKDF2.deriveKey(
1437                from: normalized, salt: kdf.salt, using: .sha256,
1438                outputByteCount: keyLength, unsafeUncheckedRounds: kdf.iterations)
1439        } catch {
1440            throw KeycaskError.failure("derive key: \(error)")
1441        }
1442    }
1443}
1444```
1445
1446`unsafeUncheckedRounds` is used because the checked overload rejects fewer than 210000 rounds, and the vault decides the count. `KDFParams.fresh()` always produces 600000.
1447
1448- [ ] **Step 4: Run tests**
1449
1450Run: `swift test --filter EnvelopeTests`
1451Expected: 9 tests pass.
1452
1453- [ ] **Step 5: Commit**
1454
1455```bash
1456git add Sources/KeycaskCore Tests/KeycaskCoreTests
1457git commit -m "Add passphrase-encrypted vault envelope"
1458```
1459
1460---
1461
1462### Task 8: Core merge request
1463
1464**Files:** none new.
1465
1466- [ ] **Step 1: Full suite and lint**
1467
1468Run: `swift test && swift format lint --strict --recursive Sources Tests Package.swift`
1469Expected: all pass, no lint output.
1470
1471- [ ] **Step 2: Push and open the MR**
1472
1473```bash
1474git push -u origin core
1475gitbay mr create --source core --target main --title "Core library: model, envelope, generator" --file - <<'EOF'
1476KeycaskCore: Entry, EntryID, Vault, VaultCodec, Envelope, Generator, Wordlist, KeycaskError.
1477Package scaffold and gitbay CI.
1478EOF
1479```
1480
1481- [ ] **Step 3: Wait for CI, merge, clean up**
1482
1483Run `gitbay build list --json` until the build for `core` is green. Then:
1484
1485```bash
1486gitbay mr merge <n> --strategy squash
1487git switch main && git pull && git branch -D core && git push origin --delete core
1488```
1489
1490If 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.
1491
1492---
1493
1494### Task 9: CLI skeleton, paths, passphrase, atomic write, `init`
1495
1496**Files:**
1497- Create: `Sources/keycask/main.swift` (replace)
1498- Create: `Sources/keycask/Keycask.swift`
1499- Create: `Sources/keycask/Paths.swift`
1500- Create: `Sources/keycask/Terminal.swift`
1501- Create: `Sources/keycask/Passphrase.swift`
1502- Create: `Sources/keycask/AtomicFile.swift`
1503- Create: `Sources/keycask/OpenVault.swift`
1504- Create: `Sources/keycask/Commands/Init.swift`
1505- Modify: `Tests/KeycaskCLITests/CLI.swift`
1506- Create: `Tests/KeycaskCLITests/InitTests.swift`
1507- Create: `Tests/KeycaskCLITests/PathsTests.swift`
1508
1509**Interfaces:**
1510- Consumes: `Vault`, `VaultCodec`, `Envelope`, `KeycaskError`.
1511- Produces:
1512
1513```swift
1514struct GlobalOptions: ParsableArguments { var vault: String? }
1515enum Paths { static func vaultURL(override: String?, environment: [String: String]) -> URL }
1516enum Terminal {
1517    static var stdinIsTTY: Bool
1518    static func readSecretLine(prompt: String) throws -> String
1519    static func readLine(prompt: String) -> String?
1520    static func confirm(_ question: String) -> Bool
1521}
1522enum Passphrase {
1523    static let variable = "KEYCASK_PASSPHRASE"
1524    static func obtain(confirm: Bool, environment: [String: String]) throws -> String
1525}
1526enum AtomicFile { static func write(_ data: Data, to url: URL) throws }
1527struct OpenVault {
1528    var vault: Vault
1529    let kdf: Envelope.KDFParams
1530    let url: URL
1531    let passphrase: String
1532    static func load(_ options: GlobalOptions) throws -> OpenVault
1533    static func create(_ options: GlobalOptions) throws -> URL
1534    func save() throws
1535}
1536struct CLI {   // test harness
1537    struct Result { let status: Int32; let stdout: String; let stderr: String }
1538    let dir: URL; let vault: URL; static let passphrase = "correct horse battery"
1539    init() throws
1540    func run(_ args: [String], stdin: String? = nil, passphrase: String? = CLI.passphrase,
1541             extraEnvironment: [String: String] = [:]) throws -> Result
1542}
1543```
1544
1545- [ ] **Step 1: Create the branch**
1546
1547```bash
1548git switch -c cli
1549```
1550
1551- [ ] **Step 2: Extend the test harness**
1552
1553Replace `Tests/KeycaskCLITests/CLI.swift`:
1554
1555```swift
1556import Foundation
1557import Testing
1558
1559enum Binary {
1560    static let url: URL = {
1561        #if os(macOS)
1562            for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") {
1563                return bundle.bundleURL.deletingLastPathComponent().appendingPathComponent("keycask")
1564            }
1565            fatalError("test bundle not found")
1566        #elseif os(Windows)
1567            return Bundle.main.bundleURL.appendingPathComponent("keycask.exe")
1568        #else
1569            return Bundle.main.bundleURL.appendingPathComponent("keycask")
1570        #endif
1571    }()
1572}
1573
1574struct CLI {
1575    struct Result {
1576        let status: Int32
1577        let stdout: String
1578        let stderr: String
1579        var lines: [String] { stdout.split(separator: "\n").map(String.init) }
1580    }
1581
1582    static let passphrase = "correct horse battery"
1583
1584    let dir: URL
1585    let vault: URL
1586
1587    init() throws {
1588        dir = FileManager.default.temporaryDirectory
1589            .appendingPathComponent("keycask-tests-\(UUID().uuidString)")
1590        try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
1591        vault = dir.appendingPathComponent("vault.kc")
1592    }
1593
1594    @discardableResult
1595    func run(
1596        _ args: [String],
1597        stdin: String? = nil,
1598        passphrase: String? = CLI.passphrase,
1599        extraEnvironment: [String: String] = [:]
1600    ) throws -> Result {
1601        let process = Process()
1602        process.executableURL = Binary.url
1603        process.arguments = args
1604        var env = ProcessInfo.processInfo.environment
1605        env["KEYCASK_VAULT"] = vault.path
1606        env.removeValue(forKey: "KEYCASK_PASSPHRASE")
1607        if let passphrase { env["KEYCASK_PASSPHRASE"] = passphrase }
1608        for (k, v) in extraEnvironment { env[k] = v }
1609        process.environment = env
1610
1611        let out = Pipe()
1612        let err = Pipe()
1613        let input = Pipe()
1614        process.standardOutput = out
1615        process.standardError = err
1616        process.standardInput = input
1617        try process.run()
1618        if let stdin {
1619            input.fileHandleForWriting.write(Data(stdin.utf8))
1620        }
1621        try input.fileHandleForWriting.close()
1622        let outData = out.fileHandleForReading.readDataToEndOfFile()
1623        let errData = err.fileHandleForReading.readDataToEndOfFile()
1624        process.waitUntilExit()
1625        return Result(
1626            status: process.terminationStatus,
1627            stdout: String(decoding: outData, as: UTF8.self),
1628            stderr: String(decoding: errData, as: UTF8.self))
1629    }
1630
1631    /// Runs `init` and returns the harness, for tests that need a vault.
1632    static func initialized() throws -> CLI {
1633        let cli = try CLI()
1634        let r = try cli.run(["init"])
1635        precondition(r.status == 0, "init failed: \(r.stderr)")
1636        return cli
1637    }
1638}
1639```
1640
1641Remove the `binaryIsBuilt` test from this file; the harness replaces it.
1642
1643- [ ] **Step 3: Write the failing tests**
1644
1645`Tests/KeycaskCLITests/InitTests.swift`:
1646
1647```swift
1648import Foundation
1649import Testing
1650
1651@Suite struct InitTests {
1652    @Test func initCreatesVaultAndPrintsPath() throws {
1653        let cli = try CLI()
1654        let r = try cli.run(["init"])
1655        #expect(r.status == 0)
1656        #expect(r.stdout.contains(cli.vault.path))
1657        #expect(FileManager.default.fileExists(atPath: cli.vault.path))
1658        let text = try String(contentsOf: cli.vault, encoding: .utf8)
1659        #expect(text.contains("\"format\" : 1"))
1660        #expect(text.contains("pbkdf2-hmac-sha256"))
1661        #expect(!text.contains("entries"))
1662    }
1663
1664    @Test func initRefusesExistingVault() throws {
1665        let cli = try CLI.initialized()
1666        let r = try cli.run(["init"])
1667        #expect(r.status == 1)
1668        #expect(r.stderr.contains("already exists"))
1669    }
1670
1671    @Test func initWithoutPassphraseOrTTYIsUsageError() throws {
1672        let cli = try CLI()
1673        let r = try cli.run(["init"], passphrase: nil)
1674        #expect(r.status == 2)
1675        #expect(r.stderr.contains("KEYCASK_PASSPHRASE"))
1676    }
1677
1678    @Test func emptyPassphraseIsRejected() throws {
1679        let cli = try CLI()
1680        let r = try cli.run(["init"], passphrase: "")
1681        #expect(r.status == 1)
1682        #expect(r.stderr.contains("empty"))
1683    }
1684
1685    @Test func vaultFlagBeatsEnvironment() throws {
1686        let cli = try CLI()
1687        let other = cli.dir.appendingPathComponent("elsewhere.kc")
1688        let r = try cli.run(["--vault", other.path, "init"])
1689        #expect(r.status == 0)
1690        #expect(FileManager.default.fileExists(atPath: other.path))
1691        #expect(!FileManager.default.fileExists(atPath: cli.vault.path))
1692    }
1693
1694    @Test func unknownSubcommandIsUsageError() throws {
1695        let cli = try CLI()
1696        let r = try cli.run(["frobnicate"])
1697        #expect(r.status == 2)
1698        #expect(r.stderr.contains("Usage"))
1699    }
1700
1701    @Test func helpExitsZero() throws {
1702        let cli = try CLI()
1703        let r = try cli.run(["--help"])
1704        #expect(r.status == 0)
1705        #expect(r.stdout.contains("init"))
1706    }
1707
1708    #if !os(Windows)
1709        @Test func vaultIsPrivateOnUnix() throws {
1710            let cli = try CLI.initialized()
1711            let attrs = try FileManager.default.attributesOfItem(atPath: cli.vault.path)
1712            let mode = (attrs[.posixPermissions] as! NSNumber).intValue & 0o777
1713            #expect(mode == 0o600)
1714        }
1715    #endif
1716
1717    @Test func noTempFileLeftBehind() throws {
1718        let cli = try CLI.initialized()
1719        let names = try FileManager.default.contentsOfDirectory(atPath: cli.dir.path)
1720        #expect(names == ["vault.kc"])
1721    }
1722}
1723```
1724
1725`Tests/KeycaskCLITests/PathsTests.swift` tests `Paths` in process. It needs `@testable import keycask`, which works because the test target depends on the executable target:
1726
1727```swift
1728import Foundation
1729import Testing
1730
1731@testable import keycask
1732
1733@Suite struct PathsTests {
1734    @Test func overrideWinsOverEverything() {
1735        let url = Paths.vaultURL(
1736            override: "/x/v.kc", environment: ["KEYCASK_VAULT": "/y", "HOME": "/h"])
1737        #expect(url.path == "/x/v.kc")
1738    }
1739
1740    @Test func environmentVariableWinsOverDefaults() {
1741        let url = Paths.vaultURL(override: nil, environment: ["KEYCASK_VAULT": "/y/v.kc", "HOME": "/h"])
1742        #expect(url.path == "/y/v.kc")
1743    }
1744
1745    #if os(Windows)
1746        @Test func windowsUsesLocalAppData() {
1747            let url = Paths.vaultURL(override: nil, environment: ["LOCALAPPDATA": "C:\\Users\\u\\AppData\\Local"])
1748            #expect(url.path.hasSuffix("keycask/vault.kc") || url.path.hasSuffix("keycask\\vault.kc"))
1749        }
1750    #else
1751        @Test func xdgDataHomeIsUsedWhenSet() {
1752            let url = Paths.vaultURL(override: nil, environment: ["XDG_DATA_HOME": "/d", "HOME": "/h"])
1753            #expect(url.path == "/d/keycask/vault.kc")
1754        }
1755
1756        @Test func homeFallback() {
1757            let url = Paths.vaultURL(override: nil, environment: ["HOME": "/h"])
1758            #expect(url.path == "/h/.local/share/keycask/vault.kc")
1759        }
1760    #endif
1761}
1762```
1763
1764- [ ] **Step 4: Run tests to verify they fail**
1765
1766Run: `swift test --filter 'InitTests|PathsTests'`
1767Expected: compile error, `Paths` not found.
1768
1769- [ ] **Step 5: Write Paths.swift**
1770
1771```swift
1772import Foundation
1773
1774enum Paths {
1775    static let variable = "KEYCASK_VAULT"
1776
1777    static func vaultURL(
1778        override: String?, environment: [String: String] = ProcessInfo.processInfo.environment
1779    ) -> URL {
1780        if let override { return URL(fileURLWithPath: override) }
1781        if let env = environment[variable], !env.isEmpty { return URL(fileURLWithPath: env) }
1782        return defaultDirectory(environment: environment)
1783            .appendingPathComponent("keycask").appendingPathComponent("vault.kc")
1784    }
1785
1786    private static func defaultDirectory(environment: [String: String]) -> URL {
1787        #if os(Windows)
1788            let base = environment["LOCALAPPDATA"] ?? environment["USERPROFILE"] ?? "."
1789            return URL(fileURLWithPath: base)
1790        #else
1791            if let xdg = environment["XDG_DATA_HOME"], !xdg.isEmpty {
1792                return URL(fileURLWithPath: xdg)
1793            }
1794            let home = environment["HOME"] ?? "."
1795            return URL(fileURLWithPath: home).appendingPathComponent(".local/share")
1796        #endif
1797    }
1798}
1799```
1800
1801- [ ] **Step 6: Write Terminal.swift**
1802
1803```swift
1804import Foundation
1805import KeycaskCore
1806
1807#if canImport(Darwin)
1808    import Darwin
1809#elseif canImport(Glibc)
1810    import Glibc
1811#elseif canImport(Musl)
1812    import Musl
1813#elseif os(Windows)
1814    import CRT
1815    import WinSDK
1816#endif
1817
1818enum Terminal {
1819    static var stdinIsTTY: Bool {
1820        #if os(Windows)
1821            return _isatty(_fileno(stdin)) != 0
1822        #else
1823            return isatty(STDIN_FILENO) != 0
1824        #endif
1825    }
1826
1827    static func write(_ text: String) {
1828        FileHandle.standardError.write(Data(text.utf8))
1829    }
1830
1831    static func readLine(prompt: String) -> String? {
1832        write(prompt)
1833        return Swift.readLine(strippingNewline: true)
1834    }
1835
1836    static func confirm(_ question: String) -> Bool {
1837        guard let answer = readLine(prompt: question + " [y/N] ") else { return false }
1838        return answer.lowercased().hasPrefix("y")
1839    }
1840
1841    static func readSecretLine(prompt: String) throws -> String {
1842        write(prompt)
1843        defer { write("\n") }
1844        return try withEchoDisabled { Swift.readLine(strippingNewline: true) ?? "" }
1845    }
1846
1847    #if os(Windows)
1848        private static func withEchoDisabled<T>(_ body: () throws -> T) throws -> T {
1849            let handle = GetStdHandle(DWORD(bitPattern: -10))
1850            var mode: DWORD = 0
1851            guard GetConsoleMode(handle, &mode).boolValue else {
1852                throw KeycaskError.io("GetConsoleMode failed")
1853            }
1854            SetConsoleMode(handle, mode & ~DWORD(ENABLE_ECHO_INPUT))
1855            defer { SetConsoleMode(handle, mode) }
1856            return try body()
1857        }
1858    #else
1859        private static func withEchoDisabled<T>(_ body: () throws -> T) throws -> T {
1860            var original = termios()
1861            guard tcgetattr(STDIN_FILENO, &original) == 0 else {
1862                throw KeycaskError.io("tcgetattr failed")
1863            }
1864            var quiet = original
1865            quiet.c_lflag &= ~tcflag_t(ECHO)
1866            tcsetattr(STDIN_FILENO, TCSANOW, &quiet)
1867            defer { tcsetattr(STDIN_FILENO, TCSANOW, &original) }
1868            return try body()
1869        }
1870    #endif
1871}
1872```
1873
1874- [ ] **Step 7: Write Passphrase.swift**
1875
1876```swift
1877import Foundation
1878import KeycaskCore
1879
1880enum Passphrase {
1881    static let variable = "KEYCASK_PASSPHRASE"
1882
1883    static func obtain(
1884        confirm: Bool, environment: [String: String] = ProcessInfo.processInfo.environment
1885    ) throws -> String {
1886        if let fromEnv = environment[variable] {
1887            return try validated(fromEnv)
1888        }
1889        guard Terminal.stdinIsTTY else {
1890            throw KeycaskError.usage("no passphrase: set \(variable) or run on a terminal")
1891        }
1892        let first = try Terminal.readSecretLine(prompt: "Passphrase: ")
1893        if confirm {
1894            let second = try Terminal.readSecretLine(prompt: "Confirm passphrase: ")
1895            guard first == second else { throw KeycaskError.failure("passphrases do not match") }
1896        }
1897        return try validated(first)
1898    }
1899
1900    private static func validated(_ passphrase: String) throws -> String {
1901        guard !passphrase.isEmpty else { throw KeycaskError.failure("passphrase is empty") }
1902        return passphrase
1903    }
1904}
1905```
1906
1907- [ ] **Step 8: Write AtomicFile.swift**
1908
1909```swift
1910import Foundation
1911import KeycaskCore
1912
1913#if canImport(Darwin)
1914    import Darwin
1915#elseif canImport(Glibc)
1916    import Glibc
1917#elseif canImport(Musl)
1918    import Musl
1919#elseif os(Windows)
1920    import WinSDK
1921#endif
1922
1923enum AtomicFile {
1924    static func write(_ data: Data, to url: URL) throws {
1925        let directory = url.deletingLastPathComponent()
1926        let temp = url.appendingPathExtension("tmp")
1927        do {
1928            try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
1929            try writePrivate(data, to: temp)
1930            try replace(url, with: temp)
1931        } catch let error as KeycaskError {
1932            try? FileManager.default.removeItem(at: temp)
1933            throw error
1934        } catch {
1935            try? FileManager.default.removeItem(at: temp)
1936            throw KeycaskError.io("write \(url.path): \(error)")
1937        }
1938    }
1939
1940    #if os(Windows)
1941        private static func writePrivate(_ data: Data, to url: URL) throws {
1942            try data.write(to: url)
1943            let handle = try FileHandle(forWritingTo: url)
1944            try handle.synchronize()
1945            try handle.close()
1946        }
1947
1948        private static func replace(_ target: URL, with temp: URL) throws {
1949            let ok = temp.path.withCString(encodedAs: UTF16.self) { src in
1950                target.path.withCString(encodedAs: UTF16.self) { dst in
1951                    MoveFileExW(src, dst, DWORD(MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
1952                }
1953            }
1954            guard ok.boolValue else { throw KeycaskError.io("rename \(temp.path): error \(GetLastError())") }
1955        }
1956    #else
1957        private static func writePrivate(_ data: Data, to url: URL) throws {
1958            let fd = open(url.path, O_WRONLY | O_CREAT | O_TRUNC, 0o600)
1959            guard fd >= 0 else {
1960                throw KeycaskError.io("open \(url.path): \(String(cString: strerror(errno)))")
1961            }
1962            let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
1963            try handle.write(contentsOf: data)
1964            try handle.synchronize()
1965            try handle.close()
1966        }
1967
1968        private static func replace(_ target: URL, with temp: URL) throws {
1969            guard rename(temp.path, target.path) == 0 else {
1970                throw KeycaskError.io("rename \(temp.path): \(String(cString: strerror(errno)))")
1971            }
1972        }
1973    #endif
1974}
1975```
1976
1977- [ ] **Step 9: Write OpenVault.swift**
1978
1979```swift
1980import Foundation
1981import KeycaskCore
1982
1983struct OpenVault {
1984    var vault: Vault
1985    let kdf: Envelope.KDFParams
1986    let url: URL
1987    let passphrase: String
1988
1989    static func load(_ options: GlobalOptions) throws -> OpenVault {
1990        let url = Paths.vaultURL(override: options.vault)
1991        let data: Data
1992        do {
1993            data = try Data(contentsOf: url)
1994        } catch let error as CocoaError where error.code == .fileReadNoSuchFile {
1995            throw KeycaskError.noVault(url.path)
1996        } catch {
1997            if !FileManager.default.fileExists(atPath: url.path) {
1998                throw KeycaskError.noVault(url.path)
1999            }
2000            throw KeycaskError.io("read \(url.path): \(error)")
2001        }
2002        let envelope = try Envelope(parsing: data)
2003        let passphrase = try Passphrase.obtain(confirm: false)
2004        let plaintext = try envelope.open(passphrase: passphrase)
2005        let vault = try VaultCodec.decode(plaintext)
2006        return OpenVault(vault: vault, kdf: envelope.kdf, url: url, passphrase: passphrase)
2007    }
2008
2009    static func create(_ options: GlobalOptions) throws -> URL {
2010        let url = Paths.vaultURL(override: options.vault)
2011        guard !FileManager.default.fileExists(atPath: url.path) else {
2012            throw KeycaskError.vaultExists(url.path)
2013        }
2014        let passphrase = try Passphrase.obtain(confirm: true)
2015        let fresh = OpenVault(vault: Vault(), kdf: .fresh(), url: url, passphrase: passphrase)
2016        try fresh.save()
2017        return url
2018    }
2019
2020    func save() throws {
2021        let plaintext = try VaultCodec.encode(vault)
2022        let envelope = try Envelope.seal(plaintext, passphrase: passphrase, kdf: kdf)
2023        try AtomicFile.write(try envelope.encoded(), to: url)
2024    }
2025}
2026```
2027
2028- [ ] **Step 10: Write Keycask.swift and Commands/Init.swift**
2029
2030`Sources/keycask/Keycask.swift`:
2031
2032```swift
2033import ArgumentParser
2034
2035struct GlobalOptions: ParsableArguments {
2036    @Option(name: .long, help: "Path to the vault file.")
2037    var vault: String?
2038}
2039
2040struct Keycask: ParsableCommand {
2041    static let configuration = CommandConfiguration(
2042        commandName: "keycask",
2043        abstract: "Command-line password manager. One passphrase-encrypted vault file.",
2044        subcommands: [Init.self]
2045    )
2046}
2047```
2048
2049`Sources/keycask/Commands/Init.swift`:
2050
2051```swift
2052import ArgumentParser
2053
2054struct Init: ParsableCommand {
2055    static let configuration = CommandConfiguration(abstract: "Create an empty vault.")
2056
2057    @OptionGroup var global: GlobalOptions
2058
2059    func run() throws {
2060        let url = try OpenVault.create(global)
2061        print("created \(url.path)")
2062    }
2063}
2064```
2065
2066- [ ] **Step 11: Write main.swift**
2067
2068```swift
2069import ArgumentParser
2070import Foundation
2071import KeycaskCore
2072
2073func fail(_ text: String, code: Int32) -> Never {
2074    FileHandle.standardError.write(Data((text + "\n").utf8))
2075    exit(code)
2076}
2077
2078do {
2079    var command = try Keycask.parseAsRoot()
2080    try command.run()
2081} catch let error as KeycaskError {
2082    fail(error.message, code: error.exitCode)
2083} catch {
2084    let text = Keycask.fullMessage(for: error)
2085    if Keycask.exitCode(for: error).isSuccess {
2086        print(text)
2087        exit(0)
2088    }
2089    fail(text, code: 2)
2090}
2091```
2092
2093- [ ] **Step 12: Run tests**
2094
2095Run: `swift test --filter 'InitTests|PathsTests'`
2096Expected: all pass. The `init` tests take about half a second each because of 600000 PBKDF2 rounds; that is expected.
2097
2098- [ ] **Step 13: Lint and commit**
2099
2100```bash
2101swift format lint --strict --recursive Sources Tests
2102git add Sources/keycask Tests/KeycaskCLITests
2103git commit -m "Add CLI skeleton with init, paths, passphrase, atomic write"
2104```
2105
2106---
2107
2108### Task 10: `add`, `show`, and output formatting
2109
2110**Files:**
2111- Create: `Sources/keycask/Output.swift`
2112- Create: `Sources/keycask/Commands/Add.swift`
2113- Create: `Sources/keycask/Commands/Show.swift`
2114- Modify: `Sources/keycask/Keycask.swift` (register subcommands)
2115- Create: `Tests/KeycaskCLITests/AddShowTests.swift`
2116
2117**Interfaces:**
2118- Consumes: `OpenVault`, `Generator`, `Entry`, `Terminal`, `VaultCodec.makeEncoder()`.
2119- Produces:
2120
2121```swift
2122enum Output {
2123    static let mask = "********"
2124    static func masked(_ entry: Entry, reveal: Bool) -> Entry
2125    static func text(_ entry: Entry, reveal: Bool) -> String       // "field: value" lines
2126    static func table(_ entries: [Entry]) -> String                // id name username url
2127    static func json(_ entries: [Entry], reveal: Bool) throws -> String
2128    static func json(_ entry: Entry, reveal: Bool) throws -> String
2129    static func field(_ entry: Entry, named: String) throws -> String  // usage error for unknown field
2130}
2131enum PasswordInput {
2132    static func read(prompt: String) throws -> String   // TTY: hidden prompt; else first line of stdin
2133}
2134struct PasswordOptions: ParsableArguments { var generate: Bool; var length: Int?; var words: Int?; func validate(); func newPassword() throws -> String? }
2135```
2136
2137Non-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.
2138
2139- [ ] **Step 1: Write the failing tests**
2140
2141`Tests/KeycaskCLITests/AddShowTests.swift`:
2142
2143```swift
2144import Foundation
2145import Testing
2146
2147@Suite struct AddShowTests {
2148    @Test func addReadsPasswordFromStdinAndPrintsID() throws {
2149        let cli = try CLI.initialized()
2150        let r = try cli.run(["add", "github", "-u", "cmc", "--url", "https://github.com", "--tag", "dev"],
2151                            stdin: "hunter2\n")
2152        #expect(r.status == 0)
2153        let id = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
2154        #expect(id.count == 8)
2155
2156        let shown = try cli.run(["show", id])
2157        #expect(shown.status == 0)
2158        #expect(shown.stdout.contains("name: github"))
2159        #expect(shown.stdout.contains("username: cmc"))
2160        #expect(shown.stdout.contains("password: ********"))
2161        #expect(shown.stdout.contains("tags: dev"))
2162        #expect(!shown.stdout.contains("hunter2"))
2163    }
2164
2165    @Test func showByNameRevealAndField() throws {
2166        let cli = try CLI.initialized()
2167        try cli.run(["add", "github"], stdin: "hunter2\n")
2168        let revealed = try cli.run(["show", "github", "--reveal"])
2169        #expect(revealed.stdout.contains("password: hunter2"))
2170        let field = try cli.run(["show", "github", "--field", "password"])
2171        #expect(field.stdout == "hunter2\n")
2172        let missing = try cli.run(["show", "github", "--field", "url"])
2173        #expect(missing.status == 0)
2174        #expect(missing.stdout == "\n")
2175        let unknown = try cli.run(["show", "github", "--field", "nope"])
2176        #expect(unknown.status == 2)
2177    }
2178
2179    @Test func jsonMasksUnlessReveal() throws {
2180        let cli = try CLI.initialized()
2181        try cli.run(["add", "github", "-u", "cmc"], stdin: "hunter2\n")
2182        let masked = try cli.run(["show", "github", "--json"])
2183        let obj = try JSONSerialization.jsonObject(with: Data(masked.stdout.utf8)) as! [String: Any]
2184        #expect(obj["name"] as? String == "github")
2185        #expect(obj["username"] as? String == "cmc")
2186        #expect(obj["password"] as? String == "********")
2187        #expect((obj["id"] as? String)?.count == 8)
2188        #expect((obj["created"] as? String)?.hasSuffix("Z") == true)
2189        let revealed = try cli.run(["show", "github", "--json", "--reveal"])
2190        let obj2 = try JSONSerialization.jsonObject(with: Data(revealed.stdout.utf8)) as! [String: Any]
2191        #expect(obj2["password"] as? String == "hunter2")
2192    }
2193
2194    @Test func addGenerateAndWords() throws {
2195        let cli = try CLI.initialized()
2196        try cli.run(["add", "a", "--generate"])
2197        try cli.run(["add", "b", "--generate", "--length", "40"])
2198        try cli.run(["add", "c", "--words", "4"])
2199        #expect(try cli.run(["show", "a", "--field", "password"]).stdout.count == 25)
2200        #expect(try cli.run(["show", "b", "--field", "password"]).stdout.count == 41)
2201        let words = try cli.run(["show", "c", "--field", "password"]).stdout
2202            .trimmingCharacters(in: .newlines).split(separator: "-")
2203        #expect(words.count == 4)
2204    }
2205
2206    @Test func generateAndWordsTogetherIsUsageError() throws {
2207        let cli = try CLI.initialized()
2208        let r = try cli.run(["add", "a", "--generate", "--words", "3"])
2209        #expect(r.status == 2)
2210    }
2211
2212    @Test func duplicateNamesAreAllowedAndAmbiguousOnShow() throws {
2213        let cli = try CLI.initialized()
2214        let a = try cli.run(["add", "gh", "-u", "one", "--generate"]).stdout.trimmingCharacters(in: .newlines)
2215        let b = try cli.run(["add", "gh", "-u", "two", "--generate"]).stdout.trimmingCharacters(in: .newlines)
2216        let r = try cli.run(["show", "gh"])
2217        #expect(r.status == 5)
2218        #expect(r.stderr.contains(a) && r.stderr.contains(b))
2219        #expect(try cli.run(["show", a]).stdout.contains("username: one"))
2220    }
2221
2222    @Test func missingEntryIsNotFound() throws {
2223        let cli = try CLI.initialized()
2224        let r = try cli.run(["show", "nope"])
2225        #expect(r.status == 3)
2226        #expect(r.stderr == "nope: not found\n")
2227    }
2228
2229    @Test func wrongPassphraseCannotDecrypt() throws {
2230        let cli = try CLI.initialized()
2231        let r = try cli.run(["show", "x"], passphrase: "wrong")
2232        #expect(r.status == 4)
2233        #expect(r.stderr.contains("cannot decrypt"))
2234    }
2235
2236    @Test func missingVaultIsNotFound() throws {
2237        let cli = try CLI()
2238        let r = try cli.run(["show", "x"])
2239        #expect(r.status == 3)
2240        #expect(r.stderr.contains("keycask init"))
2241    }
2242
2243    @Test func corruptVaultIsFailure() throws {
2244        let cli = try CLI.initialized()
2245        try Data("{}".utf8).write(to: cli.vault)
2246        let r = try cli.run(["show", "x"])
2247        #expect(r.status == 1)
2248        #expect(r.stderr.hasPrefix("vault is corrupt"))
2249    }
2250}
2251```
2252
2253- [ ] **Step 2: Run tests to verify they fail**
2254
2255Run: `swift test --filter AddShowTests`
2256Expected: failures, `add` is an unknown subcommand (exit 2).
2257
2258- [ ] **Step 3: Write Output.swift**
2259
2260```swift
2261import Foundation
2262import KeycaskCore
2263
2264enum Output {
2265    static let mask = "********"
2266
2267    static func masked(_ entry: Entry, reveal: Bool) -> Entry {
2268        guard !reveal else { return entry }
2269        var copy = entry
2270        copy.password = mask
2271        return copy
2272    }
2273
2274    static func text(_ entry: Entry, reveal: Bool) -> String {
2275        let e = masked(entry, reveal: reveal)
2276        var lines = ["id: \(e.id.rawValue)", "name: \(e.name)"]
2277        if let u = e.username { lines.append("username: \(u)") }
2278        lines.append("password: \(e.password)")
2279        if let u = e.url { lines.append("url: \(u)") }
2280        if !e.tags.isEmpty { lines.append("tags: \(e.tags.joined(separator: ", "))") }
2281        if let n = e.notes { lines.append("notes: \(n)") }
2282        lines.append("created: \(iso(e.created))")
2283        lines.append("updated: \(iso(e.updated))")
2284        return lines.joined(separator: "\n") + "\n"
2285    }
2286
2287    static func table(_ entries: [Entry]) -> String {
2288        guard !entries.isEmpty else { return "" }
2289        let rows = entries.map { [$0.id.rawValue, $0.name, $0.username ?? "", $0.url ?? ""] }
2290        let widths = (0..<3).map { col in rows.map { $0[col].count }.max() ?? 0 }
2291        return rows.map { row in
2292            let padded = (0..<3).map { row[$0].padding(toLength: widths[$0], withPad: " ", startingAt: 0) }
2293            return (padded + [row[3]]).joined(separator: "  ")
2294                .trimmingCharacters(in: .whitespaces)
2295        }.joined(separator: "\n") + "\n"
2296    }
2297
2298    static func json(_ entries: [Entry], reveal: Bool) throws -> String {
2299        try encode(entries.map { masked($0, reveal: reveal) })
2300    }
2301
2302    static func json(_ entry: Entry, reveal: Bool) throws -> String {
2303        try encode(masked(entry, reveal: reveal))
2304    }
2305
2306    static func field(_ entry: Entry, named name: String) throws -> String {
2307        switch name {
2308        case "id": entry.id.rawValue
2309        case "name": entry.name
2310        case "username": entry.username ?? ""
2311        case "password": entry.password
2312        case "url": entry.url ?? ""
2313        case "notes": entry.notes ?? ""
2314        case "tags": entry.tags.joined(separator: ",")
2315        case "created": iso(entry.created)
2316        case "updated": iso(entry.updated)
2317        default: throw KeycaskError.usage("unknown field \(name)")
2318        }
2319    }
2320
2321    private static func encode(_ value: some Encodable) throws -> String {
2322        let encoder = VaultCodec.makeEncoder()
2323        encoder.outputFormatting.insert(.prettyPrinted)
2324        do {
2325            return String(decoding: try encoder.encode(value), as: UTF8.self) + "\n"
2326        } catch {
2327            throw KeycaskError.io("encode json: \(error)")
2328        }
2329    }
2330
2331    private static func iso(_ date: Date) -> String {
2332        date.formatted(.iso8601)
2333    }
2334}
2335```
2336
2337- [ ] **Step 4: Write password input and the shared password options**
2338
2339Add to `Sources/keycask/Commands/Add.swift`:
2340
2341```swift
2342import ArgumentParser
2343import Foundation
2344import KeycaskCore
2345
2346enum PasswordInput {
2347    static func read(prompt: String) throws -> String {
2348        if Terminal.stdinIsTTY {
2349            return try Terminal.readSecretLine(prompt: prompt)
2350        }
2351        guard let line = Swift.readLine(strippingNewline: true) else {
2352            throw KeycaskError.usage("no password: pass one on stdin or run on a terminal")
2353        }
2354        return line
2355    }
2356}
2357
2358struct PasswordOptions: ParsableArguments {
2359    @Flag(name: .long, help: "Generate a random password.")
2360    var generate = false
2361
2362    @Option(name: .long, help: "Length of the generated password (default 24).")
2363    var length: Int?
2364
2365    @Option(name: .long, help: "Generate a passphrase of this many words instead.")
2366    var words: Int?
2367
2368    mutating func validate() throws {
2369        if generate, words != nil {
2370            throw ValidationError("--generate and --words are mutually exclusive")
2371        }
2372        if let length, length < 1 { throw ValidationError("--length must be at least 1") }
2373        if let words, words < 1 { throw ValidationError("--words must be at least 1") }
2374        if length != nil, !generate, words == nil {
2375            throw ValidationError("--length requires --generate")
2376        }
2377    }
2378
2379    /// nil means the caller must prompt.
2380    func newPassword() -> String? {
2381        if let words { return Generator.passphrase(words: words) }
2382        if generate { return Generator.password(length: length ?? Generator.defaultLength) }
2383        return nil
2384    }
2385}
2386
2387struct Add: ParsableCommand {
2388    static let configuration = CommandConfiguration(abstract: "Add an entry.")
2389
2390    @OptionGroup var global: GlobalOptions
2391    @Argument(help: "Entry name. Names may repeat; the printed id is unique.") var name: String
2392    @Option(name: [.short, .customLong("username")], help: "Username.") var username: String?
2393    @Option(name: .long, help: "URL.") var url: String?
2394    @Option(name: .long, help: "Notes.") var notes: String?
2395    @Option(name: .long, help: "Tag. Repeatable.") var tag: [String] = []
2396    @OptionGroup var password: PasswordOptions
2397
2398    func run() throws {
2399        var open = try OpenVault.load(global)
2400        let secret = try password.newPassword() ?? PasswordInput.read(prompt: "Password: ")
2401        var entry = Entry(name: name, username: username, password: secret, url: url,
2402                          notes: notes, tags: tag)
2403        while open.vault.entry(id: entry.id) != nil {
2404            entry = Entry(name: name, username: username, password: secret, url: url,
2405                          notes: notes, tags: tag)
2406        }
2407        try open.vault.add(entry)
2408        try open.save()
2409        print(entry.id.rawValue)
2410    }
2411}
2412```
2413
2414- [ ] **Step 5: Write Commands/Show.swift**
2415
2416```swift
2417import ArgumentParser
2418import KeycaskCore
2419
2420struct Show: ParsableCommand {
2421    static let configuration = CommandConfiguration(abstract: "Show an entry.")
2422
2423    @OptionGroup var global: GlobalOptions
2424    @Argument(help: "Entry id or name.") var ref: String
2425    @Flag(name: .long, help: "Show the password.") var reveal = false
2426    @Option(name: .long, help: "Print one field, unmasked.") var field: String?
2427    @Flag(name: .long, help: "JSON output.") var json = false
2428
2429    func run() throws {
2430        let open = try OpenVault.load(global)
2431        let entry = try open.vault.resolve(ref)
2432        if let field {
2433            print(try Output.field(entry, named: field))
2434        } else if json {
2435            print(try Output.json(entry, reveal: reveal), terminator: "")
2436        } else {
2437            print(Output.text(entry, reveal: reveal), terminator: "")
2438        }
2439    }
2440}
2441```
2442
2443- [ ] **Step 6: Register the subcommands**
2444
2445In `Keycask.swift`: `subcommands: [Init.self, Add.self, Show.self]`.
2446
2447- [ ] **Step 7: Amend the spec**
2448
2449In `docs/superpowers/specs/2026-09-17-keycask-design.md`, after the sentence beginning "Passphrase input:", add a paragraph:
2450
2451```
2452Password input for `add` and `edit --password`: on a terminal, a hidden
2453prompt. Without a terminal, the first line of stdin. Neither available is
2454exit 2.
2455```
2456
2457- [ ] **Step 8: Run tests**
2458
2459Run: `swift test --filter AddShowTests`
2460Expected: 10 tests pass.
2461
2462- [ ] **Step 9: Lint and commit**
2463
2464```bash
2465swift format lint --strict --recursive Sources Tests
2466git add Sources/keycask Tests/KeycaskCLITests docs
2467git commit -m "Add add and show commands with masked output"
2468```
2469
2470---
2471
2472### Task 11: `ls` and `find`
2473
2474**Files:**
2475- Create: `Sources/keycask/Commands/Ls.swift`
2476- Create: `Sources/keycask/Commands/Find.swift`
2477- Modify: `Sources/keycask/Keycask.swift`
2478- Create: `Tests/KeycaskCLITests/LsFindTests.swift`
2479
2480**Interfaces:**
2481- Consumes: `OpenVault`, `Output.table`, `Output.json(_:[Entry])`, `Vault.filter(tag:)`, `Vault.search`, `Vault.sortedEntries`.
2482
2483- [ ] **Step 1: Write the failing tests**
2484
2485```swift
2486import Foundation
2487import Testing
2488
2489@Suite struct LsFindTests {
2490    func seeded() throws -> CLI {
2491        let cli = try CLI.initialized()
2492        try cli.run(["add", "github", "-u", "cmc", "--url", "https://github.com", "--tag", "Dev", "--generate"])
2493        try cli.run(["add", "bank", "--url", "https://bank.example", "--notes", "downtown branch", "--generate"])
2494        try cli.run(["add", "Alpha", "--tag", "dev", "--generate"])
2495        return cli
2496    }
2497
2498    @Test func lsSortsByNameAndShowsColumns() throws {
2499        let cli = try seeded()
2500        let r = try cli.run(["ls"])
2501        #expect(r.status == 0)
2502        let names = r.lines.map { String($0.split(separator: " ", omittingEmptySubsequences: true)[1]) }
2503        #expect(names == ["Alpha", "bank", "github"])
2504        #expect(r.stdout.contains("cmc"))
2505        #expect(r.stdout.contains("https://github.com"))
2506    }
2507
2508    @Test func lsTagFilterIsCaseInsensitive() throws {
2509        let cli = try seeded()
2510        let r = try cli.run(["ls", "--tag", "DEV"])
2511        #expect(r.lines.count == 2)
2512        #expect(!r.stdout.contains("bank"))
2513    }
2514
2515    @Test func lsJsonIsAnArrayWithMaskedPasswords() throws {
2516        let cli = try seeded()
2517        let r = try cli.run(["ls", "--json"])
2518        let arr = try JSONSerialization.jsonObject(with: Data(r.stdout.utf8)) as! [[String: Any]]
2519        #expect(arr.count == 3)
2520        #expect(arr.allSatisfy { $0["password"] as? String == "********" })
2521    }
2522
2523    @Test func emptyVaultListsNothing() throws {
2524        let cli = try CLI.initialized()
2525        let r = try cli.run(["ls"])
2526        #expect(r.status == 0)
2527        #expect(r.stdout == "")
2528        let j = try cli.run(["ls", "--json"])
2529        #expect(j.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == "[]")
2530    }
2531
2532    @Test func findMatchesNotesURLTagsCaseInsensitively() throws {
2533        let cli = try seeded()
2534        #expect(try cli.run(["find", "DOWNTOWN"]).lines.count == 1)
2535        #expect(try cli.run(["find", "github.com"]).lines.count == 1)
2536        #expect(try cli.run(["find", "dev"]).lines.count == 2)
2537        let none = try cli.run(["find", "zzz"])
2538        #expect(none.status == 0)
2539        #expect(none.stdout == "")
2540    }
2541
2542    @Test func findJson() throws {
2543        let cli = try seeded()
2544        let r = try cli.run(["find", "bank", "--json"])
2545        let arr = try JSONSerialization.jsonObject(with: Data(r.stdout.utf8)) as! [[String: Any]]
2546        #expect(arr.count == 1)
2547        #expect(arr[0]["name"] as? String == "bank")
2548    }
2549}
2550```
2551
2552- [ ] **Step 2: Run tests to verify they fail**
2553
2554Run: `swift test --filter LsFindTests`
2555Expected: failures, unknown subcommand.
2556
2557- [ ] **Step 3: Write Ls.swift and Find.swift**
2558
2559`Commands/Ls.swift`:
2560
2561```swift
2562import ArgumentParser
2563import KeycaskCore
2564
2565struct Ls: ParsableCommand {
2566    static let configuration = CommandConfiguration(abstract: "List entries.")
2567
2568    @OptionGroup var global: GlobalOptions
2569    @Option(name: .long, help: "Only entries with this tag.") var tag: String?
2570    @Flag(name: .long, help: "JSON output.") var json = false
2571
2572    func run() throws {
2573        let open = try OpenVault.load(global)
2574        let entries = tag.map { open.vault.filter(tag: $0) } ?? open.vault.sortedEntries
2575        if json {
2576            print(try Output.json(entries, reveal: false), terminator: "")
2577        } else {
2578            print(Output.table(entries), terminator: "")
2579        }
2580    }
2581}
2582```
2583
2584`Commands/Find.swift`:
2585
2586```swift
2587import ArgumentParser
2588import KeycaskCore
2589
2590struct Find: ParsableCommand {
2591    static let configuration = CommandConfiguration(abstract: "Search entries.")
2592
2593    @OptionGroup var global: GlobalOptions
2594    @Argument(help: "Case-insensitive substring.") var query: String
2595    @Flag(name: .long, help: "JSON output.") var json = false
2596
2597    func run() throws {
2598        let open = try OpenVault.load(global)
2599        let entries = open.vault.search(query)
2600        if json {
2601            print(try Output.json(entries, reveal: false), terminator: "")
2602        } else {
2603            print(Output.table(entries), terminator: "")
2604        }
2605    }
2606}
2607```
2608
2609Register both: `subcommands: [Init.self, Add.self, Show.self, Ls.self, Find.self]`.
2610
2611- [ ] **Step 4: Run tests**
2612
2613Run: `swift test --filter LsFindTests`
2614Expected: 6 tests pass.
2615
2616- [ ] **Step 5: Lint and commit**
2617
2618```bash
2619swift format lint --strict --recursive Sources Tests
2620git add Sources/keycask Tests/KeycaskCLITests
2621git commit -m "Add ls and find commands"
2622```
2623
2624---
2625
2626### Task 12: `edit` and `rm`
2627
2628**Files:**
2629- Create: `Sources/keycask/Commands/Edit.swift`
2630- Create: `Sources/keycask/Commands/Rm.swift`
2631- Modify: `Sources/keycask/Keycask.swift`
2632- Create: `Tests/KeycaskCLITests/EditRmTests.swift`
2633
2634**Interfaces:**
2635- Consumes: `OpenVault`, `Vault.update`, `Vault.remove`, `PasswordOptions`, `PasswordInput`, `Terminal.confirm`, `Terminal.stdinIsTTY`.
2636
2637- [ ] **Step 1: Write the failing tests**
2638
2639```swift
2640import Foundation
2641import Testing
2642
2643@Suite struct EditRmTests {
2644    @Test func editChangesFieldsAndBumpsUpdated() throws {
2645        let cli = try CLI.initialized()
2646        try cli.run(["add", "gh", "--tag", "a", "--generate"])
2647        let before = try JSONSerialization.jsonObject(
2648            with: Data(try cli.run(["show", "gh", "--json"]).stdout.utf8)) as! [String: Any]
2649        let r = try cli.run([
2650            "edit", "gh", "--name", "github", "-u", "cmc", "--url", "https://x", "--notes", "n",
2651            "--tag", "b", "--untag", "a",
2652        ])
2653        #expect(r.status == 0)
2654        let after = try JSONSerialization.jsonObject(
2655            with: Data(try cli.run(["show", "github", "--json"]).stdout.utf8)) as! [String: Any]
2656        #expect(after["name"] as? String == "github")
2657        #expect(after["username"] as? String == "cmc")
2658        #expect(after["url"] as? String == "https://x")
2659        #expect(after["notes"] as? String == "n")
2660        #expect(after["tags"] as? [String] == ["b"])
2661        #expect(after["created"] as? String == before["created"] as? String)
2662        #expect(after["id"] as? String == before["id"] as? String)
2663    }
2664
2665    @Test func editPasswordFromStdinAndGenerate() throws {
2666        let cli = try CLI.initialized()
2667        try cli.run(["add", "gh", "--generate"])
2668        try cli.run(["edit", "gh", "--password"], stdin: "newpass\n")
2669        #expect(try cli.run(["show", "gh", "--field", "password"]).stdout == "newpass\n")
2670        try cli.run(["edit", "gh", "--generate", "--length", "30"])
2671        #expect(try cli.run(["show", "gh", "--field", "password"]).stdout.count == 31)
2672    }
2673
2674    @Test func editWithNoChangesIsUsageError() throws {
2675        let cli = try CLI.initialized()
2676        try cli.run(["add", "gh", "--generate"])
2677        let r = try cli.run(["edit", "gh"])
2678        #expect(r.status == 2)
2679    }
2680
2681    @Test func editUnknownIsNotFound() throws {
2682        let cli = try CLI.initialized()
2683        #expect(try cli.run(["edit", "nope", "--url", "x"]).status == 3)
2684    }
2685
2686    @Test func rmWithYesRemoves() throws {
2687        let cli = try CLI.initialized()
2688        let id = try cli.run(["add", "gh", "--generate"]).stdout.trimmingCharacters(in: .newlines)
2689        let r = try cli.run(["rm", id, "--yes"])
2690        #expect(r.status == 0)
2691        #expect(try cli.run(["show", id]).status == 3)
2692        #expect(try cli.run(["ls"]).stdout == "")
2693    }
2694
2695    @Test func rmWithoutYesAndWithoutTTYIsUsageError() throws {
2696        let cli = try CLI.initialized()
2697        try cli.run(["add", "gh", "--generate"])
2698        let r = try cli.run(["rm", "gh"], stdin: "y\n")
2699        #expect(r.status == 2)
2700        #expect(r.stderr.contains("--yes"))
2701        #expect(try cli.run(["ls"]).lines.count == 1)
2702    }
2703
2704    @Test func rmAmbiguousNameLists() throws {
2705        let cli = try CLI.initialized()
2706        try cli.run(["add", "gh", "--generate"])
2707        try cli.run(["add", "gh", "--generate"])
2708        let r = try cli.run(["rm", "gh", "--yes"])
2709        #expect(r.status == 5)
2710        #expect(try cli.run(["ls"]).lines.count == 2)
2711    }
2712}
2713```
2714
2715- [ ] **Step 2: Run tests to verify they fail**
2716
2717Run: `swift test --filter EditRmTests`
2718Expected: failures, unknown subcommand.
2719
2720- [ ] **Step 3: Write Edit.swift**
2721
2722```swift
2723import ArgumentParser
2724import KeycaskCore
2725
2726struct Edit: ParsableCommand {
2727    static let configuration = CommandConfiguration(abstract: "Change an entry.")
2728
2729    @OptionGroup var global: GlobalOptions
2730    @Argument(help: "Entry id or name.") var ref: String
2731    @Option(name: .long, help: "New name.") var name: String?
2732    @Option(name: [.short, .customLong("username")], help: "New username.") var username: String?
2733    @Option(name: .long, help: "New URL.") var url: String?
2734    @Option(name: .long, help: "New notes.") var notes: String?
2735    @Option(name: .long, help: "Add a tag. Repeatable.") var tag: [String] = []
2736    @Option(name: .long, help: "Remove a tag. Repeatable.") var untag: [String] = []
2737    @Flag(name: .long, help: "Prompt for a new password.") var password = false
2738    @OptionGroup var generated: PasswordOptions
2739
2740    mutating func validate() throws {
2741        let changes = [name, username, url, notes].contains { $0 != nil }
2742            || !tag.isEmpty || !untag.isEmpty || password || generated.generate || generated.words != nil
2743        guard changes else { throw ValidationError("nothing to change") }
2744        if password, generated.newPassword() != nil {
2745            throw ValidationError("--password cannot be combined with --generate or --words")
2746        }
2747    }
2748
2749    func run() throws {
2750        var open = try OpenVault.load(global)
2751        let target = try open.vault.resolve(ref)
2752        let newSecret: String? =
2753            password ? try PasswordInput.read(prompt: "New password: ") : generated.newPassword()
2754        try open.vault.update(id: target.id) { e in
2755            if let name { e.name = name }
2756            if let username { e.username = username }
2757            if let url { e.url = url }
2758            if let notes { e.notes = notes }
2759            if let newSecret { e.password = newSecret }
2760            let drop = Set(untag.map { $0.lowercased() })
2761            e.tags = e.tags.filter { !drop.contains($0.lowercased()) } + tag
2762        }
2763        try open.save()
2764    }
2765}
2766```
2767
2768- [ ] **Step 4: Write Rm.swift**
2769
2770```swift
2771import ArgumentParser
2772import KeycaskCore
2773
2774struct Rm: ParsableCommand {
2775    static let configuration = CommandConfiguration(abstract: "Remove an entry.")
2776
2777    @OptionGroup var global: GlobalOptions
2778    @Argument(help: "Entry id or name.") var ref: String
2779    @Flag(name: .long, help: "Do not ask for confirmation.") var yes = false
2780
2781    func run() throws {
2782        var open = try OpenVault.load(global)
2783        let target = try open.vault.resolve(ref)
2784        if !yes {
2785            guard Terminal.stdinIsTTY else {
2786                throw KeycaskError.usage("refusing to remove without --yes when not on a terminal")
2787            }
2788            guard Terminal.confirm("remove \(target.name) (\(target.id.rawValue))?") else {
2789                throw KeycaskError.failure("aborted")
2790            }
2791        }
2792        try open.vault.remove(id: target.id)
2793        try open.save()
2794    }
2795}
2796```
2797
2798Register: `subcommands: [Init.self, Add.self, Show.self, Ls.self, Find.self, Edit.self, Rm.self]`.
2799
2800- [ ] **Step 5: Run tests**
2801
2802Run: `swift test --filter EditRmTests`
2803Expected: 7 tests pass.
2804
2805- [ ] **Step 6: Lint and commit**
2806
2807```bash
2808swift format lint --strict --recursive Sources Tests
2809git add Sources/keycask Tests/KeycaskCLITests
2810git commit -m "Add edit and rm commands"
2811```
2812
2813---
2814
2815### Task 13: `generate`
2816
2817**Files:**
2818- Create: `Sources/keycask/Commands/Generate.swift`
2819- Modify: `Sources/keycask/Keycask.swift`
2820- Create: `Tests/KeycaskCLITests/GenerateTests.swift`
2821
2822**Interfaces:**
2823- 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.
2824
2825- [ ] **Step 1: Write the failing tests**
2826
2827```swift
2828import Foundation
2829import Testing
2830
2831@Suite struct GenerateTests {
2832    @Test func defaultIs24Characters() throws {
2833        let cli = try CLI()
2834        let r = try cli.run(["generate"], passphrase: nil)
2835        #expect(r.status == 0)
2836        #expect(r.stdout.count == 25)
2837    }
2838
2839    @Test func lengthAndWords() throws {
2840        let cli = try CLI()
2841        #expect(try cli.run(["generate", "--length", "12"], passphrase: nil).stdout.count == 13)
2842        let w = try cli.run(["generate", "--words", "6"], passphrase: nil).stdout
2843            .trimmingCharacters(in: .newlines).split(separator: "-")
2844        #expect(w.count == 6)
2845    }
2846
2847    @Test func doesNotNeedAVault() throws {
2848        let cli = try CLI()
2849        #expect(!FileManager.default.fileExists(atPath: cli.vault.path))
2850        #expect(try cli.run(["generate"], passphrase: nil).status == 0)
2851    }
2852
2853    @Test func lengthAndWordsTogetherIsUsageError() throws {
2854        let cli = try CLI()
2855        #expect(try cli.run(["generate", "--length", "3", "--words", "3"], passphrase: nil).status == 2)
2856    }
2857}
2858```
2859
2860- [ ] **Step 2: Run tests to verify they fail**
2861
2862Run: `swift test --filter GenerateTests`
2863Expected: failures, unknown subcommand.
2864
2865- [ ] **Step 3: Write Generate.swift**
2866
2867```swift
2868import ArgumentParser
2869import KeycaskCore
2870
2871struct Generate: ParsableCommand {
2872    static let configuration = CommandConfiguration(abstract: "Generate a password.")
2873
2874    @Option(name: .long, help: "Password length (default 24).") var length: Int?
2875    @Option(name: .long, help: "Passphrase of this many words instead.") var words: Int?
2876    @Flag(name: .long, help: "Copy to the clipboard instead of printing.") var copy = false
2877
2878    mutating func validate() throws {
2879        if length != nil, words != nil {
2880            throw ValidationError("--length and --words are mutually exclusive")
2881        }
2882        if let length, length < 1 { throw ValidationError("--length must be at least 1") }
2883        if let words, words < 1 { throw ValidationError("--words must be at least 1") }
2884    }
2885
2886    func run() throws {
2887        let secret =
2888            words.map { Generator.passphrase(words: $0) }
2889            ?? Generator.password(length: length ?? Generator.defaultLength)
2890        if copy {
2891            throw KeycaskError.failure("clipboard not available")
2892        }
2893        print(secret)
2894    }
2895}
2896```
2897
2898Register: add `Generate.self` to the subcommand list.
2899
2900- [ ] **Step 4: Run tests**
2901
2902Run: `swift test --filter GenerateTests`
2903Expected: 4 tests pass.
2904
2905- [ ] **Step 5: Lint and commit**
2906
2907```bash
2908swift format lint --strict --recursive Sources Tests
2909git add Sources/keycask Tests/KeycaskCLITests
2910git commit -m "Add generate command"
2911```
2912
2913---
2914
2915### Task 14: Clipboard, `clip`, daemon, `generate --copy`
2916
2917**Files:**
2918- Create: `Sources/keycask/Clipboard.swift`
2919- Create: `Sources/keycask/Commands/Clip.swift`
2920- Create: `Sources/keycask/Commands/ClipboardDaemon.swift`
2921- Modify: `Sources/keycask/Commands/Generate.swift`
2922- Modify: `Sources/keycask/Keycask.swift`
2923- Create: `Tests/KeycaskCLITests/ClipboardTests.swift`
2924
2925**Interfaces:**
2926- Produces:
2927
2928```swift
2929enum Clipboard {
2930    struct Handoff: Codable, Equatable { var secret: String; var previous: String }
2931    struct Tool { let copy: [String]; let paste: [String] }
2932    static let timeoutSeconds = 45
2933    static func shouldRestore(secret: String, current: String?) -> Bool
2934    static func findTool(path: String, fileManager: FileManager = .default) -> Tool?
2935    static func read() throws -> String
2936    static func write(_ text: String) throws
2937    static func copyWithTimeout(_ secret: String, seconds: Int = timeoutSeconds) throws
2938    static func runDaemon(seconds: Int) throws
2939}
2940```
2941
2942The tests never touch the real clipboard. They cover the restore decision, tool discovery against a fake PATH, and the daemon's handoff parsing.
2943
2944- [ ] **Step 1: Write the failing tests**
2945
2946`Tests/KeycaskCLITests/ClipboardTests.swift`:
2947
2948```swift
2949import Foundation
2950import Testing
2951
2952@testable import keycask
2953
2954@Suite struct ClipboardTests {
2955    @Test func restoresOnlyWhenClipboardStillHoldsTheSecret() {
2956        #expect(Clipboard.shouldRestore(secret: "s", current: "s"))
2957        #expect(!Clipboard.shouldRestore(secret: "s", current: "user pasted"))
2958        #expect(!Clipboard.shouldRestore(secret: "s", current: nil))
2959    }
2960
2961    @Test func handoffRoundTrips() throws {
2962        let h = Clipboard.Handoff(secret: "s3cret", previous: "old")
2963        let data = try JSONEncoder().encode(h)
2964        #expect(try JSONDecoder().decode(Clipboard.Handoff.self, from: data) == h)
2965    }
2966
2967    @Test func findToolScansPathInOrder() throws {
2968        let dir = FileManager.default.temporaryDirectory
2969            .appendingPathComponent("keycask-clip-\(UUID().uuidString)")
2970        try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
2971        #expect(Clipboard.findTool(path: dir.path) == nil)
2972
2973        #if os(macOS)
2974            let names = ["pbcopy", "pbpaste"]
2975        #elseif os(Windows)
2976            let names = ["clip.exe", "powershell.exe"]
2977        #else
2978            let names = ["xclip"]
2979        #endif
2980        for n in names {
2981            let f = dir.appendingPathComponent(n)
2982            try Data("#!/bin/sh\n".utf8).write(to: f)
2983            try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: f.path)
2984        }
2985        let tool = Clipboard.findTool(path: dir.path)
2986        #expect(tool != nil)
2987        #expect(tool?.copy.first?.hasPrefix(dir.path) == true)
2988    }
2989
2990    @Test func daemonWithoutHandoffFails() throws {
2991        let cli = try CLI()
2992        let r = try cli.run(["clipboard-daemon", "1"], stdin: "not json", passphrase: nil)
2993        #expect(r.status == 1)
2994    }
2995
2996    @Test func daemonIsHiddenFromHelp() throws {
2997        let cli = try CLI()
2998        let r = try cli.run(["--help"], passphrase: nil)
2999        #expect(!r.stdout.contains("clipboard-daemon"))
3000        #expect(r.stdout.contains("clip"))
3001    }
3002
3003    @Test func clipOfMissingEntryIsNotFoundBeforeTouchingClipboard() throws {
3004        let cli = try CLI.initialized()
3005        let r = try cli.run(["clip", "nope"])
3006        #expect(r.status == 3)
3007    }
3008}
3009```
3010
3011- [ ] **Step 2: Run tests to verify they fail**
3012
3013Run: `swift test --filter ClipboardTests`
3014Expected: compile error, `Clipboard` not found.
3015
3016- [ ] **Step 3: Write Clipboard.swift**
3017
3018```swift
3019import Foundation
3020import KeycaskCore
3021
3022enum Clipboard {
3023    struct Handoff: Codable, Equatable {
3024        var secret: String
3025        var previous: String
3026    }
3027
3028    struct Tool: Equatable {
3029        let copy: [String]
3030        let paste: [String]
3031    }
3032
3033    static let timeoutSeconds = 45
3034
3035    static func shouldRestore(secret: String, current: String?) -> Bool {
3036        current == secret
3037    }
3038
3039    static func findTool(
3040        path: String = ProcessInfo.processInfo.environment["PATH"] ?? "",
3041        fileManager: FileManager = .default
3042    ) -> Tool? {
3043        #if os(Windows)
3044            let separator: Character = ";"
3045            let candidates: [(copy: [String], paste: [String])] = [
3046                (["clip.exe"], ["powershell.exe", "-NoProfile", "-Command", "Get-Clipboard -Raw"])
3047            ]
3048        #elseif os(macOS)
3049            let separator: Character = ":"
3050            let candidates: [(copy: [String], paste: [String])] = [(["pbcopy"], ["pbpaste"])]
3051        #else
3052            let separator: Character = ":"
3053            let candidates: [(copy: [String], paste: [String])] = [
3054                (["wl-copy"], ["wl-paste", "--no-newline"]),
3055                (["xclip", "-selection", "clipboard"], ["xclip", "-selection", "clipboard", "-o"]),
3056            ]
3057        #endif
3058        let dirs = path.split(separator: separator).map(String.init)
3059        func locate(_ name: String) -> String? {
3060            for dir in dirs {
3061                let full = URL(fileURLWithPath: dir).appendingPathComponent(name).path
3062                if fileManager.isExecutableFile(atPath: full) { return full }
3063            }
3064            return nil
3065        }
3066        for candidate in candidates {
3067            guard let copy = locate(candidate.copy[0]), let paste = locate(candidate.paste[0]) else {
3068                continue
3069            }
3070            return Tool(
3071                copy: [copy] + candidate.copy.dropFirst(),
3072                paste: [paste] + candidate.paste.dropFirst())
3073        }
3074        return nil
3075    }
3076
3077    static func read() throws -> String {
3078        let tool = try requireTool()
3079        let (status, output) = try runTool(tool.paste, input: nil)
3080        guard status == 0 else { return "" }
3081        return output
3082    }
3083
3084    static func write(_ text: String) throws {
3085        let tool = try requireTool()
3086        let (status, _) = try runTool(tool.copy, input: text)
3087        guard status == 0 else { throw KeycaskError.failure("clipboard tool failed") }
3088    }
3089
3090    static func copyWithTimeout(_ secret: String, seconds: Int = timeoutSeconds) throws {
3091        _ = try requireTool()
3092        let handoff = Handoff(secret: secret, previous: try read())
3093        let process = Process()
3094        process.executableURL = Bundle.main.executableURL
3095        process.arguments = ["clipboard-daemon", String(seconds)]
3096        process.standardOutput = FileHandle.nullDevice
3097        process.standardError = FileHandle.nullDevice
3098        let input = Pipe()
3099        process.standardInput = input
3100        do {
3101            try process.run()
3102            input.fileHandleForWriting.write(try JSONEncoder().encode(handoff))
3103            try input.fileHandleForWriting.close()
3104        } catch {
3105            throw KeycaskError.failure("start clipboard daemon: \(error)")
3106        }
3107    }
3108
3109    static func runDaemon(seconds: Int) throws {
3110        let data = FileHandle.standardInput.readDataToEndOfFile()
3111        let handoff: Handoff
3112        do {
3113            handoff = try JSONDecoder().decode(Handoff.self, from: data)
3114        } catch {
3115            throw KeycaskError.failure("bad handoff")
3116        }
3117        try write(handoff.secret)
3118        Thread.sleep(forTimeInterval: TimeInterval(seconds))
3119        let current = try? read()
3120        guard shouldRestore(secret: handoff.secret, current: current) else { return }
3121        try write(handoff.previous)
3122    }
3123
3124    private static func requireTool() throws -> Tool {
3125        guard let tool = findTool() else {
3126            #if os(Windows)
3127                let hint = "clip.exe and powershell.exe"
3128            #elseif os(macOS)
3129                let hint = "pbcopy and pbpaste"
3130            #else
3131                let hint = "wl-clipboard or xclip"
3132            #endif
3133            throw KeycaskError.failure("no clipboard tool found: install \(hint)")
3134        }
3135        return tool
3136    }
3137
3138    private static func runTool(_ argv: [String], input: String?) throws -> (Int32, String) {
3139        let process = Process()
3140        process.executableURL = URL(fileURLWithPath: argv[0])
3141        process.arguments = Array(argv.dropFirst())
3142        let out = Pipe()
3143        process.standardOutput = out
3144        process.standardError = FileHandle.nullDevice
3145        let inPipe = Pipe()
3146        process.standardInput = inPipe
3147        do {
3148            try process.run()
3149        } catch {
3150            throw KeycaskError.failure("run \(argv[0]): \(error)")
3151        }
3152        if let input { inPipe.fileHandleForWriting.write(Data(input.utf8)) }
3153        try? inPipe.fileHandleForWriting.close()
3154        let data = out.fileHandleForReading.readDataToEndOfFile()
3155        process.waitUntilExit()
3156        return (process.terminationStatus, String(decoding: data, as: UTF8.self))
3157    }
3158}
3159```
3160
3161- [ ] **Step 4: Write Clip.swift and ClipboardDaemon.swift**
3162
3163`Commands/Clip.swift`:
3164
3165```swift
3166import ArgumentParser
3167import KeycaskCore
3168
3169struct Clip: ParsableCommand {
3170    static let configuration = CommandConfiguration(
3171        abstract: "Copy a field to the clipboard. Clears after \(Clipboard.timeoutSeconds) seconds.")
3172
3173    @OptionGroup var global: GlobalOptions
3174    @Argument(help: "Entry id or name.") var ref: String
3175    @Option(name: .long, help: "Field to copy (default password).") var field = "password"
3176
3177    func run() throws {
3178        let open = try OpenVault.load(global)
3179        let entry = try open.vault.resolve(ref)
3180        let value = try Output.field(entry, named: field)
3181        try Clipboard.copyWithTimeout(value)
3182        print("copied \(field) of \(entry.name); clears in \(Clipboard.timeoutSeconds)s")
3183    }
3184}
3185```
3186
3187`Commands/ClipboardDaemon.swift`:
3188
3189```swift
3190import ArgumentParser
3191
3192struct ClipboardDaemon: ParsableCommand {
3193    static let configuration = CommandConfiguration(
3194        commandName: "clipboard-daemon", shouldDisplay: false)
3195
3196    @Argument var seconds: Int
3197
3198    func run() throws {
3199        try Clipboard.runDaemon(seconds: seconds)
3200    }
3201}
3202```
3203
3204In `Generate.swift` replace the `throw KeycaskError.failure("clipboard not available")` line with:
3205
3206```swift
3207try Clipboard.copyWithTimeout(secret)
3208print("copied; clears in \(Clipboard.timeoutSeconds)s")
3209return
3210```
3211
3212Register: `subcommands: [Init.self, Add.self, Show.self, Ls.self, Find.self, Edit.self, Rm.self, Generate.self, Clip.self, ClipboardDaemon.self]`.
3213
3214- [ ] **Step 5: Run tests**
3215
3216Run: `swift test --filter ClipboardTests`
3217Expected: 6 tests pass.
3218
3219- [ ] **Step 6: Manual check on macOS**
3220
3221```bash
3222swift build && KEYCASK_VAULT=/tmp/kc-manual.kc KEYCASK_PASSPHRASE=pw .build/debug/keycask init
3223KEYCASK_VAULT=/tmp/kc-manual.kc KEYCASK_PASSPHRASE=pw .build/debug/keycask add t --generate
3224KEYCASK_VAULT=/tmp/kc-manual.kc KEYCASK_PASSPHRASE=pw .build/debug/keycask clip t && pbpaste | wc -c
3225sleep 46 && pbpaste | wc -c
3226rm /tmp/kc-manual.kc
3227```
3228
3229Expected: 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.
3230
3231- [ ] **Step 7: Lint and commit**
3232
3233```bash
3234swift format lint --strict --recursive Sources Tests
3235git add Sources/keycask Tests/KeycaskCLITests
3236git commit -m "Add clipboard support: clip, generate --copy, timed clear"
3237```
3238
3239---
3240
3241### Task 15: README, full verification, CLI merge request
3242
3243**Files:**
3244- Modify: `README.md`
3245
3246- [ ] **Step 1: Write the README**
3247
3248````markdown
3249# keycask
3250
3251Command-line password manager. One passphrase-encrypted vault file.
3252Swift, runs on macOS, Linux, and Windows.
3253
3254## install
3255
3256```sh
3257swift build -c release
3258cp .build/release/keycask ~/.local/bin/
3259```
3260
3261## use
3262
3263```sh
3264keycask init
3265keycask add github -u cmc --url https://github.com --tag dev --generate
3266keycask add mail --words 6
3267keycask add bank                     # prompts for the password
3268keycask show github                  # password masked
3269keycask show github --reveal
3270keycask show github --field password # raw value, for scripts
3271keycask clip github                  # clipboard, clears after 45s
3272keycask ls --tag dev
3273keycask find example
3274keycask edit github --tag work --untag dev
3275keycask rm github --yes
3276keycask generate --words 5 --copy
3277```
3278
3279Every read command takes `--json`. Passwords are masked unless `--reveal`.
3280
3281Names are labels and may repeat. Every command that takes a name also
3282takes the entry's 8-character id, which `ls` and `add` print. An
3283ambiguous name lists the candidates.
3284
3285## files
3286
3287| what | default | override |
3288|---|---|---|
3289| vault | `~/.local/share/keycask/vault.kc` (`%LOCALAPPDATA%\keycask\vault.kc` on Windows) | `KEYCASK_VAULT`, `--vault` |
3290| passphrase | prompted | `KEYCASK_PASSPHRASE` |
3291
3292The vault is a JSON envelope: PBKDF2-HMAC-SHA256 (600000 rounds) over
3293the passphrase, ChaCha20-Poly1305 over the entries. Writes are atomic.
3294
3295## exit codes
3296
32970 ok, 1 failure, 2 usage, 3 not found, 4 cannot decrypt, 5 ambiguous name.
3298
3299## develop
3300
3301```sh
3302swift build
3303swift test
3304swift format lint --strict --recursive Sources Tests
3305```
3306
3307Design: `docs/superpowers/specs/2026-09-17-keycask-design.md`.
3308````
3309
3310- [ ] **Step 2: Full suite, lint, release build**
3311
3312Run: `swift test && swift format lint --strict --recursive Sources Tests Package.swift && swift build -c release`
3313Expected: all tests pass, no lint output, release binary at `.build/release/keycask`.
3314
3315- [ ] **Step 3: Commit, push, open MR**
3316
3317```bash
3318git add README.md
3319git commit -m "Write README for the CLI"
3320git push -u origin cli
3321gitbay mr create --source cli --target main --title "CLI: init, add, show, ls, find, edit, rm, generate, clip" --file - <<'EOF'
3322ArgumentParser commands over KeycaskCore. Paths, hidden passphrase prompt,
3323atomic writes, shell-out clipboard with timed clear. Black-box CLI tests
3324cover every command and exit code.
3325EOF
3326```
3327
3328- [ ] **Step 4: Wait for CI, merge, clean up**
3329
3330Run `gitbay build list --json` until green. Then:
3331
3332```bash
3333gitbay mr merge <n> --strategy squash
3334git switch main && git pull && git branch -D cli && git push origin --delete cli
3335```
3336
3337Do not merge red. If Linux CI fails on something platform-specific (a `Glibc` import, `posixPermissions`), fix it on the branch and push again.