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