krz/keycask

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

Commit 0c55b46838

0c55b468383a5c9c7418e3cef618e2d34e6370d7

parent: 9069ae00a0

Verified · cmc

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

Add ls and find commands

Layout: unified · split

Sources/keycask/Commands/Find.swift added +20
@@ -0,0 +1,20 @@
1import ArgumentParser
2import KeycaskCore
3
4struct Find: ParsableCommand {
5 static let configuration = CommandConfiguration(abstract: "Search entries.")
6
7 @OptionGroup var global: GlobalOptions
8 @Argument(help: "Case-insensitive substring.") var query: String
9 @Flag(name: .long, help: "JSON output.") var json = false
10
11 func run() throws {
12 let open = try OpenVault.load(global)
13 let entries = open.vault.search(query)
14 if json {
15 print(try Output.json(entries, reveal: false), terminator: "")
16 } else {
17 print(Output.table(entries), terminator: "")
18 }
19 }
20}
Sources/keycask/Commands/Ls.swift added +20
@@ -0,0 +1,20 @@
1import ArgumentParser
2import KeycaskCore
3
4struct Ls: ParsableCommand {
5 static let configuration = CommandConfiguration(abstract: "List entries.")
6
7 @OptionGroup var global: GlobalOptions
8 @Option(name: .long, help: "Only entries with this tag.") var tag: String?
9 @Flag(name: .long, help: "JSON output.") var json = false
10
11 func run() throws {
12 let open = try OpenVault.load(global)
13 let entries = tag.map { open.vault.filter(tag: $0) } ?? open.vault.sortedEntries
14 if json {
15 print(try Output.json(entries, reveal: false), terminator: "")
16 } else {
17 print(Output.table(entries), terminator: "")
18 }
19 }
20}
Sources/keycask/Keycask.swift +1 −1
@@ -9,7 +9,7 @@ struct Keycask: ParsableCommand {
99 static let configuration = CommandConfiguration(
1010 commandName: "keycask",
1111 abstract: "Command-line password manager. One passphrase-encrypted vault file.",
12 subcommands: [Init.self, Add.self, Show.self]
12 subcommands: [Init.self, Add.self, Show.self, Ls.self, Find.self]
1313 )
1414
1515 @OptionGroup var global: GlobalOptions
Sources/keycask/Output.swift +2 −1
@@ -38,7 +38,8 @@ enum Output {
3838 }
3939
4040 static func json(_ entries: [Entry], reveal: Bool) throws -> String {
41 try encode(entries.map { masked($0, reveal: reveal) })
41 guard !entries.isEmpty else { return "[]\n" }
42 return try encode(entries.map { masked($0, reveal: reveal) })
4243 }
4344
4445 static func json(_ entry: Entry, reveal: Bool) throws -> String {
Tests/KeycaskCLITests/LsFindTests.swift added +72
@@ -0,0 +1,72 @@
1import Foundation
2import Testing
3
4@Suite struct LsFindTests {
5 func seeded() throws -> CLI {
6 let cli = try CLI.initialized()
7 try cli.run([
8 "add", "github", "-u", "cmc", "--url", "https://github.com", "--tag", "Dev",
9 "--generate",
10 ])
11 try cli.run([
12 "add", "bank", "--url", "https://bank.example", "--notes", "downtown branch",
13 "--generate",
14 ])
15 try cli.run(["add", "Alpha", "--tag", "dev", "--generate"])
16 return cli
17 }
18
19 @Test func lsSortsByNameAndShowsColumns() throws {
20 let cli = try seeded()
21 let r = try cli.run(["ls"])
22 #expect(r.status == 0)
23 let names = r.lines.map {
24 String($0.split(separator: " ", omittingEmptySubsequences: true)[1])
25 }
26 #expect(names == ["Alpha", "bank", "github"])
27 #expect(r.stdout.contains("cmc"))
28 #expect(r.stdout.contains("https://github.com"))
29 }
30
31 @Test func lsTagFilterIsCaseInsensitive() throws {
32 let cli = try seeded()
33 let r = try cli.run(["ls", "--tag", "DEV"])
34 #expect(r.lines.count == 2)
35 #expect(!r.stdout.contains("bank"))
36 }
37
38 @Test func lsJsonIsAnArrayWithMaskedPasswords() throws {
39 let cli = try seeded()
40 let r = try cli.run(["ls", "--json"])
41 let arr = try JSONSerialization.jsonObject(with: Data(r.stdout.utf8)) as! [[String: Any]]
42 #expect(arr.count == 3)
43 #expect(arr.allSatisfy { $0["password"] as? String == "********" })
44 }
45
46 @Test func emptyVaultListsNothing() throws {
47 let cli = try CLI.initialized()
48 let r = try cli.run(["ls"])
49 #expect(r.status == 0)
50 #expect(r.stdout == "")
51 let j = try cli.run(["ls", "--json"])
52 #expect(j.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == "[]")
53 }
54
55 @Test func findMatchesNotesURLTagsCaseInsensitively() throws {
56 let cli = try seeded()
57 #expect(try cli.run(["find", "DOWNTOWN"]).lines.count == 1)
58 #expect(try cli.run(["find", "github.com"]).lines.count == 1)
59 #expect(try cli.run(["find", "dev"]).lines.count == 2)
60 let none = try cli.run(["find", "zzz"])
61 #expect(none.status == 0)
62 #expect(none.stdout == "")
63 }
64
65 @Test func findJson() throws {
66 let cli = try seeded()
67 let r = try cli.run(["find", "bank", "--json"])
68 let arr = try JSONSerialization.jsonObject(with: Data(r.stdout.utf8)) as! [[String: Any]]
69 #expect(arr.count == 1)
70 #expect(arr[0]["name"] as? String == "bank")
71 }
72}