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