krz/keycask

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

Commit 9069ae00a0

9069ae00a01f8f74b22dd7e00761f18dce645b8f

parent: 9a5889fafe

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-17 16:08 UTC

Add add and show commands with masked output

Layout: unified · split

Sources/keycask/Commands/Add.swift added +72
@@ -0,0 +1,72 @@
1import ArgumentParser
2import Foundation
3import KeycaskCore
4
5enum PasswordInput {
6 static func read(prompt: String) throws -> String {
7 if Terminal.stdinIsTTY {
8 return try Terminal.readSecretLine(prompt: prompt)
9 }
10 guard let line = Swift.readLine(strippingNewline: true) else {
11 throw KeycaskError.usage("no password: pass one on stdin or run on a terminal")
12 }
13 return line
14 }
15}
16
17struct PasswordOptions: ParsableArguments {
18 @Flag(name: .long, help: "Generate a random password.")
19 var generate = false
20
21 @Option(name: .long, help: "Length of the generated password (default 24).")
22 var length: Int?
23
24 @Option(name: .long, help: "Generate a passphrase of this many words instead.")
25 var words: Int?
26
27 mutating func validate() throws {
28 if generate, words != nil {
29 throw ValidationError("--generate and --words are mutually exclusive")
30 }
31 if let length, length < 1 { throw ValidationError("--length must be at least 1") }
32 if let words, words < 1 { throw ValidationError("--words must be at least 1") }
33 if length != nil, !generate, words == nil {
34 throw ValidationError("--length requires --generate")
35 }
36 }
37
38 /// nil means the caller must prompt.
39 func newPassword() -> String? {
40 if let words { return Generator.passphrase(words: words) }
41 if generate { return Generator.password(length: length ?? Generator.defaultLength) }
42 return nil
43 }
44}
45
46struct Add: ParsableCommand {
47 static let configuration = CommandConfiguration(abstract: "Add an entry.")
48
49 @OptionGroup var global: GlobalOptions
50 @Argument(help: "Entry name. Names may repeat; the printed id is unique.") var name: String
51 @Option(name: [.short, .customLong("username")], help: "Username.") var username: String?
52 @Option(name: .long, help: "URL.") var url: String?
53 @Option(name: .long, help: "Notes.") var notes: String?
54 @Option(name: .long, help: "Tag. Repeatable.") var tag: [String] = []
55 @OptionGroup var password: PasswordOptions
56
57 func run() throws {
58 var open = try OpenVault.load(global)
59 let secret = try password.newPassword() ?? PasswordInput.read(prompt: "Password: ")
60 var entry = Entry(
61 name: name, username: username, password: secret, url: url,
62 notes: notes, tags: tag)
63 while open.vault.entry(id: entry.id) != nil {
64 entry = Entry(
65 name: name, username: username, password: secret, url: url,
66 notes: notes, tags: tag)
67 }
68 try open.vault.add(entry)
69 try open.save()
70 print(entry.id.rawValue)
71 }
72}
Sources/keycask/Commands/Show.swift added +24
@@ -0,0 +1,24 @@
1import ArgumentParser
2import KeycaskCore
3
4struct Show: ParsableCommand {
5 static let configuration = CommandConfiguration(abstract: "Show an entry.")
6
7 @OptionGroup var global: GlobalOptions
8 @Argument(help: "Entry id or name.") var ref: String
9 @Flag(name: .long, help: "Show the password.") var reveal = false
10 @Option(name: .long, help: "Print one field, unmasked.") var field: String?
11 @Flag(name: .long, help: "JSON output.") var json = false
12
13 func run() throws {
14 let open = try OpenVault.load(global)
15 let entry = try open.vault.resolve(ref)
16 if let field {
17 print(try Output.field(entry, named: field))
18 } else if json {
19 print(try Output.json(entry, reveal: reveal), terminator: "")
20 } else {
21 print(Output.text(entry, reveal: reveal), terminator: "")
22 }
23 }
24}
Sources/keycask/Keycask.swift +1 −1
@@ -9,7 +9,7 @@ struct Keycask: ParsableCommand {
9 static let configuration = CommandConfiguration( 9 static let configuration = CommandConfiguration(
10 commandName: "keycask", 10 commandName: "keycask",
11 abstract: "Command-line password manager. One passphrase-encrypted vault file.", 11 abstract: "Command-line password manager. One passphrase-encrypted vault file.",
12 subcommands: [Init.self] 12 subcommands: [Init.self, Add.self, Show.self]
13 ) 13 )
14 14
15 @OptionGroup var global: GlobalOptions 15 @OptionGroup var global: GlobalOptions
Sources/keycask/Output.swift added +76
@@ -0,0 +1,76 @@
1import Foundation
2import KeycaskCore
3
4enum Output {
5 static let mask = "********"
6
7 static func masked(_ entry: Entry, reveal: Bool) -> Entry {
8 guard !reveal else { return entry }
9 var copy = entry
10 copy.password = mask
11 return copy
12 }
13
14 static func text(_ entry: Entry, reveal: Bool) -> String {
15 let e = masked(entry, reveal: reveal)
16 var lines = ["id: \(e.id.rawValue)", "name: \(e.name)"]
17 if let u = e.username { lines.append("username: \(u)") }
18 lines.append("password: \(e.password)")
19 if let u = e.url { lines.append("url: \(u)") }
20 if !e.tags.isEmpty { lines.append("tags: \(e.tags.joined(separator: ", "))") }
21 if let n = e.notes { lines.append("notes: \(n)") }
22 lines.append("created: \(iso(e.created))")
23 lines.append("updated: \(iso(e.updated))")
24 return lines.joined(separator: "\n") + "\n"
25 }
26
27 static func table(_ entries: [Entry]) -> String {
28 guard !entries.isEmpty else { return "" }
29 let rows = entries.map { [$0.id.rawValue, $0.name, $0.username ?? "", $0.url ?? ""] }
30 let widths = (0..<3).map { col in rows.map { $0[col].count }.max() ?? 0 }
31 return rows.map { row in
32 let padded = (0..<3).map {
33 row[$0].padding(toLength: widths[$0], withPad: " ", startingAt: 0)
34 }
35 return (padded + [row[3]]).joined(separator: " ")
36 .trimmingCharacters(in: .whitespaces)
37 }.joined(separator: "\n") + "\n"
38 }
39
40 static func json(_ entries: [Entry], reveal: Bool) throws -> String {
41 try encode(entries.map { masked($0, reveal: reveal) })
42 }
43
44 static func json(_ entry: Entry, reveal: Bool) throws -> String {
45 try encode(masked(entry, reveal: reveal))
46 }
47
48 static func field(_ entry: Entry, named name: String) throws -> String {
49 switch name {
50 case "id": entry.id.rawValue
51 case "name": entry.name
52 case "username": entry.username ?? ""
53 case "password": entry.password
54 case "url": entry.url ?? ""
55 case "notes": entry.notes ?? ""
56 case "tags": entry.tags.joined(separator: ",")
57 case "created": iso(entry.created)
58 case "updated": iso(entry.updated)
59 default: throw KeycaskError.usage("unknown field \(name)")
60 }
61 }
62
63 private static func encode(_ value: some Encodable) throws -> String {
64 let encoder = VaultCodec.makeEncoder()
65 encoder.outputFormatting.insert(.prettyPrinted)
66 do {
67 return String(decoding: try encoder.encode(value), as: UTF8.self) + "\n"
68 } catch {
69 throw KeycaskError.io("encode json: \(error)")
70 }
71 }
72
73 private static func iso(_ date: Date) -> String {
74 date.formatted(.iso8601)
75 }
76}
Tests/KeycaskCLITests/AddShowTests.swift added +111
@@ -0,0 +1,111 @@
1import Foundation
2import Testing
3
4@Suite struct AddShowTests {
5 @Test func addReadsPasswordFromStdinAndPrintsID() throws {
6 let cli = try CLI.initialized()
7 let r = try cli.run(
8 ["add", "github", "-u", "cmc", "--url", "https://github.com", "--tag", "dev"],
9 stdin: "hunter2\n")
10 #expect(r.status == 0)
11 let id = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
12 #expect(id.count == 8)
13
14 let shown = try cli.run(["show", id])
15 #expect(shown.status == 0)
16 #expect(shown.stdout.contains("name: github"))
17 #expect(shown.stdout.contains("username: cmc"))
18 #expect(shown.stdout.contains("password: ********"))
19 #expect(shown.stdout.contains("tags: dev"))
20 #expect(!shown.stdout.contains("hunter2"))
21 }
22
23 @Test func showByNameRevealAndField() throws {
24 let cli = try CLI.initialized()
25 try cli.run(["add", "github"], stdin: "hunter2\n")
26 let revealed = try cli.run(["show", "github", "--reveal"])
27 #expect(revealed.stdout.contains("password: hunter2"))
28 let field = try cli.run(["show", "github", "--field", "password"])
29 #expect(field.stdout == "hunter2\n")
30 let missing = try cli.run(["show", "github", "--field", "url"])
31 #expect(missing.status == 0)
32 #expect(missing.stdout == "\n")
33 let unknown = try cli.run(["show", "github", "--field", "nope"])
34 #expect(unknown.status == 2)
35 }
36
37 @Test func jsonMasksUnlessReveal() throws {
38 let cli = try CLI.initialized()
39 try cli.run(["add", "github", "-u", "cmc"], stdin: "hunter2\n")
40 let masked = try cli.run(["show", "github", "--json"])
41 let obj = try JSONSerialization.jsonObject(with: Data(masked.stdout.utf8)) as! [String: Any]
42 #expect(obj["name"] as? String == "github")
43 #expect(obj["username"] as? String == "cmc")
44 #expect(obj["password"] as? String == "********")
45 #expect((obj["id"] as? String)?.count == 8)
46 #expect((obj["created"] as? String)?.hasSuffix("Z") == true)
47 let revealed = try cli.run(["show", "github", "--json", "--reveal"])
48 let obj2 =
49 try JSONSerialization.jsonObject(with: Data(revealed.stdout.utf8)) as! [String: Any]
50 #expect(obj2["password"] as? String == "hunter2")
51 }
52
53 @Test func addGenerateAndWords() throws {
54 let cli = try CLI.initialized()
55 try cli.run(["add", "a", "--generate"])
56 try cli.run(["add", "b", "--generate", "--length", "40"])
57 try cli.run(["add", "c", "--words", "4"])
58 #expect(try cli.run(["show", "a", "--field", "password"]).stdout.count == 25)
59 #expect(try cli.run(["show", "b", "--field", "password"]).stdout.count == 41)
60 let words = try cli.run(["show", "c", "--field", "password"]).stdout
61 .trimmingCharacters(in: .newlines).split(separator: "-")
62 #expect(words.count == 4)
63 }
64
65 @Test func generateAndWordsTogetherIsUsageError() throws {
66 let cli = try CLI.initialized()
67 let r = try cli.run(["add", "a", "--generate", "--words", "3"])
68 #expect(r.status == 2)
69 }
70
71 @Test func duplicateNamesAreAllowedAndAmbiguousOnShow() throws {
72 let cli = try CLI.initialized()
73 let a = try cli.run(["add", "gh", "-u", "one", "--generate"]).stdout.trimmingCharacters(
74 in: .newlines)
75 let b = try cli.run(["add", "gh", "-u", "two", "--generate"]).stdout.trimmingCharacters(
76 in: .newlines)
77 let r = try cli.run(["show", "gh"])
78 #expect(r.status == 5)
79 #expect(r.stderr.contains(a) && r.stderr.contains(b))
80 #expect(try cli.run(["show", a]).stdout.contains("username: one"))
81 }
82
83 @Test func missingEntryIsNotFound() throws {
84 let cli = try CLI.initialized()
85 let r = try cli.run(["show", "nope"])
86 #expect(r.status == 3)
87 #expect(r.stderr == "nope: not found\n")
88 }
89
90 @Test func wrongPassphraseCannotDecrypt() throws {
91 let cli = try CLI.initialized()
92 let r = try cli.run(["show", "x"], passphrase: "wrong")
93 #expect(r.status == 4)
94 #expect(r.stderr.contains("cannot decrypt"))
95 }
96
97 @Test func missingVaultIsNotFound() throws {
98 let cli = try CLI()
99 let r = try cli.run(["show", "x"])
100 #expect(r.status == 3)
101 #expect(r.stderr.contains("keycask init"))
102 }
103
104 @Test func corruptVaultIsFailure() throws {
105 let cli = try CLI.initialized()
106 try Data("{}".utf8).write(to: cli.vault)
107 let r = try cli.run(["show", "x"])
108 #expect(r.status == 1)
109 #expect(r.stderr.hasPrefix("vault is corrupt"))
110 }
111}
docs/superpowers/specs/2026-09-17-keycask-design.md +4
@@ -183,6 +183,10 @@ Passphrase input: if `KEYCASK_PASSPHRASE` is set, its value is used. It
183exists so tests and scripts run unattended. Otherwise the CLI prompts on 183exists so tests and scripts run unattended. Otherwise the CLI prompts on
184the terminal with echo off. No TTY and no variable is exit 2. 184the terminal with echo off. No TTY and no variable is exit 2.
185 185
186Password input for `add` and `edit --password`: on a terminal, a hidden
187prompt. Without a terminal, the first line of stdin. Neither available is
188exit 2.
189
186Exit codes: 190Exit codes:
187 191
188| Code | Meaning | 192| Code | Meaning |