krz/keycask

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

Commit ae55bc8935

ae55bc893563b9a45fa337e8a2e59102efab5b87

parent: a89f22df65

Verified · cmc

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

Add generate command

Layout: unified · split

Sources/keycask/Commands/Generate.swift added +28
@@ -0,0 +1,28 @@
1import ArgumentParser
2import KeycaskCore
3
4struct Generate: ParsableCommand {
5 static let configuration = CommandConfiguration(abstract: "Generate a password.")
6
7 @Option(name: .long, help: "Password length (default 24).") var length: Int?
8 @Option(name: .long, help: "Passphrase of this many words instead.") var words: Int?
9 @Flag(name: .long, help: "Copy to the clipboard instead of printing.") var copy = false
10
11 mutating func validate() throws {
12 if length != nil, words != nil {
13 throw ValidationError("--length and --words are mutually exclusive")
14 }
15 if let length, length < 1 { throw ValidationError("--length must be at least 1") }
16 if let words, words < 1 { throw ValidationError("--words must be at least 1") }
17 }
18
19 func run() throws {
20 let secret =
21 words.map { Generator.passphrase(words: $0) }
22 ?? Generator.password(length: length ?? Generator.defaultLength)
23 if copy {
24 throw KeycaskError.failure("clipboard not available")
25 }
26 print(secret)
27 }
28}
Sources/keycask/Keycask.swift +3 −1
@@ -9,7 +9,9 @@ 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, Ls.self, Find.self, Edit.self, Rm.self]
12 subcommands: [
13 Init.self, Add.self, Show.self, Ls.self, Find.self, Edit.self, Rm.self, Generate.self,
14 ]
1315 )
1416
1517 @OptionGroup var global: GlobalOptions
Tests/KeycaskCLITests/GenerateTests.swift added +32
@@ -0,0 +1,32 @@
1import Foundation
2import Testing
3
4@Suite struct GenerateTests {
5 @Test func defaultIs24Characters() throws {
6 let cli = try CLI()
7 let r = try cli.run(["generate"], passphrase: nil)
8 #expect(r.status == 0)
9 #expect(r.stdout.count == 25)
10 }
11
12 @Test func lengthAndWords() throws {
13 let cli = try CLI()
14 let lenResult = try cli.run(["generate", "--length", "12"], passphrase: nil)
15 #expect(lenResult.stdout.count == 13)
16 let wordsOutput = try cli.run(["generate", "--words", "6"], passphrase: nil).stdout
17 let w = wordsOutput.trimmingCharacters(in: .newlines).split(separator: "-")
18 #expect(w.count == 6)
19 }
20
21 @Test func doesNotNeedAVault() throws {
22 let cli = try CLI()
23 #expect(!FileManager.default.fileExists(atPath: cli.vault.path))
24 #expect(try cli.run(["generate"], passphrase: nil).status == 0)
25 }
26
27 @Test func lengthAndWordsTogetherIsUsageError() throws {
28 let cli = try CLI()
29 #expect(
30 try cli.run(["generate", "--length", "3", "--words", "3"], passphrase: nil).status == 2)
31 }
32}