krz/keycask

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

Commit a89f22df65

a89f22df658222502791cedac3d3cf74ffdc063c

parent: 0c55b46838

Verified · cmc

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

Add edit and rm commands

Layout: unified · split

Sources/keycask/Commands/Edit.swift added +45
@@ -0,0 +1,45 @@
1import ArgumentParser
2import KeycaskCore
3
4struct Edit: ParsableCommand {
5 static let configuration = CommandConfiguration(abstract: "Change an entry.")
6
7 @OptionGroup var global: GlobalOptions
8 @Argument(help: "Entry id or name.") var ref: String
9 @Option(name: .long, help: "New name.") var name: String?
10 @Option(name: [.short, .customLong("username")], help: "New username.") var username: String?
11 @Option(name: .long, help: "New URL.") var url: String?
12 @Option(name: .long, help: "New notes.") var notes: String?
13 @Option(name: .long, help: "Add a tag. Repeatable.") var tag: [String] = []
14 @Option(name: .long, help: "Remove a tag. Repeatable.") var untag: [String] = []
15 @Flag(name: .long, help: "Prompt for a new password.") var password = false
16 @OptionGroup var generated: PasswordOptions
17
18 mutating func validate() throws {
19 let changes =
20 [name, username, url, notes].contains { $0 != nil }
21 || !tag.isEmpty || !untag.isEmpty || password || generated.generate
22 || generated.words != nil
23 guard changes else { throw ValidationError("nothing to change") }
24 if password, generated.newPassword() != nil {
25 throw ValidationError("--password cannot be combined with --generate or --words")
26 }
27 }
28
29 func run() throws {
30 var open = try OpenVault.load(global)
31 let target = try open.vault.resolve(ref)
32 let newSecret: String? =
33 password ? try PasswordInput.read(prompt: "New password: ") : generated.newPassword()
34 try open.vault.update(id: target.id) { e in
35 if let name { e.name = name }
36 if let username { e.username = username }
37 if let url { e.url = url }
38 if let notes { e.notes = notes }
39 if let newSecret { e.password = newSecret }
40 let drop = Set(untag.map { $0.lowercased() })
41 e.tags = e.tags.filter { !drop.contains($0.lowercased()) } + tag
42 }
43 try open.save()
44 }
45}
Sources/keycask/Commands/Rm.swift added +25
@@ -0,0 +1,25 @@
1import ArgumentParser
2import KeycaskCore
3
4struct Rm: ParsableCommand {
5 static let configuration = CommandConfiguration(abstract: "Remove an entry.")
6
7 @OptionGroup var global: GlobalOptions
8 @Argument(help: "Entry id or name.") var ref: String
9 @Flag(name: .long, help: "Do not ask for confirmation.") var yes = false
10
11 func run() throws {
12 var open = try OpenVault.load(global)
13 let target = try open.vault.resolve(ref)
14 if !yes {
15 guard Terminal.stdinIsTTY else {
16 throw KeycaskError.usage("refusing to remove without --yes when not on a terminal")
17 }
18 guard Terminal.confirm("remove \(target.name) (\(target.id.rawValue))?") else {
19 throw KeycaskError.failure("aborted")
20 }
21 }
22 try open.vault.remove(id: target.id)
23 try open.save()
24 }
25}
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, Add.self, Show.self, Ls.self, Find.self] 12 subcommands: [Init.self, Add.self, Show.self, Ls.self, Find.self, Edit.self, Rm.self]
13 ) 13 )
14 14
15 @OptionGroup var global: GlobalOptions 15 @OptionGroup var global: GlobalOptions
Tests/KeycaskCLITests/EditRmTests.swift added +75
@@ -0,0 +1,75 @@
1import Foundation
2import Testing
3
4@Suite struct EditRmTests {
5 @Test func editChangesFieldsAndBumpsUpdated() throws {
6 let cli = try CLI.initialized()
7 try cli.run(["add", "gh", "--tag", "a", "--generate"])
8 let before =
9 try JSONSerialization.jsonObject(
10 with: Data(try cli.run(["show", "gh", "--json"]).stdout.utf8)) as! [String: Any]
11 let r = try cli.run([
12 "edit", "gh", "--name", "github", "-u", "cmc", "--url", "https://x", "--notes", "n",
13 "--tag", "b", "--untag", "a",
14 ])
15 #expect(r.status == 0)
16 let after =
17 try JSONSerialization.jsonObject(
18 with: Data(try cli.run(["show", "github", "--json"]).stdout.utf8)) as! [String: Any]
19 #expect(after["name"] as? String == "github")
20 #expect(after["username"] as? String == "cmc")
21 #expect(after["url"] as? String == "https://x")
22 #expect(after["notes"] as? String == "n")
23 #expect(after["tags"] as? [String] == ["b"])
24 #expect(after["created"] as? String == before["created"] as? String)
25 #expect(after["id"] as? String == before["id"] as? String)
26 }
27
28 @Test func editPasswordFromStdinAndGenerate() throws {
29 let cli = try CLI.initialized()
30 try cli.run(["add", "gh", "--generate"])
31 try cli.run(["edit", "gh", "--password"], stdin: "newpass\n")
32 #expect(try cli.run(["show", "gh", "--field", "password"]).stdout == "newpass\n")
33 try cli.run(["edit", "gh", "--generate", "--length", "30"])
34 #expect(try cli.run(["show", "gh", "--field", "password"]).stdout.count == 31)
35 }
36
37 @Test func editWithNoChangesIsUsageError() throws {
38 let cli = try CLI.initialized()
39 try cli.run(["add", "gh", "--generate"])
40 let r = try cli.run(["edit", "gh"])
41 #expect(r.status == 2)
42 }
43
44 @Test func editUnknownIsNotFound() throws {
45 let cli = try CLI.initialized()
46 #expect(try cli.run(["edit", "nope", "--url", "x"]).status == 3)
47 }
48
49 @Test func rmWithYesRemoves() throws {
50 let cli = try CLI.initialized()
51 let id = try cli.run(["add", "gh", "--generate"]).stdout.trimmingCharacters(in: .newlines)
52 let r = try cli.run(["rm", id, "--yes"])
53 #expect(r.status == 0)
54 #expect(try cli.run(["show", id]).status == 3)
55 #expect(try cli.run(["ls"]).stdout == "")
56 }
57
58 @Test func rmWithoutYesAndWithoutTTYIsUsageError() throws {
59 let cli = try CLI.initialized()
60 try cli.run(["add", "gh", "--generate"])
61 let r = try cli.run(["rm", "gh"], stdin: "y\n")
62 #expect(r.status == 2)
63 #expect(r.stderr.contains("--yes"))
64 #expect(try cli.run(["ls"]).lines.count == 1)
65 }
66
67 @Test func rmAmbiguousNameLists() throws {
68 let cli = try CLI.initialized()
69 try cli.run(["add", "gh", "--generate"])
70 try cli.run(["add", "gh", "--generate"])
71 let r = try cli.run(["rm", "gh", "--yes"])
72 #expect(r.status == 5)
73 #expect(try cli.run(["ls"]).lines.count == 2)
74 }
75}