# keycask core + CLI Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** A Swift package with `KeycaskCore` (vault model, passphrase-encrypted envelope, generator, resolution) and a `keycask` CLI that behaves identically on macOS, Linux, and Windows, covered by in-process and black-box tests. **Architecture:** `KeycaskCore` depends on Foundation and swift-crypto only and holds every rule about entries, IDs, encryption, and lookup. The `keycask` executable wraps it with ArgumentParser commands and a small platform layer (paths, terminal, atomic write, clipboard). Tests in `KeycaskCoreTests` run in process; tests in `KeycaskCLITests` spawn the built binary and check stdout, stderr, and exit codes. **Tech Stack:** Swift 6.4, SwiftPM, Swift Testing, swift-crypto 3.15 (`Crypto`, `_CryptoExtras`), swift-argument-parser 1.8. **Spec:** `docs/superpowers/specs/2026-09-17-keycask-design.md` ## Global Constraints - `// swift-tools-version:6.4`, Swift 6 language mode, `platforms: [.macOS(.v14), .iOS(.v17)]`. - Dependencies are exactly `apple/swift-crypto` and `apple/swift-argument-parser`. `Package.resolved` is committed. - `KeycaskCore` imports only `Foundation`, `Crypto`, and `_CryptoExtras`. It never imports ArgumentParser, never spawns a process, never reads the environment. - Envelope: `format` 1, `kdf.name` `"pbkdf2-hmac-sha256"`, 600000 iterations, 16-byte salt, ChaCha20-Poly1305 combined box, key 32 bytes, passphrase NFC-normalized UTF-8. - `EntryID`: 8 characters from `abcdefghijkmnpqrstuvwxyz23456789`. - Dates: ISO 8601 UTC, whole seconds. JSON: sorted keys. - Exit codes: 0 ok, 1 failure, 2 usage, 3 not found, 4 cannot decrypt, 5 ambiguous. - Masked secret string is exactly `********`. - Clipboard timeout is 45 seconds. Password default length 24. Passphrase separator `-`. - No code comments that mention the history of the project or how it used to work. No attribution trailers in commits. - Every file passes `swift format lint --strict`. - Work happens on a branch. Tasks 1-8 go on branch `core`, merged through one MR. Tasks 9-15 go on branch `cli`, merged through a second MR. Never commit to `main` directly. ## File structure ``` Package.swift Package.resolved NOTICE .gitbay/ci.yml Sources/KeycaskCore/ KeycaskError.swift error enum, exit codes, messages EntryID.swift 8-char random ID, Codable as a string Entry.swift entry struct, tag normalization, second-truncated dates Vault.swift entries, add/remove/update, resolve, filter, search VaultCodec.swift deterministic JSON encode/decode of Vault Envelope.swift file envelope, PBKDF2 + ChaChaPoly seal/open, parse/encode Generator.swift random password and passphrase Wordlist.swift EFF long list as one string literal (generated file) Sources/keycask/ main.swift parse, run, map errors to exit codes Keycask.swift root command, GlobalOptions Paths.swift vault path resolution Terminal.swift isatty, echo-off line read, y/N confirm Passphrase.swift env var or prompt AtomicFile.swift temp + fsync + rename, 0600 on Unix, MoveFileExW on Windows OpenVault.swift load/save/create: ties paths, passphrase, envelope, codec, atomic write Output.swift text, table, JSON, masking, --field Clipboard.swift tool discovery, read/write, daemon handoff Commands/Init.swift Commands/Add.swift Commands/Show.swift Commands/Ls.swift Commands/Find.swift Commands/Edit.swift Commands/Rm.swift Commands/Generate.swift Commands/Clip.swift Commands/ClipboardDaemon.swift Tests/KeycaskCoreTests/ EntryIDTests.swift EntryTests.swift VaultTests.swift VaultCodecTests.swift EnvelopeTests.swift GeneratorTests.swift KeycaskErrorTests.swift Tests/KeycaskCLITests/ CLI.swift harness: locate binary, temp vault, run with env InitTests.swift AddShowTests.swift LsFindTests.swift EditRmTests.swift GenerateTests.swift ClipboardTests.swift PathsTests.swift ``` --- ### Task 1: Package scaffold and CI **Files:** - Create: `Package.swift` - Create: `Sources/KeycaskCore/KeycaskCore.swift` (temporary, deleted in Task 2) - Create: `Sources/keycask/main.swift` (replaced in Task 9) - Create: `Tests/KeycaskCoreTests/SmokeTests.swift` (deleted in Task 2) - Create: `.gitbay/ci.yml` - Create: `.swift-format` **Interfaces:** - Produces: the package layout every later task adds files to. - [ ] **Step 1: Create the branch** ```bash cd /Users/cmc/git/krz/keycask && git switch -c core ``` - [ ] **Step 2: Write Package.swift** ```swift // swift-tools-version:6.4 import PackageDescription let package = Package( name: "keycask", platforms: [.macOS(.v14), .iOS(.v17)], products: [ .library(name: "KeycaskCore", targets: ["KeycaskCore"]), .executable(name: "keycask", targets: ["keycask"]), ], dependencies: [ .package(url: "https://github.com/apple/swift-crypto", from: "3.15.0"), .package(url: "https://github.com/apple/swift-argument-parser", from: "1.8.0"), ], targets: [ .target( name: "KeycaskCore", dependencies: [ .product(name: "Crypto", package: "swift-crypto"), .product(name: "_CryptoExtras", package: "swift-crypto"), ] ), .executableTarget( name: "keycask", dependencies: [ "KeycaskCore", .product(name: "ArgumentParser", package: "swift-argument-parser"), ] ), .testTarget(name: "KeycaskCoreTests", dependencies: ["KeycaskCore"]), .testTarget(name: "KeycaskCLITests", dependencies: ["keycask"]), ] ) ``` - [ ] **Step 3: Write placeholder sources so the package builds** `Sources/KeycaskCore/KeycaskCore.swift`: ```swift public enum KeycaskCore { public static let name = "keycask" } ``` `Sources/keycask/main.swift`: ```swift import KeycaskCore print(KeycaskCore.name) ``` `Tests/KeycaskCoreTests/SmokeTests.swift`: ```swift import Testing @testable import KeycaskCore @Test func packageBuilds() { #expect(KeycaskCore.name == "keycask") } ``` `Tests/KeycaskCLITests/CLI.swift` (a real file, extended in Task 9; this version only locates the binary): ```swift import Foundation import Testing enum Binary { static let url: URL = { #if os(macOS) for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { return bundle.bundleURL.deletingLastPathComponent().appendingPathComponent("keycask") } fatalError("test bundle not found") #elseif os(Windows) return Bundle.main.bundleURL.appendingPathComponent("keycask.exe") #else return Bundle.main.bundleURL.appendingPathComponent("keycask") #endif }() } @Test func binaryIsBuilt() { #expect(FileManager.default.isExecutableFile(atPath: Binary.url.path)) } ``` - [ ] **Step 4: Write .swift-format** ```json { "version": 1, "indentation": { "spaces": 4 }, "lineLength": 100, "maximumBlankLines": 1, "respectsExistingLineBreaks": true, "rules": { "AlwaysUseLowerCamelCase": true, "NeverForceUnwrap": false, "NeverUseImplicitlyUnwrappedOptionals": true } } ``` - [ ] **Step 5: Build and test** Run: `swift build && swift test` Expected: `Build complete`, two tests pass. `Package.resolved` now exists. - [ ] **Step 6: Lint** Run: `swift format lint --strict --recursive Sources Tests Package.swift` Expected: no output. If it reports findings, run `swift format --in-place --recursive Sources Tests Package.swift` and re-lint. - [ ] **Step 7: Write .gitbay/ci.yml** ```yaml # Each step runs in its own `sh -c`; exports do not survive between steps. # swiftly installs into $HOME, which persists across builds. jobs: build: steps: - | set -eu command -v curl >/dev/null || { echo "runner is missing: curl"; exit 1; } if ! command -v "$HOME/.local/bin/swiftly" >/dev/null 2>&1; then curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz" | tar -xz -C /tmp /tmp/swiftly init --assume-yes --skip-install --quiet-shell-followup fi . "$HOME/.local/share/swiftly/env.sh" swiftly install --use 6.4 swift format lint --strict --recursive Sources Tests Package.swift swift build test: steps: - | set -eu . "$HOME/.local/share/swiftly/env.sh" swiftly install --use 6.4 swift test paths-ignore: - docs/** ``` - [ ] **Step 8: Commit** ```bash git add Package.swift Package.resolved .swift-format .gitbay/ci.yml Sources Tests git commit -m "Add package scaffold and CI" ``` --- ### Task 2: KeycaskError **Files:** - Create: `Sources/KeycaskCore/KeycaskError.swift` - Create: `Tests/KeycaskCoreTests/KeycaskErrorTests.swift` - Delete: `Sources/KeycaskCore/KeycaskCore.swift`, `Tests/KeycaskCoreTests/SmokeTests.swift` **Interfaces:** - Produces: `public enum KeycaskError: Error, Equatable, Sendable` with cases `notFound(String)`, `ambiguous(name: String, candidates: [Entry])`, `cannotDecrypt`, `corrupt(String)`, `vaultExists(String)`, `noVault(String)`, `duplicateID(EntryID)`, `io(String)`, `usage(String)`, `failure(String)`; properties `exitCode: Int32`, `message: String`. - Note: `Entry` and `EntryID` do not exist yet. Write this task with `ambiguous(name: String, candidates: [String])` and `duplicateID(String)` and change them to the real types in Tasks 3 and 4. - [ ] **Step 1: Delete placeholders** ```bash git rm -q Sources/KeycaskCore/KeycaskCore.swift Tests/KeycaskCoreTests/SmokeTests.swift ``` - [ ] **Step 2: Write the failing test** `Tests/KeycaskCoreTests/KeycaskErrorTests.swift`: ```swift import Testing @testable import KeycaskCore @Suite struct KeycaskErrorTests { @Test func exitCodesFollowTheSpec() { #expect(KeycaskError.failure("x").exitCode == 1) #expect(KeycaskError.io("x").exitCode == 1) #expect(KeycaskError.corrupt("x").exitCode == 1) #expect(KeycaskError.vaultExists("x").exitCode == 1) #expect(KeycaskError.duplicateID("abcd2345").exitCode == 1) #expect(KeycaskError.usage("x").exitCode == 2) #expect(KeycaskError.notFound("x").exitCode == 3) #expect(KeycaskError.noVault("/p").exitCode == 3) #expect(KeycaskError.cannotDecrypt.exitCode == 4) #expect(KeycaskError.ambiguous(name: "gh", candidates: []).exitCode == 5) } @Test func messagesNameTheSubject() { #expect(KeycaskError.notFound("gh").message == "gh: not found") #expect(KeycaskError.noVault("/v").message == "vault /v not found (run `keycask init`)") #expect(KeycaskError.vaultExists("/v").message == "vault /v already exists") #expect(KeycaskError.cannotDecrypt.message == "cannot decrypt: wrong passphrase or damaged vault") #expect(KeycaskError.corrupt("bad json").message == "vault is corrupt: bad json") } } ``` - [ ] **Step 3: Run test to verify it fails** Run: `swift test --filter KeycaskErrorTests` Expected: compile error, `KeycaskError` not found. - [ ] **Step 4: Write the implementation** `Sources/KeycaskCore/KeycaskError.swift`: ```swift public enum KeycaskError: Error, Equatable, Sendable { case notFound(String) case ambiguous(name: String, candidates: [String]) case cannotDecrypt case corrupt(String) case vaultExists(String) case noVault(String) case duplicateID(String) case io(String) case usage(String) case failure(String) public var exitCode: Int32 { switch self { case .failure, .io, .corrupt, .vaultExists, .duplicateID: 1 case .usage: 2 case .notFound, .noVault: 3 case .cannotDecrypt: 4 case .ambiguous: 5 } } public var message: String { switch self { case .notFound(let what): "\(what): not found" case .ambiguous(let name, let candidates): (["\(name): ambiguous, use an id:"] + candidates).joined(separator: "\n") case .cannotDecrypt: "cannot decrypt: wrong passphrase or damaged vault" case .corrupt(let why): "vault is corrupt: \(why)" case .vaultExists(let path): "vault \(path) already exists" case .noVault(let path): "vault \(path) not found (run `keycask init`)" case .duplicateID(let id): "duplicate id \(id)" case .io(let why): why case .usage(let why): why case .failure(let why): why } } } ``` - [ ] **Step 5: Run tests** Run: `swift test --filter KeycaskErrorTests` Expected: 2 tests pass. - [ ] **Step 6: Commit** ```bash git add -A Sources/KeycaskCore Tests/KeycaskCoreTests git commit -m "Add KeycaskError with exit codes" ``` --- ### Task 3: EntryID **Files:** - Create: `Sources/KeycaskCore/EntryID.swift` - Create: `Tests/KeycaskCoreTests/EntryIDTests.swift` - Modify: `Sources/KeycaskCore/KeycaskError.swift` (`duplicateID(EntryID)`) - Modify: `Tests/KeycaskCoreTests/KeycaskErrorTests.swift` **Interfaces:** - Produces: `public struct EntryID: Hashable, Sendable, Codable, CustomStringConvertible` with `static let alphabet: [Character]`, `static let length = 8`, `let rawValue: String`, `init?(_ raw: String)`, `static func random() -> EntryID`, `static func random(using: inout some RandomNumberGenerator) -> EntryID`. Codable as a bare JSON string. - [ ] **Step 1: Write the failing test** `Tests/KeycaskCoreTests/EntryIDTests.swift`: ```swift import Foundation import Testing @testable import KeycaskCore @Suite struct EntryIDTests { @Test func randomIDsHaveLengthEightFromTheAlphabet() { let allowed = Set(EntryID.alphabet) for _ in 0..<200 { let id = EntryID.random() #expect(id.rawValue.count == 8) #expect(id.rawValue.allSatisfy { allowed.contains($0) }) } } @Test func alphabetExcludesAmbiguousCharacters() { let alphabet = Set(EntryID.alphabet) #expect(alphabet.count == 32) for bad in ["l", "o", "0", "1"] { #expect(!alphabet.contains(Character(bad))) } } @Test func parsingValidatesLengthAndAlphabet() { #expect(EntryID("abcd2345") != nil) #expect(EntryID("abcd234") == nil) #expect(EntryID("abcd23456") == nil) #expect(EntryID("abcd234l") == nil) #expect(EntryID("ABCD2345") == nil) } @Test func codableIsABareString() throws { let id = EntryID("abcd2345")! let data = try JSONEncoder().encode([id]) #expect(String(decoding: data, as: UTF8.self) == "[\"abcd2345\"]") let back = try JSONDecoder().decode([EntryID].self, from: data) #expect(back == [id]) #expect(throws: DecodingError.self) { try JSONDecoder().decode([EntryID].self, from: Data("[\"bad\"]".utf8)) } } @Test func seededGeneratorIsDeterministic() { struct Counter: RandomNumberGenerator { var n: UInt64 = 0 mutating func next() -> UInt64 { n += 1 return n } } var a = Counter() var b = Counter() #expect(EntryID.random(using: &a) == EntryID.random(using: &b)) } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `swift test --filter EntryIDTests` Expected: compile error, `EntryID` not found. - [ ] **Step 3: Write the implementation** `Sources/KeycaskCore/EntryID.swift`: ```swift public struct EntryID: Hashable, Sendable, CustomStringConvertible { public static let alphabet: [Character] = Array("abcdefghijkmnpqrstuvwxyz23456789") public static let length = 8 public let rawValue: String public init?(_ raw: String) { guard raw.count == Self.length else { return nil } let allowed = Set(Self.alphabet) guard raw.allSatisfy({ allowed.contains($0) }) else { return nil } rawValue = raw } public static func random() -> EntryID { var rng = SystemRandomNumberGenerator() return random(using: &rng) } public static func random(using rng: inout some RandomNumberGenerator) -> EntryID { var chars: [Character] = [] chars.reserveCapacity(length) for _ in 0.. [String] public static func truncateToSeconds(_ date: Date) -> Date public func hasTag(_ tag: String) -> Bool public func matches(_ query: String) -> Bool } ``` - [ ] **Step 1: Write the failing test** `Tests/KeycaskCoreTests/EntryTests.swift`: ```swift import Foundation import Testing @testable import KeycaskCore @Suite struct EntryTests { @Test func initNormalizesTagsAndTruncatesDates() { let now = Date(timeIntervalSince1970: 1_700_000_000.75) let e = Entry(name: "gh", password: "p", tags: [" work", "Dev", "dev", "", "alpha"], now: now) #expect(e.tags == ["alpha", "Dev", "work"]) #expect(e.created == Date(timeIntervalSince1970: 1_700_000_000)) #expect(e.updated == e.created) } @Test func normalizeSortsCaseInsensitivelyAndKeepsFirstSpelling() { #expect(Entry.normalize(tags: ["b", "A", "a", "B"]) == ["A", "b"]) #expect(Entry.normalize(tags: []) == []) } @Test func hasTagIsCaseInsensitive() { let e = Entry(name: "gh", password: "p", tags: ["Dev"]) #expect(e.hasTag("dev")) #expect(e.hasTag("DEV")) #expect(!e.hasTag("ops")) } @Test func matchesSearchesEveryTextFieldExceptPassword() { let e = Entry( name: "GitHub", username: "cmc", password: "hunter2", url: "https://github.com", notes: "downtown office", tags: ["Dev"]) #expect(e.matches("github")) #expect(e.matches("CMC")) #expect(e.matches("github.com")) #expect(e.matches("downtown")) #expect(e.matches("dev")) #expect(!e.matches("hunter2")) #expect(!e.matches("nothing")) } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `swift test --filter EntryTests` Expected: compile error, `Entry` not found. - [ ] **Step 3: Write the implementation** `Sources/KeycaskCore/Entry.swift`: ```swift import Foundation public struct Entry: Codable, Equatable, Sendable { public let id: EntryID public var name: String public var username: String? public var password: String public var url: String? public var notes: String? public var tags: [String] public let created: Date public var updated: Date public init( id: EntryID = .random(), name: String, username: String? = nil, password: String, url: String? = nil, notes: String? = nil, tags: [String] = [], now: Date = .now ) { self.id = id self.name = name self.username = username self.password = password self.url = url self.notes = notes self.tags = Self.normalize(tags: tags) let stamp = Self.truncateToSeconds(now) created = stamp updated = stamp } public static func normalize(tags: [String]) -> [String] { var seen: Set = [] var out: [String] = [] for raw in tags { let tag = raw.trimmingCharacters(in: .whitespaces) guard !tag.isEmpty, seen.insert(tag.lowercased()).inserted else { continue } out.append(tag) } return out.sorted { a, b in let (la, lb) = (a.lowercased(), b.lowercased()) return la == lb ? a < b : la < lb } } public static func truncateToSeconds(_ date: Date) -> Date { Date(timeIntervalSince1970: date.timeIntervalSince1970.rounded(.down)) } public func hasTag(_ tag: String) -> Bool { let needle = tag.lowercased() return tags.contains { $0.lowercased() == needle } } public func matches(_ query: String) -> Bool { let needle = query.lowercased() guard !needle.isEmpty else { return false } let haystacks = [name, username ?? "", url ?? "", notes ?? ""] + tags return haystacks.contains { $0.lowercased().contains(needle) } } } ``` - [ ] **Step 4: Switch `ambiguous` to carry entries** In `KeycaskError.swift` change the case to `case ambiguous(name: String, candidates: [Entry])` and the message body to: ```swift case .ambiguous(let name, let candidates): (["\(name): ambiguous, use an id:"] + candidates.map { " \($0.id.rawValue) \($0.username ?? "") \($0.url ?? "")" }) .joined(separator: "\n") ``` `KeycaskErrorTests.swift` already passes `candidates: []`, which now infers `[Entry]`. Add one test there: ```swift @Test func ambiguousListsCandidateIDs() { let a = Entry(id: EntryID("aaaa2222")!, name: "gh", username: "one", password: "p") let b = Entry(id: EntryID("bbbb3333")!, name: "gh", password: "p", url: "https://x") let m = KeycaskError.ambiguous(name: "gh", candidates: [a, b]).message #expect(m.hasPrefix("gh: ambiguous, use an id:\n")) #expect(m.contains("aaaa2222")) #expect(m.contains("bbbb3333")) #expect(m.contains("https://x")) } ``` - [ ] **Step 5: Run tests** Run: `swift test --filter 'EntryTests|KeycaskErrorTests'` Expected: all pass. - [ ] **Step 6: Commit** ```bash git add Sources/KeycaskCore Tests/KeycaskCoreTests git commit -m "Add Entry with tag normalization and search" ``` --- ### Task 5: Vault and VaultCodec **Files:** - Create: `Sources/KeycaskCore/Vault.swift` - Create: `Sources/KeycaskCore/VaultCodec.swift` - Create: `Tests/KeycaskCoreTests/VaultTests.swift` - Create: `Tests/KeycaskCoreTests/VaultCodecTests.swift` **Interfaces:** - Consumes: `Entry`, `EntryID`, `KeycaskError`. - Produces: ```swift public struct Vault: Codable, Equatable, Sendable { public var entries: [Entry] public init(entries: [Entry] = []) public func entry(id: EntryID) -> Entry? public mutating func add(_ entry: Entry) throws // duplicateID public mutating func remove(id: EntryID) throws // notFound(id) public mutating func update(id: EntryID, now: Date = .now, _ change: (inout Entry) -> Void) throws // notFound(id); normalizes tags, sets updated public func resolve(_ ref: String) throws -> Entry // id, unique name, ambiguous, notFound public func filter(tag: String) -> [Entry] public func search(_ query: String) -> [Entry] public var sortedEntries: [Entry] // by name (case-insensitive), then id } public enum VaultCodec { public static func encode(_ vault: Vault) throws -> Data // sortedKeys, iso8601; io on failure public static func decode(_ data: Data) throws -> Vault // corrupt on failure public static func makeEncoder() -> JSONEncoder // shared settings, also used by CLI output } ``` - [ ] **Step 1: Write the failing tests** `Tests/KeycaskCoreTests/VaultTests.swift`: ```swift import Foundation import Testing @testable import KeycaskCore @Suite struct VaultTests { func idA() -> EntryID { EntryID("aaaa2222")! } func idB() -> EntryID { EntryID("bbbb3333")! } @Test func addRejectsDuplicateID() throws { var v = Vault() try v.add(Entry(id: idA(), name: "gh", password: "p")) #expect(throws: KeycaskError.duplicateID(idA())) { try v.add(Entry(id: idA(), name: "other", password: "p")) } #expect(v.entries.count == 1) } @Test func removeUnknownIsNotFound() { var v = Vault() #expect(throws: KeycaskError.notFound("aaaa2222")) { try v.remove(id: idA()) } } @Test func updateSetsUpdatedAndNormalizesTags() throws { let t0 = Date(timeIntervalSince1970: 1_000) let t1 = Date(timeIntervalSince1970: 2_000.9) var v = Vault() try v.add(Entry(id: idA(), name: "gh", password: "p", now: t0)) try v.update(id: idA(), now: t1) { e in e.tags = ["z", "A", "a"] e.password = "q" } let e = v.entry(id: idA())! #expect(e.password == "q") #expect(e.tags == ["A", "z"]) #expect(e.created == t0) #expect(e.updated == Date(timeIntervalSince1970: 2_000)) } @Test func resolvePrefersIDThenUniqueName() throws { var v = Vault() try v.add(Entry(id: idA(), name: "gh", password: "p")) try v.add(Entry(id: idB(), name: "aaaa2222", password: "p")) #expect(try v.resolve("aaaa2222").id == idA()) #expect(try v.resolve("gh").id == idA()) #expect(try v.resolve("bbbb3333").id == idB()) } @Test func resolveReportsAmbiguousWithAllCandidates() throws { var v = Vault() let a = Entry(id: idA(), name: "gh", password: "p") let b = Entry(id: idB(), name: "gh", password: "p") try v.add(a) try v.add(b) #expect(throws: KeycaskError.ambiguous(name: "gh", candidates: [a, b])) { try v.resolve("gh") } } @Test func resolveUnknownIsNotFound() { #expect(throws: KeycaskError.notFound("nope")) { try Vault().resolve("nope") } } @Test func filterAndSearch() throws { var v = Vault() try v.add(Entry(id: idA(), name: "GitHub", password: "p", tags: ["dev"])) try v.add(Entry(id: idB(), name: "bank", password: "p", url: "https://bank.example")) #expect(v.filter(tag: "DEV").map(\.id) == [idA()]) #expect(v.search("example").map(\.id) == [idB()]) #expect(v.search("zzz").isEmpty) } @Test func sortedEntriesOrderByNameThenID() throws { var v = Vault() try v.add(Entry(id: idB(), name: "gh", password: "p")) try v.add(Entry(id: idA(), name: "gh", password: "p")) try v.add(Entry(id: EntryID("cccc4444")!, name: "Alpha", password: "p")) #expect(v.sortedEntries.map(\.id.rawValue) == ["cccc4444", "aaaa2222", "bbbb3333"]) } } ``` `Tests/KeycaskCoreTests/VaultCodecTests.swift`: ```swift import Foundation import Testing @testable import KeycaskCore @Suite struct VaultCodecTests { @Test func roundTripsAndIsDeterministic() throws { var v = Vault() try v.add( Entry( id: EntryID("aaaa2222")!, name: "gh", username: "cmc", password: "p", url: "https://github.com", notes: "n", tags: ["dev"], now: Date(timeIntervalSince1970: 1_700_000_000))) let a = try VaultCodec.encode(v) let b = try VaultCodec.encode(v) #expect(a == b) #expect(try VaultCodec.decode(a) == v) } @Test func datesAreISO8601WholeSeconds() throws { var v = Vault() try v.add( Entry(id: EntryID("aaaa2222")!, name: "gh", password: "p", now: Date(timeIntervalSince1970: 1_700_000_000))) let text = String(decoding: try VaultCodec.encode(v), as: UTF8.self) #expect(text.contains("\"created\":\"2023-11-14T22:13:20Z\"")) } @Test func keysAreSorted() throws { var v = Vault() try v.add(Entry(id: EntryID("aaaa2222")!, name: "gh", password: "p")) let text = String(decoding: try VaultCodec.encode(v), as: UTF8.self) let created = text.range(of: "\"created\"")!.lowerBound let id = text.range(of: "\"id\"")!.lowerBound let updated = text.range(of: "\"updated\"")!.lowerBound #expect(created < id && id < updated) } @Test func garbageIsCorrupt() { #expect(throws: KeycaskError.self) { try VaultCodec.decode(Data("nope".utf8)) } do { _ = try VaultCodec.decode(Data("{\"entries\":[{\"id\":1}]}".utf8)) Issue.record("expected corrupt") } catch let e as KeycaskError { #expect(e.exitCode == 1) #expect(e.message.hasPrefix("vault is corrupt:")) } catch { Issue.record("wrong error \(error)") } } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `swift test --filter 'VaultTests|VaultCodecTests'` Expected: compile error, `Vault` not found. - [ ] **Step 3: Write Vault.swift** ```swift import Foundation public struct Vault: Codable, Equatable, Sendable { public var entries: [Entry] public init(entries: [Entry] = []) { self.entries = entries } public func entry(id: EntryID) -> Entry? { entries.first { $0.id == id } } public mutating func add(_ entry: Entry) throws { guard self.entry(id: entry.id) == nil else { throw KeycaskError.duplicateID(entry.id) } entries.append(entry) } public mutating func remove(id: EntryID) throws { guard let index = entries.firstIndex(where: { $0.id == id }) else { throw KeycaskError.notFound(id.rawValue) } entries.remove(at: index) } public mutating func update( id: EntryID, now: Date = .now, _ change: (inout Entry) -> Void ) throws { guard let index = entries.firstIndex(where: { $0.id == id }) else { throw KeycaskError.notFound(id.rawValue) } change(&entries[index]) entries[index].tags = Entry.normalize(tags: entries[index].tags) entries[index].updated = Entry.truncateToSeconds(now) } public func resolve(_ ref: String) throws -> Entry { if let id = EntryID(ref), let hit = entry(id: id) { return hit } let byName = entries.filter { $0.name == ref } switch byName.count { case 0: throw KeycaskError.notFound(ref) case 1: return byName[0] default: throw KeycaskError.ambiguous(name: ref, candidates: byName) } } public func filter(tag: String) -> [Entry] { sortedEntries.filter { $0.hasTag(tag) } } public func search(_ query: String) -> [Entry] { sortedEntries.filter { $0.matches(query) } } public var sortedEntries: [Entry] { entries.sorted { a, b in let (la, lb) = (a.name.lowercased(), b.name.lowercased()) return la == lb ? a.id.rawValue < b.id.rawValue : la < lb } } } ``` - [ ] **Step 4: Write VaultCodec.swift** ```swift import Foundation public enum VaultCodec { public static func makeEncoder() -> JSONEncoder { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] encoder.dateEncodingStrategy = .iso8601 return encoder } public static func makeDecoder() -> JSONDecoder { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return decoder } public static func encode(_ vault: Vault) throws -> Data { do { return try makeEncoder().encode(vault) } catch { throw KeycaskError.io("encode vault: \(error)") } } public static func decode(_ data: Data) throws -> Vault { do { return try makeDecoder().decode(Vault.self, from: data) } catch { throw KeycaskError.corrupt("\(error)") } } } ``` - [ ] **Step 5: Run tests** Run: `swift test --filter 'VaultTests|VaultCodecTests'` Expected: 12 tests pass. - [ ] **Step 6: Commit** ```bash git add Sources/KeycaskCore Tests/KeycaskCoreTests git commit -m "Add Vault operations and deterministic JSON codec" ``` --- ### Task 6: Generator and word list **Files:** - Create: `Sources/KeycaskCore/Wordlist.swift` (generated) - Create: `Sources/KeycaskCore/Generator.swift` - Create: `NOTICE` - Create: `Tests/KeycaskCoreTests/GeneratorTests.swift` **Interfaces:** - Produces: ```swift public enum Wordlist { public static let words: [String] } // 7776 entries public enum Generator { public static let alphabet: [Character] // A-Z a-z 0-9 and !@#$%^&*()-_=+[]{};:,.<>? public static let defaultLength = 24 public static let wordSeparator = "-" public static func password(length: Int) -> String public static func password(length: Int, using: inout some RandomNumberGenerator) -> String public static func passphrase(words: Int) -> String public static func passphrase(words: Int, using: inout some RandomNumberGenerator) -> String } ``` - [ ] **Step 1: Generate Wordlist.swift from the EFF list** ```bash cd /Users/cmc/git/krz/keycask curl -fsSL https://www.eff.org/files/2016/07/18/eff_large_wordlist.txt -o /tmp/eff.txt test "$(wc -l < /tmp/eff.txt)" -eq 7776 { printf '// EFF long word list, https://www.eff.org/dice. See NOTICE.\n' printf 'let effLongWordlist = """\n' cut -f2 /tmp/eff.txt printf '"""\n\npublic enum Wordlist {\n' printf ' public static let words: [String] = effLongWordlist.split(separator: "\\n").map(String.init)\n' printf '}\n' } > Sources/KeycaskCore/Wordlist.swift rm /tmp/eff.txt ``` - [ ] **Step 2: Write NOTICE** ``` The word list in Sources/KeycaskCore/Wordlist.swift is the EFF Long Wordlist by the Electronic Frontier Foundation, licensed under the Creative Commons Attribution 3.0 United States License. https://www.eff.org/dice https://creativecommons.org/licenses/by/3.0/us/ ``` - [ ] **Step 3: Write the failing test** `Tests/KeycaskCoreTests/GeneratorTests.swift`: ```swift import Testing @testable import KeycaskCore @Suite struct GeneratorTests { struct Counter: RandomNumberGenerator { var n: UInt64 = 0 mutating func next() -> UInt64 { n &+= 0x9E37_79B9_7F4A_7C15 return n } } @Test func wordlistHas7776UniqueWords() { #expect(Wordlist.words.count == 7776) #expect(Set(Wordlist.words).count == 7776) #expect(Wordlist.words.first == "abacus") #expect(Wordlist.words.allSatisfy { !$0.isEmpty && !$0.contains(" ") }) } @Test func passwordHasRequestedLengthFromTheAlphabet() { let allowed = Set(Generator.alphabet) for length in [1, 8, 24, 64] { let p = Generator.password(length: length) #expect(p.count == length) #expect(p.allSatisfy { allowed.contains($0) }) } #expect(Generator.password(length: 0) == "") } @Test func alphabetCoversAllClasses() { let s = String(Generator.alphabet) #expect(s.contains("A") && s.contains("z") && s.contains("7") && s.contains("!")) #expect(Set(Generator.alphabet).count == Generator.alphabet.count) } @Test func passphraseUsesWordsFromTheList() { let words = Set(Wordlist.words) let p = Generator.passphrase(words: 5) let parts = p.split(separator: "-").map(String.init) #expect(parts.count == 5) #expect(parts.allSatisfy { words.contains($0) }) #expect(Generator.passphrase(words: 0) == "") } @Test func seededOutputIsReproducible() { var a = Counter() var b = Counter() #expect(Generator.password(length: 16, using: &a) == Generator.password(length: 16, using: &b)) #expect(Generator.passphrase(words: 3, using: &a) == Generator.passphrase(words: 3, using: &b)) } } ``` - [ ] **Step 4: Run test to verify it fails** Run: `swift test --filter GeneratorTests` Expected: compile error, `Generator` not found. - [ ] **Step 5: Write Generator.swift** ```swift public enum Generator { public static let alphabet: [Character] = Array( "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{};:,.<>?" ) public static let defaultLength = 24 public static let wordSeparator = "-" public static func password(length: Int) -> String { var rng = SystemRandomNumberGenerator() return password(length: length, using: &rng) } public static func password(length: Int, using rng: inout some RandomNumberGenerator) -> String { var chars: [Character] = [] chars.reserveCapacity(max(length, 0)) for _ in 0.. String { var rng = SystemRandomNumberGenerator() return passphrase(words: words, using: &rng) } public static func passphrase(words: Int, using rng: inout some RandomNumberGenerator) -> String { let list = Wordlist.words var picked: [String] = [] for _ in 0.. KDFParams } public static let currentFormat = 1 public static let defaultIterations = 600_000 public static let kdfName = "pbkdf2-hmac-sha256" public static let saltLength = 16 public var format: Int public var kdf: KDFParams public var box: Data public static func seal(_ plaintext: Data, passphrase: String, kdf: KDFParams) throws -> Envelope public func open(passphrase: String) throws -> Data // cannotDecrypt / corrupt public init(parsing data: Data) throws // corrupt public func encoded() throws -> Data static func deriveKey(passphrase: String, kdf: KDFParams) throws -> SymmetricKey } ``` - [ ] **Step 1: Write the failing test** `Tests/KeycaskCoreTests/EnvelopeTests.swift`: ```swift import Crypto import Foundation import Testing @testable import KeycaskCore @Suite struct EnvelopeTests { // Low iteration count keeps the suite fast. Production uses Envelope.defaultIterations. let kdf = Envelope.KDFParams( name: Envelope.kdfName, iterations: 1_000, salt: Data(repeating: 7, count: 16)) func hex(_ key: SymmetricKey) -> String { key.withUnsafeBytes { $0.map { String(format: "%02x", $0) }.joined() } } @Test func pbkdf2MatchesPublishedVectors() throws { let one = Envelope.KDFParams(name: Envelope.kdfName, iterations: 1, salt: Data("salt".utf8)) #expect( hex(try Envelope.deriveKey(passphrase: "password", kdf: one)) == "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b") let many = Envelope.KDFParams(name: Envelope.kdfName, iterations: 4096, salt: Data("salt".utf8)) #expect( hex(try Envelope.deriveKey(passphrase: "password", kdf: many)) == "c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a") } @Test func sealThenOpenRoundTrips() throws { let env = try Envelope.seal(Data("hello vault".utf8), passphrase: "pw", kdf: kdf) #expect(env.format == 1) #expect(env.kdf == kdf) #expect(try env.open(passphrase: "pw") == Data("hello vault".utf8)) } @Test func wrongPassphraseCannotDecrypt() throws { let env = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf) #expect(throws: KeycaskError.cannotDecrypt) { try env.open(passphrase: "PW") } } @Test func tamperedBoxCannotDecrypt() throws { var env = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf) env.box[env.box.count - 1] ^= 0x01 #expect(throws: KeycaskError.cannotDecrypt) { try env.open(passphrase: "pw") } } @Test func nonceIsFreshAndSaltIsKept() throws { let a = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf) let b = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf) #expect(a.box != b.box) #expect(a.kdf.salt == b.kdf.salt) } @Test func freshParamsUseDefaults() { let p = Envelope.KDFParams.fresh() #expect(p.name == "pbkdf2-hmac-sha256") #expect(p.iterations == 600_000) #expect(p.salt.count == 16) #expect(p.salt != Envelope.KDFParams.fresh().salt) } @Test func encodedShapeMatchesTheSpec() throws { let env = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf) let json = try JSONSerialization.jsonObject(with: env.encoded()) as! [String: Any] #expect(json["format"] as? Int == 1) let k = json["kdf"] as! [String: Any] #expect(k["name"] as? String == "pbkdf2-hmac-sha256") #expect(k["iterations"] as? Int == 1_000) #expect(Data(base64Encoded: k["salt"] as! String) == kdf.salt) #expect(Data(base64Encoded: json["box"] as! String) == env.box) #expect(try Envelope(parsing: env.encoded()) == env) } @Test func malformedInputsAreCorrupt() throws { #expect(throws: KeycaskError.self) { try Envelope(parsing: Data("not json".utf8)) } #expect(throws: KeycaskError.self) { try Envelope(parsing: Data("{\"format\":1}".utf8)) } var wrongFormat = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf) wrongFormat.format = 2 #expect(throws: KeycaskError.corrupt("unsupported format 2")) { try wrongFormat.open(passphrase: "pw") } var wrongKDF = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf) wrongKDF.kdf.name = "argon2id" #expect(throws: KeycaskError.corrupt("unsupported kdf argon2id")) { try wrongKDF.open(passphrase: "pw") } var shortBox = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf) shortBox.box = Data([1, 2, 3]) #expect(throws: KeycaskError.corrupt("box too short")) { try shortBox.open(passphrase: "pw") } } @Test func passphraseIsNFCNormalized() throws { let composed = "caf\u{00E9}" let decomposed = "cafe\u{0301}" let env = try Envelope.seal(Data("x".utf8), passphrase: composed, kdf: kdf) #expect(try env.open(passphrase: decomposed) == Data("x".utf8)) } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `swift test --filter EnvelopeTests` Expected: compile error, `Envelope` not found. - [ ] **Step 3: Write Envelope.swift** ```swift import Crypto import Foundation import _CryptoExtras public struct Envelope: Codable, Equatable, Sendable { public struct KDFParams: Codable, Equatable, Sendable { public var name: String public var iterations: Int public var salt: Data public init(name: String, iterations: Int, salt: Data) { self.name = name self.iterations = iterations self.salt = salt } public static func fresh(iterations: Int = Envelope.defaultIterations) -> KDFParams { var rng = SystemRandomNumberGenerator() let salt = Data((0.. Envelope { let key = try deriveKey(passphrase: passphrase, kdf: kdf) do { let sealed = try ChaChaPoly.seal(plaintext, using: key) return Envelope(format: currentFormat, kdf: kdf, box: sealed.combined) } catch { throw KeycaskError.failure("encrypt: \(error)") } } public func open(passphrase: String) throws -> Data { guard format == Self.currentFormat else { throw KeycaskError.corrupt("unsupported format \(format)") } guard kdf.name == Self.kdfName else { throw KeycaskError.corrupt("unsupported kdf \(kdf.name)") } guard box.count >= Self.minimumBoxLength else { throw KeycaskError.corrupt("box too short") } let key = try Self.deriveKey(passphrase: passphrase, kdf: kdf) let sealed: ChaChaPoly.SealedBox do { sealed = try ChaChaPoly.SealedBox(combined: box) } catch { throw KeycaskError.corrupt("box is malformed") } do { return try ChaChaPoly.open(sealed, using: key) } catch { throw KeycaskError.cannotDecrypt } } public init(parsing data: Data) throws { do { self = try JSONDecoder().decode(Envelope.self, from: data) } catch { throw KeycaskError.corrupt("not a keycask vault: \(error)") } } public func encoded() throws -> Data { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys, .prettyPrinted] do { return try encoder.encode(self) } catch { throw KeycaskError.io("encode envelope: \(error)") } } init(format: Int, kdf: KDFParams, box: Data) { self.format = format self.kdf = kdf self.box = box } static func deriveKey(passphrase: String, kdf: KDFParams) throws -> SymmetricKey { let normalized = Array(passphrase.precomposedStringWithCanonicalMapping.utf8) do { return try KDF.Insecure.PBKDF2.deriveKey( from: normalized, salt: kdf.salt, using: .sha256, outputByteCount: keyLength, unsafeUncheckedRounds: kdf.iterations) } catch { throw KeycaskError.failure("derive key: \(error)") } } } ``` `unsafeUncheckedRounds` is used because the checked overload rejects fewer than 210000 rounds, and the vault decides the count. `KDFParams.fresh()` always produces 600000. - [ ] **Step 4: Run tests** Run: `swift test --filter EnvelopeTests` Expected: 9 tests pass. - [ ] **Step 5: Commit** ```bash git add Sources/KeycaskCore Tests/KeycaskCoreTests git commit -m "Add passphrase-encrypted vault envelope" ``` --- ### Task 8: Core merge request **Files:** none new. - [ ] **Step 1: Full suite and lint** Run: `swift test && swift format lint --strict --recursive Sources Tests Package.swift` Expected: all pass, no lint output. - [ ] **Step 2: Push and open the MR** ```bash git push -u origin core gitbay mr create --source core --target main --title "Core library: model, envelope, generator" --file - <<'EOF' KeycaskCore: Entry, EntryID, Vault, VaultCodec, Envelope, Generator, Wordlist, KeycaskError. Package scaffold and gitbay CI. EOF ``` - [ ] **Step 3: Wait for CI, merge, clean up** Run `gitbay build list --json` until the build for `core` is green. Then: ```bash gitbay mr merge --strategy squash git switch main && git pull && git branch -D core && git push origin --delete core ``` If the CI job fails on the swiftly install lines, read `gitbay build log `, fix `.gitbay/ci.yml` on the branch, push, and re-check. Do not merge red. --- ### Task 9: CLI skeleton, paths, passphrase, atomic write, `init` **Files:** - Create: `Sources/keycask/main.swift` (replace) - Create: `Sources/keycask/Keycask.swift` - Create: `Sources/keycask/Paths.swift` - Create: `Sources/keycask/Terminal.swift` - Create: `Sources/keycask/Passphrase.swift` - Create: `Sources/keycask/AtomicFile.swift` - Create: `Sources/keycask/OpenVault.swift` - Create: `Sources/keycask/Commands/Init.swift` - Modify: `Tests/KeycaskCLITests/CLI.swift` - Create: `Tests/KeycaskCLITests/InitTests.swift` - Create: `Tests/KeycaskCLITests/PathsTests.swift` **Interfaces:** - Consumes: `Vault`, `VaultCodec`, `Envelope`, `KeycaskError`. - Produces: ```swift struct GlobalOptions: ParsableArguments { var vault: String? } enum Paths { static func vaultURL(override: String?, environment: [String: String]) -> URL } enum Terminal { static var stdinIsTTY: Bool static func readSecretLine(prompt: String) throws -> String static func readLine(prompt: String) -> String? static func confirm(_ question: String) -> Bool } enum Passphrase { static let variable = "KEYCASK_PASSPHRASE" static func obtain(confirm: Bool, environment: [String: String]) throws -> String } enum AtomicFile { static func write(_ data: Data, to url: URL) throws } struct OpenVault { var vault: Vault let kdf: Envelope.KDFParams let url: URL let passphrase: String static func load(_ options: GlobalOptions) throws -> OpenVault static func create(_ options: GlobalOptions) throws -> URL func save() throws } struct CLI { // test harness struct Result { let status: Int32; let stdout: String; let stderr: String } let dir: URL; let vault: URL; static let passphrase = "correct horse battery" init() throws func run(_ args: [String], stdin: String? = nil, passphrase: String? = CLI.passphrase, extraEnvironment: [String: String] = [:]) throws -> Result } ``` - [ ] **Step 1: Create the branch** ```bash git switch -c cli ``` - [ ] **Step 2: Extend the test harness** Replace `Tests/KeycaskCLITests/CLI.swift`: ```swift import Foundation import Testing enum Binary { static let url: URL = { #if os(macOS) for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { return bundle.bundleURL.deletingLastPathComponent().appendingPathComponent("keycask") } fatalError("test bundle not found") #elseif os(Windows) return Bundle.main.bundleURL.appendingPathComponent("keycask.exe") #else return Bundle.main.bundleURL.appendingPathComponent("keycask") #endif }() } struct CLI { struct Result { let status: Int32 let stdout: String let stderr: String var lines: [String] { stdout.split(separator: "\n").map(String.init) } } static let passphrase = "correct horse battery" let dir: URL let vault: URL init() throws { dir = FileManager.default.temporaryDirectory .appendingPathComponent("keycask-tests-\(UUID().uuidString)") try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) vault = dir.appendingPathComponent("vault.kc") } @discardableResult func run( _ args: [String], stdin: String? = nil, passphrase: String? = CLI.passphrase, extraEnvironment: [String: String] = [:] ) throws -> Result { let process = Process() process.executableURL = Binary.url process.arguments = args var env = ProcessInfo.processInfo.environment env["KEYCASK_VAULT"] = vault.path env.removeValue(forKey: "KEYCASK_PASSPHRASE") if let passphrase { env["KEYCASK_PASSPHRASE"] = passphrase } for (k, v) in extraEnvironment { env[k] = v } process.environment = env let out = Pipe() let err = Pipe() let input = Pipe() process.standardOutput = out process.standardError = err process.standardInput = input try process.run() if let stdin { input.fileHandleForWriting.write(Data(stdin.utf8)) } try input.fileHandleForWriting.close() let outData = out.fileHandleForReading.readDataToEndOfFile() let errData = err.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() return Result( status: process.terminationStatus, stdout: String(decoding: outData, as: UTF8.self), stderr: String(decoding: errData, as: UTF8.self)) } /// Runs `init` and returns the harness, for tests that need a vault. static func initialized() throws -> CLI { let cli = try CLI() let r = try cli.run(["init"]) precondition(r.status == 0, "init failed: \(r.stderr)") return cli } } ``` Remove the `binaryIsBuilt` test from this file; the harness replaces it. - [ ] **Step 3: Write the failing tests** `Tests/KeycaskCLITests/InitTests.swift`: ```swift import Foundation import Testing @Suite struct InitTests { @Test func initCreatesVaultAndPrintsPath() throws { let cli = try CLI() let r = try cli.run(["init"]) #expect(r.status == 0) #expect(r.stdout.contains(cli.vault.path)) #expect(FileManager.default.fileExists(atPath: cli.vault.path)) let text = try String(contentsOf: cli.vault, encoding: .utf8) #expect(text.contains("\"format\" : 1")) #expect(text.contains("pbkdf2-hmac-sha256")) #expect(!text.contains("entries")) } @Test func initRefusesExistingVault() throws { let cli = try CLI.initialized() let r = try cli.run(["init"]) #expect(r.status == 1) #expect(r.stderr.contains("already exists")) } @Test func initWithoutPassphraseOrTTYIsUsageError() throws { let cli = try CLI() let r = try cli.run(["init"], passphrase: nil) #expect(r.status == 2) #expect(r.stderr.contains("KEYCASK_PASSPHRASE")) } @Test func emptyPassphraseIsRejected() throws { let cli = try CLI() let r = try cli.run(["init"], passphrase: "") #expect(r.status == 1) #expect(r.stderr.contains("empty")) } @Test func vaultFlagBeatsEnvironment() throws { let cli = try CLI() let other = cli.dir.appendingPathComponent("elsewhere.kc") let r = try cli.run(["--vault", other.path, "init"]) #expect(r.status == 0) #expect(FileManager.default.fileExists(atPath: other.path)) #expect(!FileManager.default.fileExists(atPath: cli.vault.path)) } @Test func unknownSubcommandIsUsageError() throws { let cli = try CLI() let r = try cli.run(["frobnicate"]) #expect(r.status == 2) #expect(r.stderr.contains("Usage")) } @Test func helpExitsZero() throws { let cli = try CLI() let r = try cli.run(["--help"]) #expect(r.status == 0) #expect(r.stdout.contains("init")) } #if !os(Windows) @Test func vaultIsPrivateOnUnix() throws { let cli = try CLI.initialized() let attrs = try FileManager.default.attributesOfItem(atPath: cli.vault.path) let mode = (attrs[.posixPermissions] as! NSNumber).intValue & 0o777 #expect(mode == 0o600) } #endif @Test func noTempFileLeftBehind() throws { let cli = try CLI.initialized() let names = try FileManager.default.contentsOfDirectory(atPath: cli.dir.path) #expect(names == ["vault.kc"]) } } ``` `Tests/KeycaskCLITests/PathsTests.swift` tests `Paths` in process. It needs `@testable import keycask`, which works because the test target depends on the executable target: ```swift import Foundation import Testing @testable import keycask @Suite struct PathsTests { @Test func overrideWinsOverEverything() { let url = Paths.vaultURL( override: "/x/v.kc", environment: ["KEYCASK_VAULT": "/y", "HOME": "/h"]) #expect(url.path == "/x/v.kc") } @Test func environmentVariableWinsOverDefaults() { let url = Paths.vaultURL(override: nil, environment: ["KEYCASK_VAULT": "/y/v.kc", "HOME": "/h"]) #expect(url.path == "/y/v.kc") } #if os(Windows) @Test func windowsUsesLocalAppData() { let url = Paths.vaultURL(override: nil, environment: ["LOCALAPPDATA": "C:\\Users\\u\\AppData\\Local"]) #expect(url.path.hasSuffix("keycask/vault.kc") || url.path.hasSuffix("keycask\\vault.kc")) } #else @Test func xdgDataHomeIsUsedWhenSet() { let url = Paths.vaultURL(override: nil, environment: ["XDG_DATA_HOME": "/d", "HOME": "/h"]) #expect(url.path == "/d/keycask/vault.kc") } @Test func homeFallback() { let url = Paths.vaultURL(override: nil, environment: ["HOME": "/h"]) #expect(url.path == "/h/.local/share/keycask/vault.kc") } #endif } ``` - [ ] **Step 4: Run tests to verify they fail** Run: `swift test --filter 'InitTests|PathsTests'` Expected: compile error, `Paths` not found. - [ ] **Step 5: Write Paths.swift** ```swift import Foundation enum Paths { static let variable = "KEYCASK_VAULT" static func vaultURL( override: String?, environment: [String: String] = ProcessInfo.processInfo.environment ) -> URL { if let override { return URL(fileURLWithPath: override) } if let env = environment[variable], !env.isEmpty { return URL(fileURLWithPath: env) } return defaultDirectory(environment: environment) .appendingPathComponent("keycask").appendingPathComponent("vault.kc") } private static func defaultDirectory(environment: [String: String]) -> URL { #if os(Windows) let base = environment["LOCALAPPDATA"] ?? environment["USERPROFILE"] ?? "." return URL(fileURLWithPath: base) #else if let xdg = environment["XDG_DATA_HOME"], !xdg.isEmpty { return URL(fileURLWithPath: xdg) } let home = environment["HOME"] ?? "." return URL(fileURLWithPath: home).appendingPathComponent(".local/share") #endif } } ``` - [ ] **Step 6: Write Terminal.swift** ```swift import Foundation import KeycaskCore #if canImport(Darwin) import Darwin #elseif canImport(Glibc) import Glibc #elseif canImport(Musl) import Musl #elseif os(Windows) import CRT import WinSDK #endif enum Terminal { static var stdinIsTTY: Bool { #if os(Windows) return _isatty(_fileno(stdin)) != 0 #else return isatty(STDIN_FILENO) != 0 #endif } static func write(_ text: String) { FileHandle.standardError.write(Data(text.utf8)) } static func readLine(prompt: String) -> String? { write(prompt) return Swift.readLine(strippingNewline: true) } static func confirm(_ question: String) -> Bool { guard let answer = readLine(prompt: question + " [y/N] ") else { return false } return answer.lowercased().hasPrefix("y") } static func readSecretLine(prompt: String) throws -> String { write(prompt) defer { write("\n") } return try withEchoDisabled { Swift.readLine(strippingNewline: true) ?? "" } } #if os(Windows) private static func withEchoDisabled(_ body: () throws -> T) throws -> T { let handle = GetStdHandle(DWORD(bitPattern: -10)) var mode: DWORD = 0 guard GetConsoleMode(handle, &mode).boolValue else { throw KeycaskError.io("GetConsoleMode failed") } SetConsoleMode(handle, mode & ~DWORD(ENABLE_ECHO_INPUT)) defer { SetConsoleMode(handle, mode) } return try body() } #else private static func withEchoDisabled(_ body: () throws -> T) throws -> T { var original = termios() guard tcgetattr(STDIN_FILENO, &original) == 0 else { throw KeycaskError.io("tcgetattr failed") } var quiet = original quiet.c_lflag &= ~tcflag_t(ECHO) tcsetattr(STDIN_FILENO, TCSANOW, &quiet) defer { tcsetattr(STDIN_FILENO, TCSANOW, &original) } return try body() } #endif } ``` - [ ] **Step 7: Write Passphrase.swift** ```swift import Foundation import KeycaskCore enum Passphrase { static let variable = "KEYCASK_PASSPHRASE" static func obtain( confirm: Bool, environment: [String: String] = ProcessInfo.processInfo.environment ) throws -> String { if let fromEnv = environment[variable] { return try validated(fromEnv) } guard Terminal.stdinIsTTY else { throw KeycaskError.usage("no passphrase: set \(variable) or run on a terminal") } let first = try Terminal.readSecretLine(prompt: "Passphrase: ") if confirm { let second = try Terminal.readSecretLine(prompt: "Confirm passphrase: ") guard first == second else { throw KeycaskError.failure("passphrases do not match") } } return try validated(first) } private static func validated(_ passphrase: String) throws -> String { guard !passphrase.isEmpty else { throw KeycaskError.failure("passphrase is empty") } return passphrase } } ``` - [ ] **Step 8: Write AtomicFile.swift** ```swift import Foundation import KeycaskCore #if canImport(Darwin) import Darwin #elseif canImport(Glibc) import Glibc #elseif canImport(Musl) import Musl #elseif os(Windows) import WinSDK #endif enum AtomicFile { static func write(_ data: Data, to url: URL) throws { let directory = url.deletingLastPathComponent() let temp = url.appendingPathExtension("tmp") do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try writePrivate(data, to: temp) try replace(url, with: temp) } catch let error as KeycaskError { try? FileManager.default.removeItem(at: temp) throw error } catch { try? FileManager.default.removeItem(at: temp) throw KeycaskError.io("write \(url.path): \(error)") } } #if os(Windows) private static func writePrivate(_ data: Data, to url: URL) throws { try data.write(to: url) let handle = try FileHandle(forWritingTo: url) try handle.synchronize() try handle.close() } private static func replace(_ target: URL, with temp: URL) throws { let ok = temp.path.withCString(encodedAs: UTF16.self) { src in target.path.withCString(encodedAs: UTF16.self) { dst in MoveFileExW(src, dst, DWORD(MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) } } guard ok.boolValue else { throw KeycaskError.io("rename \(temp.path): error \(GetLastError())") } } #else private static func writePrivate(_ data: Data, to url: URL) throws { let fd = open(url.path, O_WRONLY | O_CREAT | O_TRUNC, 0o600) guard fd >= 0 else { throw KeycaskError.io("open \(url.path): \(String(cString: strerror(errno)))") } let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) try handle.write(contentsOf: data) try handle.synchronize() try handle.close() } private static func replace(_ target: URL, with temp: URL) throws { guard rename(temp.path, target.path) == 0 else { throw KeycaskError.io("rename \(temp.path): \(String(cString: strerror(errno)))") } } #endif } ``` - [ ] **Step 9: Write OpenVault.swift** ```swift import Foundation import KeycaskCore struct OpenVault { var vault: Vault let kdf: Envelope.KDFParams let url: URL let passphrase: String static func load(_ options: GlobalOptions) throws -> OpenVault { let url = Paths.vaultURL(override: options.vault) let data: Data do { data = try Data(contentsOf: url) } catch let error as CocoaError where error.code == .fileReadNoSuchFile { throw KeycaskError.noVault(url.path) } catch { if !FileManager.default.fileExists(atPath: url.path) { throw KeycaskError.noVault(url.path) } throw KeycaskError.io("read \(url.path): \(error)") } let envelope = try Envelope(parsing: data) let passphrase = try Passphrase.obtain(confirm: false) let plaintext = try envelope.open(passphrase: passphrase) let vault = try VaultCodec.decode(plaintext) return OpenVault(vault: vault, kdf: envelope.kdf, url: url, passphrase: passphrase) } static func create(_ options: GlobalOptions) throws -> URL { let url = Paths.vaultURL(override: options.vault) guard !FileManager.default.fileExists(atPath: url.path) else { throw KeycaskError.vaultExists(url.path) } let passphrase = try Passphrase.obtain(confirm: true) let fresh = OpenVault(vault: Vault(), kdf: .fresh(), url: url, passphrase: passphrase) try fresh.save() return url } func save() throws { let plaintext = try VaultCodec.encode(vault) let envelope = try Envelope.seal(plaintext, passphrase: passphrase, kdf: kdf) try AtomicFile.write(try envelope.encoded(), to: url) } } ``` - [ ] **Step 10: Write Keycask.swift and Commands/Init.swift** `Sources/keycask/Keycask.swift`: ```swift import ArgumentParser struct GlobalOptions: ParsableArguments { @Option(name: .long, help: "Path to the vault file.") var vault: String? } struct Keycask: ParsableCommand { static let configuration = CommandConfiguration( commandName: "keycask", abstract: "Command-line password manager. One passphrase-encrypted vault file.", subcommands: [Init.self] ) } ``` `Sources/keycask/Commands/Init.swift`: ```swift import ArgumentParser struct Init: ParsableCommand { static let configuration = CommandConfiguration(abstract: "Create an empty vault.") @OptionGroup var global: GlobalOptions func run() throws { let url = try OpenVault.create(global) print("created \(url.path)") } } ``` - [ ] **Step 11: Write main.swift** ```swift import ArgumentParser import Foundation import KeycaskCore func fail(_ text: String, code: Int32) -> Never { FileHandle.standardError.write(Data((text + "\n").utf8)) exit(code) } do { var command = try Keycask.parseAsRoot() try command.run() } catch let error as KeycaskError { fail(error.message, code: error.exitCode) } catch { let text = Keycask.fullMessage(for: error) if Keycask.exitCode(for: error).isSuccess { print(text) exit(0) } fail(text, code: 2) } ``` - [ ] **Step 12: Run tests** Run: `swift test --filter 'InitTests|PathsTests'` Expected: all pass. The `init` tests take about half a second each because of 600000 PBKDF2 rounds; that is expected. - [ ] **Step 13: Lint and commit** ```bash swift format lint --strict --recursive Sources Tests git add Sources/keycask Tests/KeycaskCLITests git commit -m "Add CLI skeleton with init, paths, passphrase, atomic write" ``` --- ### Task 10: `add`, `show`, and output formatting **Files:** - Create: `Sources/keycask/Output.swift` - Create: `Sources/keycask/Commands/Add.swift` - Create: `Sources/keycask/Commands/Show.swift` - Modify: `Sources/keycask/Keycask.swift` (register subcommands) - Create: `Tests/KeycaskCLITests/AddShowTests.swift` **Interfaces:** - Consumes: `OpenVault`, `Generator`, `Entry`, `Terminal`, `VaultCodec.makeEncoder()`. - Produces: ```swift enum Output { static let mask = "********" static func masked(_ entry: Entry, reveal: Bool) -> Entry static func text(_ entry: Entry, reveal: Bool) -> String // "field: value" lines static func table(_ entries: [Entry]) -> String // id name username url static func json(_ entries: [Entry], reveal: Bool) throws -> String static func json(_ entry: Entry, reveal: Bool) throws -> String static func field(_ entry: Entry, named: String) throws -> String // usage error for unknown field } enum PasswordInput { static func read(prompt: String) throws -> String // TTY: hidden prompt; else first line of stdin } struct PasswordOptions: ParsableArguments { var generate: Bool; var length: Int?; var words: Int?; func validate(); func newPassword() throws -> String? } ``` Non-TTY password entry: when stdin is not a terminal, `add` and `edit --password` read the password as the first line of stdin. Add this sentence to the spec's CLI section in this task. - [ ] **Step 1: Write the failing tests** `Tests/KeycaskCLITests/AddShowTests.swift`: ```swift import Foundation import Testing @Suite struct AddShowTests { @Test func addReadsPasswordFromStdinAndPrintsID() throws { let cli = try CLI.initialized() let r = try cli.run(["add", "github", "-u", "cmc", "--url", "https://github.com", "--tag", "dev"], stdin: "hunter2\n") #expect(r.status == 0) let id = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines) #expect(id.count == 8) let shown = try cli.run(["show", id]) #expect(shown.status == 0) #expect(shown.stdout.contains("name: github")) #expect(shown.stdout.contains("username: cmc")) #expect(shown.stdout.contains("password: ********")) #expect(shown.stdout.contains("tags: dev")) #expect(!shown.stdout.contains("hunter2")) } @Test func showByNameRevealAndField() throws { let cli = try CLI.initialized() try cli.run(["add", "github"], stdin: "hunter2\n") let revealed = try cli.run(["show", "github", "--reveal"]) #expect(revealed.stdout.contains("password: hunter2")) let field = try cli.run(["show", "github", "--field", "password"]) #expect(field.stdout == "hunter2\n") let missing = try cli.run(["show", "github", "--field", "url"]) #expect(missing.status == 0) #expect(missing.stdout == "\n") let unknown = try cli.run(["show", "github", "--field", "nope"]) #expect(unknown.status == 2) } @Test func jsonMasksUnlessReveal() throws { let cli = try CLI.initialized() try cli.run(["add", "github", "-u", "cmc"], stdin: "hunter2\n") let masked = try cli.run(["show", "github", "--json"]) let obj = try JSONSerialization.jsonObject(with: Data(masked.stdout.utf8)) as! [String: Any] #expect(obj["name"] as? String == "github") #expect(obj["username"] as? String == "cmc") #expect(obj["password"] as? String == "********") #expect((obj["id"] as? String)?.count == 8) #expect((obj["created"] as? String)?.hasSuffix("Z") == true) let revealed = try cli.run(["show", "github", "--json", "--reveal"]) let obj2 = try JSONSerialization.jsonObject(with: Data(revealed.stdout.utf8)) as! [String: Any] #expect(obj2["password"] as? String == "hunter2") } @Test func addGenerateAndWords() throws { let cli = try CLI.initialized() try cli.run(["add", "a", "--generate"]) try cli.run(["add", "b", "--generate", "--length", "40"]) try cli.run(["add", "c", "--words", "4"]) #expect(try cli.run(["show", "a", "--field", "password"]).stdout.count == 25) #expect(try cli.run(["show", "b", "--field", "password"]).stdout.count == 41) let words = try cli.run(["show", "c", "--field", "password"]).stdout .trimmingCharacters(in: .newlines).split(separator: "-") #expect(words.count == 4) } @Test func generateAndWordsTogetherIsUsageError() throws { let cli = try CLI.initialized() let r = try cli.run(["add", "a", "--generate", "--words", "3"]) #expect(r.status == 2) } @Test func duplicateNamesAreAllowedAndAmbiguousOnShow() throws { let cli = try CLI.initialized() let a = try cli.run(["add", "gh", "-u", "one", "--generate"]).stdout.trimmingCharacters(in: .newlines) let b = try cli.run(["add", "gh", "-u", "two", "--generate"]).stdout.trimmingCharacters(in: .newlines) let r = try cli.run(["show", "gh"]) #expect(r.status == 5) #expect(r.stderr.contains(a) && r.stderr.contains(b)) #expect(try cli.run(["show", a]).stdout.contains("username: one")) } @Test func missingEntryIsNotFound() throws { let cli = try CLI.initialized() let r = try cli.run(["show", "nope"]) #expect(r.status == 3) #expect(r.stderr == "nope: not found\n") } @Test func wrongPassphraseCannotDecrypt() throws { let cli = try CLI.initialized() let r = try cli.run(["show", "x"], passphrase: "wrong") #expect(r.status == 4) #expect(r.stderr.contains("cannot decrypt")) } @Test func missingVaultIsNotFound() throws { let cli = try CLI() let r = try cli.run(["show", "x"]) #expect(r.status == 3) #expect(r.stderr.contains("keycask init")) } @Test func corruptVaultIsFailure() throws { let cli = try CLI.initialized() try Data("{}".utf8).write(to: cli.vault) let r = try cli.run(["show", "x"]) #expect(r.status == 1) #expect(r.stderr.hasPrefix("vault is corrupt")) } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `swift test --filter AddShowTests` Expected: failures, `add` is an unknown subcommand (exit 2). - [ ] **Step 3: Write Output.swift** ```swift import Foundation import KeycaskCore enum Output { static let mask = "********" static func masked(_ entry: Entry, reveal: Bool) -> Entry { guard !reveal else { return entry } var copy = entry copy.password = mask return copy } static func text(_ entry: Entry, reveal: Bool) -> String { let e = masked(entry, reveal: reveal) var lines = ["id: \(e.id.rawValue)", "name: \(e.name)"] if let u = e.username { lines.append("username: \(u)") } lines.append("password: \(e.password)") if let u = e.url { lines.append("url: \(u)") } if !e.tags.isEmpty { lines.append("tags: \(e.tags.joined(separator: ", "))") } if let n = e.notes { lines.append("notes: \(n)") } lines.append("created: \(iso(e.created))") lines.append("updated: \(iso(e.updated))") return lines.joined(separator: "\n") + "\n" } static func table(_ entries: [Entry]) -> String { guard !entries.isEmpty else { return "" } let rows = entries.map { [$0.id.rawValue, $0.name, $0.username ?? "", $0.url ?? ""] } let widths = (0..<3).map { col in rows.map { $0[col].count }.max() ?? 0 } return rows.map { row in let padded = (0..<3).map { row[$0].padding(toLength: widths[$0], withPad: " ", startingAt: 0) } return (padded + [row[3]]).joined(separator: " ") .trimmingCharacters(in: .whitespaces) }.joined(separator: "\n") + "\n" } static func json(_ entries: [Entry], reveal: Bool) throws -> String { try encode(entries.map { masked($0, reveal: reveal) }) } static func json(_ entry: Entry, reveal: Bool) throws -> String { try encode(masked(entry, reveal: reveal)) } static func field(_ entry: Entry, named name: String) throws -> String { switch name { case "id": entry.id.rawValue case "name": entry.name case "username": entry.username ?? "" case "password": entry.password case "url": entry.url ?? "" case "notes": entry.notes ?? "" case "tags": entry.tags.joined(separator: ",") case "created": iso(entry.created) case "updated": iso(entry.updated) default: throw KeycaskError.usage("unknown field \(name)") } } private static func encode(_ value: some Encodable) throws -> String { let encoder = VaultCodec.makeEncoder() encoder.outputFormatting.insert(.prettyPrinted) do { return String(decoding: try encoder.encode(value), as: UTF8.self) + "\n" } catch { throw KeycaskError.io("encode json: \(error)") } } private static func iso(_ date: Date) -> String { date.formatted(.iso8601) } } ``` - [ ] **Step 4: Write password input and the shared password options** Add to `Sources/keycask/Commands/Add.swift`: ```swift import ArgumentParser import Foundation import KeycaskCore enum PasswordInput { static func read(prompt: String) throws -> String { if Terminal.stdinIsTTY { return try Terminal.readSecretLine(prompt: prompt) } guard let line = Swift.readLine(strippingNewline: true) else { throw KeycaskError.usage("no password: pass one on stdin or run on a terminal") } return line } } struct PasswordOptions: ParsableArguments { @Flag(name: .long, help: "Generate a random password.") var generate = false @Option(name: .long, help: "Length of the generated password (default 24).") var length: Int? @Option(name: .long, help: "Generate a passphrase of this many words instead.") var words: Int? mutating func validate() throws { if generate, words != nil { throw ValidationError("--generate and --words are mutually exclusive") } if let length, length < 1 { throw ValidationError("--length must be at least 1") } if let words, words < 1 { throw ValidationError("--words must be at least 1") } if length != nil, !generate, words == nil { throw ValidationError("--length requires --generate") } } /// nil means the caller must prompt. func newPassword() -> String? { if let words { return Generator.passphrase(words: words) } if generate { return Generator.password(length: length ?? Generator.defaultLength) } return nil } } struct Add: ParsableCommand { static let configuration = CommandConfiguration(abstract: "Add an entry.") @OptionGroup var global: GlobalOptions @Argument(help: "Entry name. Names may repeat; the printed id is unique.") var name: String @Option(name: [.short, .customLong("username")], help: "Username.") var username: String? @Option(name: .long, help: "URL.") var url: String? @Option(name: .long, help: "Notes.") var notes: String? @Option(name: .long, help: "Tag. Repeatable.") var tag: [String] = [] @OptionGroup var password: PasswordOptions func run() throws { var open = try OpenVault.load(global) let secret = try password.newPassword() ?? PasswordInput.read(prompt: "Password: ") var entry = Entry(name: name, username: username, password: secret, url: url, notes: notes, tags: tag) while open.vault.entry(id: entry.id) != nil { entry = Entry(name: name, username: username, password: secret, url: url, notes: notes, tags: tag) } try open.vault.add(entry) try open.save() print(entry.id.rawValue) } } ``` - [ ] **Step 5: Write Commands/Show.swift** ```swift import ArgumentParser import KeycaskCore struct Show: ParsableCommand { static let configuration = CommandConfiguration(abstract: "Show an entry.") @OptionGroup var global: GlobalOptions @Argument(help: "Entry id or name.") var ref: String @Flag(name: .long, help: "Show the password.") var reveal = false @Option(name: .long, help: "Print one field, unmasked.") var field: String? @Flag(name: .long, help: "JSON output.") var json = false func run() throws { let open = try OpenVault.load(global) let entry = try open.vault.resolve(ref) if let field { print(try Output.field(entry, named: field)) } else if json { print(try Output.json(entry, reveal: reveal), terminator: "") } else { print(Output.text(entry, reveal: reveal), terminator: "") } } } ``` - [ ] **Step 6: Register the subcommands** In `Keycask.swift`: `subcommands: [Init.self, Add.self, Show.self]`. - [ ] **Step 7: Amend the spec** In `docs/superpowers/specs/2026-09-17-keycask-design.md`, after the sentence beginning "Passphrase input:", add a paragraph: ``` Password input for `add` and `edit --password`: on a terminal, a hidden prompt. Without a terminal, the first line of stdin. Neither available is exit 2. ``` - [ ] **Step 8: Run tests** Run: `swift test --filter AddShowTests` Expected: 10 tests pass. - [ ] **Step 9: Lint and commit** ```bash swift format lint --strict --recursive Sources Tests git add Sources/keycask Tests/KeycaskCLITests docs git commit -m "Add add and show commands with masked output" ``` --- ### Task 11: `ls` and `find` **Files:** - Create: `Sources/keycask/Commands/Ls.swift` - Create: `Sources/keycask/Commands/Find.swift` - Modify: `Sources/keycask/Keycask.swift` - Create: `Tests/KeycaskCLITests/LsFindTests.swift` **Interfaces:** - Consumes: `OpenVault`, `Output.table`, `Output.json(_:[Entry])`, `Vault.filter(tag:)`, `Vault.search`, `Vault.sortedEntries`. - [ ] **Step 1: Write the failing tests** ```swift import Foundation import Testing @Suite struct LsFindTests { func seeded() throws -> CLI { let cli = try CLI.initialized() try cli.run(["add", "github", "-u", "cmc", "--url", "https://github.com", "--tag", "Dev", "--generate"]) try cli.run(["add", "bank", "--url", "https://bank.example", "--notes", "downtown branch", "--generate"]) try cli.run(["add", "Alpha", "--tag", "dev", "--generate"]) return cli } @Test func lsSortsByNameAndShowsColumns() throws { let cli = try seeded() let r = try cli.run(["ls"]) #expect(r.status == 0) let names = r.lines.map { String($0.split(separator: " ", omittingEmptySubsequences: true)[1]) } #expect(names == ["Alpha", "bank", "github"]) #expect(r.stdout.contains("cmc")) #expect(r.stdout.contains("https://github.com")) } @Test func lsTagFilterIsCaseInsensitive() throws { let cli = try seeded() let r = try cli.run(["ls", "--tag", "DEV"]) #expect(r.lines.count == 2) #expect(!r.stdout.contains("bank")) } @Test func lsJsonIsAnArrayWithMaskedPasswords() throws { let cli = try seeded() let r = try cli.run(["ls", "--json"]) let arr = try JSONSerialization.jsonObject(with: Data(r.stdout.utf8)) as! [[String: Any]] #expect(arr.count == 3) #expect(arr.allSatisfy { $0["password"] as? String == "********" }) } @Test func emptyVaultListsNothing() throws { let cli = try CLI.initialized() let r = try cli.run(["ls"]) #expect(r.status == 0) #expect(r.stdout == "") let j = try cli.run(["ls", "--json"]) #expect(j.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == "[]") } @Test func findMatchesNotesURLTagsCaseInsensitively() throws { let cli = try seeded() #expect(try cli.run(["find", "DOWNTOWN"]).lines.count == 1) #expect(try cli.run(["find", "github.com"]).lines.count == 1) #expect(try cli.run(["find", "dev"]).lines.count == 2) let none = try cli.run(["find", "zzz"]) #expect(none.status == 0) #expect(none.stdout == "") } @Test func findJson() throws { let cli = try seeded() let r = try cli.run(["find", "bank", "--json"]) let arr = try JSONSerialization.jsonObject(with: Data(r.stdout.utf8)) as! [[String: Any]] #expect(arr.count == 1) #expect(arr[0]["name"] as? String == "bank") } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `swift test --filter LsFindTests` Expected: failures, unknown subcommand. - [ ] **Step 3: Write Ls.swift and Find.swift** `Commands/Ls.swift`: ```swift import ArgumentParser import KeycaskCore struct Ls: ParsableCommand { static let configuration = CommandConfiguration(abstract: "List entries.") @OptionGroup var global: GlobalOptions @Option(name: .long, help: "Only entries with this tag.") var tag: String? @Flag(name: .long, help: "JSON output.") var json = false func run() throws { let open = try OpenVault.load(global) let entries = tag.map { open.vault.filter(tag: $0) } ?? open.vault.sortedEntries if json { print(try Output.json(entries, reveal: false), terminator: "") } else { print(Output.table(entries), terminator: "") } } } ``` `Commands/Find.swift`: ```swift import ArgumentParser import KeycaskCore struct Find: ParsableCommand { static let configuration = CommandConfiguration(abstract: "Search entries.") @OptionGroup var global: GlobalOptions @Argument(help: "Case-insensitive substring.") var query: String @Flag(name: .long, help: "JSON output.") var json = false func run() throws { let open = try OpenVault.load(global) let entries = open.vault.search(query) if json { print(try Output.json(entries, reveal: false), terminator: "") } else { print(Output.table(entries), terminator: "") } } } ``` Register both: `subcommands: [Init.self, Add.self, Show.self, Ls.self, Find.self]`. - [ ] **Step 4: Run tests** Run: `swift test --filter LsFindTests` Expected: 6 tests pass. - [ ] **Step 5: Lint and commit** ```bash swift format lint --strict --recursive Sources Tests git add Sources/keycask Tests/KeycaskCLITests git commit -m "Add ls and find commands" ``` --- ### Task 12: `edit` and `rm` **Files:** - Create: `Sources/keycask/Commands/Edit.swift` - Create: `Sources/keycask/Commands/Rm.swift` - Modify: `Sources/keycask/Keycask.swift` - Create: `Tests/KeycaskCLITests/EditRmTests.swift` **Interfaces:** - Consumes: `OpenVault`, `Vault.update`, `Vault.remove`, `PasswordOptions`, `PasswordInput`, `Terminal.confirm`, `Terminal.stdinIsTTY`. - [ ] **Step 1: Write the failing tests** ```swift import Foundation import Testing @Suite struct EditRmTests { @Test func editChangesFieldsAndBumpsUpdated() throws { let cli = try CLI.initialized() try cli.run(["add", "gh", "--tag", "a", "--generate"]) let before = try JSONSerialization.jsonObject( with: Data(try cli.run(["show", "gh", "--json"]).stdout.utf8)) as! [String: Any] let r = try cli.run([ "edit", "gh", "--name", "github", "-u", "cmc", "--url", "https://x", "--notes", "n", "--tag", "b", "--untag", "a", ]) #expect(r.status == 0) let after = try JSONSerialization.jsonObject( with: Data(try cli.run(["show", "github", "--json"]).stdout.utf8)) as! [String: Any] #expect(after["name"] as? String == "github") #expect(after["username"] as? String == "cmc") #expect(after["url"] as? String == "https://x") #expect(after["notes"] as? String == "n") #expect(after["tags"] as? [String] == ["b"]) #expect(after["created"] as? String == before["created"] as? String) #expect(after["id"] as? String == before["id"] as? String) } @Test func editPasswordFromStdinAndGenerate() throws { let cli = try CLI.initialized() try cli.run(["add", "gh", "--generate"]) try cli.run(["edit", "gh", "--password"], stdin: "newpass\n") #expect(try cli.run(["show", "gh", "--field", "password"]).stdout == "newpass\n") try cli.run(["edit", "gh", "--generate", "--length", "30"]) #expect(try cli.run(["show", "gh", "--field", "password"]).stdout.count == 31) } @Test func editWithNoChangesIsUsageError() throws { let cli = try CLI.initialized() try cli.run(["add", "gh", "--generate"]) let r = try cli.run(["edit", "gh"]) #expect(r.status == 2) } @Test func editUnknownIsNotFound() throws { let cli = try CLI.initialized() #expect(try cli.run(["edit", "nope", "--url", "x"]).status == 3) } @Test func rmWithYesRemoves() throws { let cli = try CLI.initialized() let id = try cli.run(["add", "gh", "--generate"]).stdout.trimmingCharacters(in: .newlines) let r = try cli.run(["rm", id, "--yes"]) #expect(r.status == 0) #expect(try cli.run(["show", id]).status == 3) #expect(try cli.run(["ls"]).stdout == "") } @Test func rmWithoutYesAndWithoutTTYIsUsageError() throws { let cli = try CLI.initialized() try cli.run(["add", "gh", "--generate"]) let r = try cli.run(["rm", "gh"], stdin: "y\n") #expect(r.status == 2) #expect(r.stderr.contains("--yes")) #expect(try cli.run(["ls"]).lines.count == 1) } @Test func rmAmbiguousNameLists() throws { let cli = try CLI.initialized() try cli.run(["add", "gh", "--generate"]) try cli.run(["add", "gh", "--generate"]) let r = try cli.run(["rm", "gh", "--yes"]) #expect(r.status == 5) #expect(try cli.run(["ls"]).lines.count == 2) } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `swift test --filter EditRmTests` Expected: failures, unknown subcommand. - [ ] **Step 3: Write Edit.swift** ```swift import ArgumentParser import KeycaskCore struct Edit: ParsableCommand { static let configuration = CommandConfiguration(abstract: "Change an entry.") @OptionGroup var global: GlobalOptions @Argument(help: "Entry id or name.") var ref: String @Option(name: .long, help: "New name.") var name: String? @Option(name: [.short, .customLong("username")], help: "New username.") var username: String? @Option(name: .long, help: "New URL.") var url: String? @Option(name: .long, help: "New notes.") var notes: String? @Option(name: .long, help: "Add a tag. Repeatable.") var tag: [String] = [] @Option(name: .long, help: "Remove a tag. Repeatable.") var untag: [String] = [] @Flag(name: .long, help: "Prompt for a new password.") var password = false @OptionGroup var generated: PasswordOptions mutating func validate() throws { let changes = [name, username, url, notes].contains { $0 != nil } || !tag.isEmpty || !untag.isEmpty || password || generated.generate || generated.words != nil guard changes else { throw ValidationError("nothing to change") } if password, generated.newPassword() != nil { throw ValidationError("--password cannot be combined with --generate or --words") } } func run() throws { var open = try OpenVault.load(global) let target = try open.vault.resolve(ref) let newSecret: String? = password ? try PasswordInput.read(prompt: "New password: ") : generated.newPassword() try open.vault.update(id: target.id) { e in if let name { e.name = name } if let username { e.username = username } if let url { e.url = url } if let notes { e.notes = notes } if let newSecret { e.password = newSecret } let drop = Set(untag.map { $0.lowercased() }) e.tags = e.tags.filter { !drop.contains($0.lowercased()) } + tag } try open.save() } } ``` - [ ] **Step 4: Write Rm.swift** ```swift import ArgumentParser import KeycaskCore struct Rm: ParsableCommand { static let configuration = CommandConfiguration(abstract: "Remove an entry.") @OptionGroup var global: GlobalOptions @Argument(help: "Entry id or name.") var ref: String @Flag(name: .long, help: "Do not ask for confirmation.") var yes = false func run() throws { var open = try OpenVault.load(global) let target = try open.vault.resolve(ref) if !yes { guard Terminal.stdinIsTTY else { throw KeycaskError.usage("refusing to remove without --yes when not on a terminal") } guard Terminal.confirm("remove \(target.name) (\(target.id.rawValue))?") else { throw KeycaskError.failure("aborted") } } try open.vault.remove(id: target.id) try open.save() } } ``` Register: `subcommands: [Init.self, Add.self, Show.self, Ls.self, Find.self, Edit.self, Rm.self]`. - [ ] **Step 5: Run tests** Run: `swift test --filter EditRmTests` Expected: 7 tests pass. - [ ] **Step 6: Lint and commit** ```bash swift format lint --strict --recursive Sources Tests git add Sources/keycask Tests/KeycaskCLITests git commit -m "Add edit and rm commands" ``` --- ### Task 13: `generate` **Files:** - Create: `Sources/keycask/Commands/Generate.swift` - Modify: `Sources/keycask/Keycask.swift` - Create: `Tests/KeycaskCLITests/GenerateTests.swift` **Interfaces:** - Consumes: `Generator`. `--copy` calls `Clipboard.copyWithTimeout`, which does not exist until Task 14; in this task `--copy` is declared but `run()` throws `KeycaskError.failure("clipboard not available")` when it is set. Task 14 replaces that line. - [ ] **Step 1: Write the failing tests** ```swift import Foundation import Testing @Suite struct GenerateTests { @Test func defaultIs24Characters() throws { let cli = try CLI() let r = try cli.run(["generate"], passphrase: nil) #expect(r.status == 0) #expect(r.stdout.count == 25) } @Test func lengthAndWords() throws { let cli = try CLI() #expect(try cli.run(["generate", "--length", "12"], passphrase: nil).stdout.count == 13) let w = try cli.run(["generate", "--words", "6"], passphrase: nil).stdout .trimmingCharacters(in: .newlines).split(separator: "-") #expect(w.count == 6) } @Test func doesNotNeedAVault() throws { let cli = try CLI() #expect(!FileManager.default.fileExists(atPath: cli.vault.path)) #expect(try cli.run(["generate"], passphrase: nil).status == 0) } @Test func lengthAndWordsTogetherIsUsageError() throws { let cli = try CLI() #expect(try cli.run(["generate", "--length", "3", "--words", "3"], passphrase: nil).status == 2) } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `swift test --filter GenerateTests` Expected: failures, unknown subcommand. - [ ] **Step 3: Write Generate.swift** ```swift import ArgumentParser import KeycaskCore struct Generate: ParsableCommand { static let configuration = CommandConfiguration(abstract: "Generate a password.") @Option(name: .long, help: "Password length (default 24).") var length: Int? @Option(name: .long, help: "Passphrase of this many words instead.") var words: Int? @Flag(name: .long, help: "Copy to the clipboard instead of printing.") var copy = false mutating func validate() throws { if length != nil, words != nil { throw ValidationError("--length and --words are mutually exclusive") } if let length, length < 1 { throw ValidationError("--length must be at least 1") } if let words, words < 1 { throw ValidationError("--words must be at least 1") } } func run() throws { let secret = words.map { Generator.passphrase(words: $0) } ?? Generator.password(length: length ?? Generator.defaultLength) if copy { throw KeycaskError.failure("clipboard not available") } print(secret) } } ``` Register: add `Generate.self` to the subcommand list. - [ ] **Step 4: Run tests** Run: `swift test --filter GenerateTests` Expected: 4 tests pass. - [ ] **Step 5: Lint and commit** ```bash swift format lint --strict --recursive Sources Tests git add Sources/keycask Tests/KeycaskCLITests git commit -m "Add generate command" ``` --- ### Task 14: Clipboard, `clip`, daemon, `generate --copy` **Files:** - Create: `Sources/keycask/Clipboard.swift` - Create: `Sources/keycask/Commands/Clip.swift` - Create: `Sources/keycask/Commands/ClipboardDaemon.swift` - Modify: `Sources/keycask/Commands/Generate.swift` - Modify: `Sources/keycask/Keycask.swift` - Create: `Tests/KeycaskCLITests/ClipboardTests.swift` **Interfaces:** - Produces: ```swift enum Clipboard { struct Handoff: Codable, Equatable { var secret: String; var previous: String } struct Tool { let copy: [String]; let paste: [String] } static let timeoutSeconds = 45 static func shouldRestore(secret: String, current: String?) -> Bool static func findTool(path: String, fileManager: FileManager = .default) -> Tool? static func read() throws -> String static func write(_ text: String) throws static func copyWithTimeout(_ secret: String, seconds: Int = timeoutSeconds) throws static func runDaemon(seconds: Int) throws } ``` The tests never touch the real clipboard. They cover the restore decision, tool discovery against a fake PATH, and the daemon's handoff parsing. - [ ] **Step 1: Write the failing tests** `Tests/KeycaskCLITests/ClipboardTests.swift`: ```swift import Foundation import Testing @testable import keycask @Suite struct ClipboardTests { @Test func restoresOnlyWhenClipboardStillHoldsTheSecret() { #expect(Clipboard.shouldRestore(secret: "s", current: "s")) #expect(!Clipboard.shouldRestore(secret: "s", current: "user pasted")) #expect(!Clipboard.shouldRestore(secret: "s", current: nil)) } @Test func handoffRoundTrips() throws { let h = Clipboard.Handoff(secret: "s3cret", previous: "old") let data = try JSONEncoder().encode(h) #expect(try JSONDecoder().decode(Clipboard.Handoff.self, from: data) == h) } @Test func findToolScansPathInOrder() throws { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("keycask-clip-\(UUID().uuidString)") try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) #expect(Clipboard.findTool(path: dir.path) == nil) #if os(macOS) let names = ["pbcopy", "pbpaste"] #elseif os(Windows) let names = ["clip.exe", "powershell.exe"] #else let names = ["xclip"] #endif for n in names { let f = dir.appendingPathComponent(n) try Data("#!/bin/sh\n".utf8).write(to: f) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: f.path) } let tool = Clipboard.findTool(path: dir.path) #expect(tool != nil) #expect(tool?.copy.first?.hasPrefix(dir.path) == true) } @Test func daemonWithoutHandoffFails() throws { let cli = try CLI() let r = try cli.run(["clipboard-daemon", "1"], stdin: "not json", passphrase: nil) #expect(r.status == 1) } @Test func daemonIsHiddenFromHelp() throws { let cli = try CLI() let r = try cli.run(["--help"], passphrase: nil) #expect(!r.stdout.contains("clipboard-daemon")) #expect(r.stdout.contains("clip")) } @Test func clipOfMissingEntryIsNotFoundBeforeTouchingClipboard() throws { let cli = try CLI.initialized() let r = try cli.run(["clip", "nope"]) #expect(r.status == 3) } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `swift test --filter ClipboardTests` Expected: compile error, `Clipboard` not found. - [ ] **Step 3: Write Clipboard.swift** ```swift import Foundation import KeycaskCore enum Clipboard { struct Handoff: Codable, Equatable { var secret: String var previous: String } struct Tool: Equatable { let copy: [String] let paste: [String] } static let timeoutSeconds = 45 static func shouldRestore(secret: String, current: String?) -> Bool { current == secret } static func findTool( path: String = ProcessInfo.processInfo.environment["PATH"] ?? "", fileManager: FileManager = .default ) -> Tool? { #if os(Windows) let separator: Character = ";" let candidates: [(copy: [String], paste: [String])] = [ (["clip.exe"], ["powershell.exe", "-NoProfile", "-Command", "Get-Clipboard -Raw"]) ] #elseif os(macOS) let separator: Character = ":" let candidates: [(copy: [String], paste: [String])] = [(["pbcopy"], ["pbpaste"])] #else let separator: Character = ":" let candidates: [(copy: [String], paste: [String])] = [ (["wl-copy"], ["wl-paste", "--no-newline"]), (["xclip", "-selection", "clipboard"], ["xclip", "-selection", "clipboard", "-o"]), ] #endif let dirs = path.split(separator: separator).map(String.init) func locate(_ name: String) -> String? { for dir in dirs { let full = URL(fileURLWithPath: dir).appendingPathComponent(name).path if fileManager.isExecutableFile(atPath: full) { return full } } return nil } for candidate in candidates { guard let copy = locate(candidate.copy[0]), let paste = locate(candidate.paste[0]) else { continue } return Tool( copy: [copy] + candidate.copy.dropFirst(), paste: [paste] + candidate.paste.dropFirst()) } return nil } static func read() throws -> String { let tool = try requireTool() let (status, output) = try runTool(tool.paste, input: nil) guard status == 0 else { return "" } return output } static func write(_ text: String) throws { let tool = try requireTool() let (status, _) = try runTool(tool.copy, input: text) guard status == 0 else { throw KeycaskError.failure("clipboard tool failed") } } static func copyWithTimeout(_ secret: String, seconds: Int = timeoutSeconds) throws { _ = try requireTool() let handoff = Handoff(secret: secret, previous: try read()) let process = Process() process.executableURL = Bundle.main.executableURL process.arguments = ["clipboard-daemon", String(seconds)] process.standardOutput = FileHandle.nullDevice process.standardError = FileHandle.nullDevice let input = Pipe() process.standardInput = input do { try process.run() input.fileHandleForWriting.write(try JSONEncoder().encode(handoff)) try input.fileHandleForWriting.close() } catch { throw KeycaskError.failure("start clipboard daemon: \(error)") } } static func runDaemon(seconds: Int) throws { let data = FileHandle.standardInput.readDataToEndOfFile() let handoff: Handoff do { handoff = try JSONDecoder().decode(Handoff.self, from: data) } catch { throw KeycaskError.failure("bad handoff") } try write(handoff.secret) Thread.sleep(forTimeInterval: TimeInterval(seconds)) let current = try? read() guard shouldRestore(secret: handoff.secret, current: current) else { return } try write(handoff.previous) } private static func requireTool() throws -> Tool { guard let tool = findTool() else { #if os(Windows) let hint = "clip.exe and powershell.exe" #elseif os(macOS) let hint = "pbcopy and pbpaste" #else let hint = "wl-clipboard or xclip" #endif throw KeycaskError.failure("no clipboard tool found: install \(hint)") } return tool } private static func runTool(_ argv: [String], input: String?) throws -> (Int32, String) { let process = Process() process.executableURL = URL(fileURLWithPath: argv[0]) process.arguments = Array(argv.dropFirst()) let out = Pipe() process.standardOutput = out process.standardError = FileHandle.nullDevice let inPipe = Pipe() process.standardInput = inPipe do { try process.run() } catch { throw KeycaskError.failure("run \(argv[0]): \(error)") } if let input { inPipe.fileHandleForWriting.write(Data(input.utf8)) } try? inPipe.fileHandleForWriting.close() let data = out.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() return (process.terminationStatus, String(decoding: data, as: UTF8.self)) } } ``` - [ ] **Step 4: Write Clip.swift and ClipboardDaemon.swift** `Commands/Clip.swift`: ```swift import ArgumentParser import KeycaskCore struct Clip: ParsableCommand { static let configuration = CommandConfiguration( abstract: "Copy a field to the clipboard. Clears after \(Clipboard.timeoutSeconds) seconds.") @OptionGroup var global: GlobalOptions @Argument(help: "Entry id or name.") var ref: String @Option(name: .long, help: "Field to copy (default password).") var field = "password" func run() throws { let open = try OpenVault.load(global) let entry = try open.vault.resolve(ref) let value = try Output.field(entry, named: field) try Clipboard.copyWithTimeout(value) print("copied \(field) of \(entry.name); clears in \(Clipboard.timeoutSeconds)s") } } ``` `Commands/ClipboardDaemon.swift`: ```swift import ArgumentParser struct ClipboardDaemon: ParsableCommand { static let configuration = CommandConfiguration( commandName: "clipboard-daemon", shouldDisplay: false) @Argument var seconds: Int func run() throws { try Clipboard.runDaemon(seconds: seconds) } } ``` In `Generate.swift` replace the `throw KeycaskError.failure("clipboard not available")` line with: ```swift try Clipboard.copyWithTimeout(secret) print("copied; clears in \(Clipboard.timeoutSeconds)s") return ``` Register: `subcommands: [Init.self, Add.self, Show.self, Ls.self, Find.self, Edit.self, Rm.self, Generate.self, Clip.self, ClipboardDaemon.self]`. - [ ] **Step 5: Run tests** Run: `swift test --filter ClipboardTests` Expected: 6 tests pass. - [ ] **Step 6: Manual check on macOS** ```bash swift build && KEYCASK_VAULT=/tmp/kc-manual.kc KEYCASK_PASSPHRASE=pw .build/debug/keycask init KEYCASK_VAULT=/tmp/kc-manual.kc KEYCASK_PASSPHRASE=pw .build/debug/keycask add t --generate KEYCASK_VAULT=/tmp/kc-manual.kc KEYCASK_PASSPHRASE=pw .build/debug/keycask clip t && pbpaste | wc -c sleep 46 && pbpaste | wc -c rm /tmp/kc-manual.kc ``` Expected: first `wc -c` prints 24, second prints the length of whatever was on the clipboard before (0 if it was empty). Record the actual output in the commit message body if it differs. - [ ] **Step 7: Lint and commit** ```bash swift format lint --strict --recursive Sources Tests git add Sources/keycask Tests/KeycaskCLITests git commit -m "Add clipboard support: clip, generate --copy, timed clear" ``` --- ### Task 15: README, full verification, CLI merge request **Files:** - Modify: `README.md` - [ ] **Step 1: Write the README** ````markdown # keycask Command-line password manager. One passphrase-encrypted vault file. Swift, runs on macOS, Linux, and Windows. ## install ```sh swift build -c release cp .build/release/keycask ~/.local/bin/ ``` ## use ```sh keycask init keycask add github -u cmc --url https://github.com --tag dev --generate keycask add mail --words 6 keycask add bank # prompts for the password keycask show github # password masked keycask show github --reveal keycask show github --field password # raw value, for scripts keycask clip github # clipboard, clears after 45s keycask ls --tag dev keycask find example keycask edit github --tag work --untag dev keycask rm github --yes keycask generate --words 5 --copy ``` Every read command takes `--json`. Passwords are masked unless `--reveal`. Names are labels and may repeat. Every command that takes a name also takes the entry's 8-character id, which `ls` and `add` print. An ambiguous name lists the candidates. ## files | what | default | override | |---|---|---| | vault | `~/.local/share/keycask/vault.kc` (`%LOCALAPPDATA%\keycask\vault.kc` on Windows) | `KEYCASK_VAULT`, `--vault` | | passphrase | prompted | `KEYCASK_PASSPHRASE` | The vault is a JSON envelope: PBKDF2-HMAC-SHA256 (600000 rounds) over the passphrase, ChaCha20-Poly1305 over the entries. Writes are atomic. ## exit codes 0 ok, 1 failure, 2 usage, 3 not found, 4 cannot decrypt, 5 ambiguous name. ## develop ```sh swift build swift test swift format lint --strict --recursive Sources Tests ``` Design: `docs/superpowers/specs/2026-09-17-keycask-design.md`. ```` - [ ] **Step 2: Full suite, lint, release build** Run: `swift test && swift format lint --strict --recursive Sources Tests Package.swift && swift build -c release` Expected: all tests pass, no lint output, release binary at `.build/release/keycask`. - [ ] **Step 3: Commit, push, open MR** ```bash git add README.md git commit -m "Write README for the CLI" git push -u origin cli gitbay mr create --source cli --target main --title "CLI: init, add, show, ls, find, edit, rm, generate, clip" --file - <<'EOF' ArgumentParser commands over KeycaskCore. Paths, hidden passphrase prompt, atomic writes, shell-out clipboard with timed clear. Black-box CLI tests cover every command and exit code. EOF ``` - [ ] **Step 4: Wait for CI, merge, clean up** Run `gitbay build list --json` until green. Then: ```bash gitbay mr merge --strategy squash git switch main && git pull && git branch -D cli && git push origin --delete cli ``` Do not merge red. If Linux CI fails on something platform-specific (a `Glibc` import, `posixPermissions`), fix it on the branch and push again.