diff --git a/Sources/Packages/Package.swift b/Sources/Packages/Package.swift
index 92dc60d..44c0323 100644
--- a/Sources/Packages/Package.swift
+++ b/Sources/Packages/Package.swift
@@ -88,6 +88,16 @@ let package = Package(
name: "XPCWrappers",
swiftSettings: swiftSettings,
),
+ .executableTarget(
+ name: "SecretiveCLI",
+ dependencies: [
+ "SecretAgentKit",
+ "SecureEnclaveSecretKit",
+ "SecretKit",
+ "Common",
+ ],
+ swiftSettings: swiftSettings,
+ ),
]
)
diff --git a/Sources/Packages/Sources/SecretiveCLI/README.md b/Sources/Packages/Sources/SecretiveCLI/README.md
new file mode 100644
index 0000000..3967684
--- /dev/null
+++ b/Sources/Packages/Sources/SecretiveCLI/README.md
@@ -0,0 +1,129 @@
+# Secretive CLI
+
+A command-line interface for Secretive that provides full key management and SSH agent functionality, sharing the same keychain and socket path as the GUI application.
+
+## Building
+
+Build the CLI using Swift Package Manager:
+
+```bash
+swift build -c release --product SecretiveCLI
+```
+
+The binary will be located at `.build/release/SecretiveCLI`.
+
+## Code Signing
+
+To use the CLI with the same keychain access as the GUI app, you must sign it with the same bundle identifier and entitlements.
+
+### Required Entitlements
+
+The CLI must be signed with entitlements that include the same keychain access group as the GUI app. Create an entitlements file (e.g., `SecretiveCLI.entitlements`) with:
+
+```xml
+
+
+
+
+ com.apple.security.smartcard
+
+ keychain-access-groups
+
+ $(AppIdentifierPrefix)com.maxgoedjen.Secretive
+
+
+
+```
+
+### Signing Command
+
+Sign the CLI binary with:
+
+```bash
+codesign --force \
+ --sign "Developer ID Application: YOUR_TEAM_NAME" \
+ --options runtime \
+ --identifier com.maxgoedjen.Secretive.Host \
+ --entitlements SecretiveCLI.entitlements \
+ .build/release/SecretiveCLI
+```
+
+Replace `YOUR_TEAM_NAME` with your actual Developer ID or use your team's signing identity.
+
+**Important:** The `--identifier` must be `com.maxgoedjen.Secretive.Host` to match the GUI app's bundle identifier, ensuring the CLI can access the same keychain items.
+
+## Usage
+
+### Agent Management
+
+Install and manage the SSH agent as a launchd service:
+
+```bash
+# Install the agent as a launchd service
+secretive-cli agent install
+
+# Start the agent
+secretive-cli agent start
+
+# Check agent status
+secretive-cli agent status
+
+# Stop the agent
+secretive-cli agent stop
+
+# Uninstall the agent
+secretive-cli agent uninstall
+
+# Run agent in foreground (for testing)
+secretive-cli agent run
+```
+
+### Key Management
+
+Manage SSH keys stored in the Secure Enclave:
+
+```bash
+# Generate a new key
+secretive-cli key generate "My Key Name"
+
+# List all keys
+secretive-cli key list
+
+# Show public key for a specific key
+secretive-cli key show "My Key Name"
+
+# Delete a key
+secretive-cli key delete "My Key Name"
+
+# Update key (note: attributes cannot be changed after creation)
+secretive-cli key update "My Key Name"
+```
+
+## Socket Path
+
+The CLI uses the same socket path as the GUI app:
+- Production: `~/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh`
+- Debug: `~/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket-debug.ssh`
+
+Set `SSH_AUTH_SOCK` to this path to use the agent:
+
+```bash
+export SSH_AUTH_SOCK=~/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
+```
+
+## Keychain Access
+
+The CLI shares the same keychain access group as the GUI app (`com.maxgoedjen.Secretive`), allowing it to:
+- Access keys created by the GUI app
+- Create keys that are accessible by the GUI app
+- Use the same Secure Enclave storage
+
+This is achieved by signing the CLI with the same bundle identifier (`com.maxgoedjen.Secretive.Host`) and keychain access group entitlements.
+
+## Notes
+
+- The CLI uses the same `SecretStoreList` setup as the GUI app, including Secure Enclave and Smart Card stores
+- Keys created via CLI will appear in the GUI app and vice versa
+- The agent can be run either via launchd (recommended) or in foreground mode for testing
+- All key operations require appropriate authentication (Touch ID, Apple Watch, or password) as configured per key
+
diff --git a/Sources/Packages/Sources/SecretiveCLI/main.swift b/Sources/Packages/Sources/SecretiveCLI/main.swift
new file mode 100644
index 0000000..c70c334
--- /dev/null
+++ b/Sources/Packages/Sources/SecretiveCLI/main.swift
@@ -0,0 +1,419 @@
+import Foundation
+import Darwin
+import SecretAgentKit
+import SecretKit
+import SecureEnclaveSecretKit
+import SmartCardSecretKit
+import Common
+import OSLog
+
+@main
+struct SecretiveCLI {
+ private static let logger = Logger(subsystem: "com.maxgoedjen.secretive.cli", category: "CLI")
+
+ static func main() async {
+ let args = Array(CommandLine.arguments.dropFirst())
+
+ guard let command = args.first else {
+ printUsage()
+ exit(1)
+ }
+
+ do {
+ switch command {
+ case "agent":
+ try await handleAgentCommand(args: Array(args.dropFirst()))
+ case "key":
+ try await handleKeyCommand(args: Array(args.dropFirst()))
+ case "help", "--help", "-h":
+ printUsage()
+ default:
+ print("Unknown command: \(command)")
+ printUsage()
+ exit(1)
+ }
+ } catch {
+ logger.error("Error: \(error.localizedDescription)")
+ print("Error: \(error.localizedDescription)")
+ exit(1)
+ }
+ }
+
+ static func printUsage() {
+ print("""
+ Secretive CLI - Manage SSH keys with Secure Enclave
+
+ Usage: secretive-cli [options]
+
+ Commands:
+ agent Manage SSH agent
+ install Install agent as launchd service
+ uninstall Uninstall agent from launchd
+ start Start the agent service
+ stop Stop the agent service
+ status Check agent status
+ run Run agent in foreground (for testing)
+
+ key Manage SSH keys
+ generate [name] Generate a new key (default: "Secretive Key")
+ list List all keys
+ show Show public key for a key by name
+ delete Delete a key by name
+ update Update key attributes (name/authentication)
+
+ help Show this help message
+ """)
+ }
+}
+
+// MARK: - Agent Commands
+
+extension SecretiveCLI {
+
+ static func handleAgentCommand(args: [String]) async throws {
+ guard let subcommand = args.first else {
+ print("Agent subcommand required: install, uninstall, start, stop, status, or run")
+ exit(1)
+ }
+
+ switch subcommand {
+ case "install":
+ try await installAgent()
+ case "uninstall":
+ try await uninstallAgent()
+ case "start":
+ try await startAgent()
+ case "stop":
+ try await stopAgent()
+ case "status":
+ try await checkAgentStatus()
+ case "run":
+ try await runAgent()
+ default:
+ print("Unknown agent subcommand: \(subcommand)")
+ exit(1)
+ }
+ }
+
+ static func installAgent() async throws {
+ let plistPath = launchdPlistPath
+ let plistDir = (plistPath as NSString).deletingLastPathComponent
+
+ // Create directory if needed
+ try FileManager.default.createDirectory(atPath: plistDir, withIntermediateDirectories: true)
+
+ // Get the CLI binary path
+ guard let cliPath = Bundle.main.executablePath else {
+ throw CLIError("Could not determine CLI binary path")
+ }
+
+ // Create plist content
+ let plist: [String: Any] = [
+ "Label": launchdServiceLabel,
+ "ProgramArguments": [cliPath, "agent", "run"],
+ "RunAtLoad": true,
+ "KeepAlive": true,
+ "StandardOutPath": "/dev/null",
+ "StandardErrorPath": "/dev/null",
+ "EnvironmentVariables": [
+ "SSH_AUTH_SOCK": socketPath
+ ]
+ ]
+
+ let plistData = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
+ try plistData.write(to: URL(fileURLWithPath: plistPath))
+
+ // Bootstrap the service
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/bin/launchctl")
+ process.arguments = ["bootstrap", "gui/\(getuid())", plistPath]
+
+ try process.run()
+ process.waitUntilExit()
+
+ if process.terminationStatus != 0 {
+ throw CLIError("Failed to install agent: launchctl bootstrap returned \(process.terminationStatus)")
+ }
+
+ print("Agent installed successfully")
+ }
+
+ static func uninstallAgent() async throws {
+ let plistPath = launchdPlistPath
+
+ // Unbootstrap the service
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/bin/launchctl")
+ process.arguments = ["bootout", "gui/\(getuid())", launchdServiceLabel]
+
+ try process.run()
+ process.waitUntilExit()
+
+ // Remove plist file if it exists
+ if FileManager.default.fileExists(atPath: plistPath) {
+ try FileManager.default.removeItem(atPath: plistPath)
+ }
+
+ print("Agent uninstalled successfully")
+ }
+
+ static func startAgent() async throws {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/bin/launchctl")
+ process.arguments = ["kickstart", "gui/\(getuid())/\(launchdServiceLabel)"]
+
+ try process.run()
+ process.waitUntilExit()
+
+ if process.terminationStatus != 0 {
+ throw CLIError("Failed to start agent: launchctl kickstart returned \(process.terminationStatus)")
+ }
+
+ print("Agent started")
+ }
+
+ static func stopAgent() async throws {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/bin/launchctl")
+ process.arguments = ["kill", "gui/\(getuid())/\(launchdServiceLabel)"]
+
+ try process.run()
+ process.waitUntilExit()
+
+ print("Agent stopped")
+ }
+
+ static func checkAgentStatus() async throws {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/bin/launchctl")
+ process.arguments = ["list", launchdServiceLabel]
+
+ let pipe = Pipe()
+ process.standardOutput = pipe
+
+ try process.run()
+ process.waitUntilExit()
+
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
+ let output = String(data: data, encoding: .utf8) ?? ""
+
+ if process.terminationStatus == 0 && !output.isEmpty {
+ print("Agent is running")
+ print(output)
+ } else {
+ print("Agent is not running")
+ }
+ }
+
+ static func runAgent() async throws {
+ logger.info("Starting SSH agent")
+
+ // Set up store list
+ let storeList: SecretStoreList = await MainActor.run {
+ let list = SecretStoreList()
+ let cryptoKit = SecureEnclave.Store()
+ let migrator = SecureEnclave.CryptoKitMigrator()
+ try? migrator.migrate(to: cryptoKit)
+ list.add(store: cryptoKit)
+ list.add(store: SmartCard.Store())
+ return list
+ }
+
+ // Create agent (no witness for CLI)
+ let agent = Agent(storeList: storeList, witness: nil)
+
+ // Set up socket controller
+ let socket = SocketController(path: socketPath)
+
+ // Set up input parser (use direct parser, not XPC)
+ let parser = SSHAgentInputParser()
+
+ logger.info("SSH agent listening on \(socketPath)")
+ print("SSH agent running on \(socketPath)")
+ print("Set SSH_AUTH_SOCK=\(socketPath) to use this agent")
+
+ // Handle sessions
+ for await session in socket.sessions {
+ Task {
+ do {
+ for await message in session.messages {
+ let request = try parser.parse(data: message)
+ let response = await agent.handle(request: request, provenance: session.provenance)
+ try await MainActor.run {
+ try session.write(response)
+ }
+ }
+ } catch {
+ logger.error("Session error: \(error.localizedDescription)")
+ try? session.close()
+ }
+ }
+ }
+ }
+
+ // MARK: - Agent Paths
+
+ static var socketPath: String {
+ // Use the same socket path as the GUI app
+ // This matches URL.socketPath from Common module, which constructs:
+ // ~/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
+ let containerPath = "\(home)/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data"
+ #if DEBUG
+ return "\(containerPath)/socket-debug.ssh"
+ #else
+ return "\(containerPath)/socket.ssh"
+ #endif
+ }
+
+ static var launchdServiceLabel: String {
+ "com.maxgoedjen.secretive.cli"
+ }
+
+ static var launchdPlistPath: String {
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
+ return "\(home)/Library/LaunchAgents/\(launchdServiceLabel).plist"
+ }
+}
+
+// MARK: - Key Commands
+
+extension SecretiveCLI {
+
+ static func handleKeyCommand(args: [String]) async throws {
+ guard let subcommand = args.first else {
+ print("Key subcommand required: generate, list, show, delete, or update")
+ exit(1)
+ }
+
+ // Set up store list
+ let storeList: SecretStoreList = await MainActor.run {
+ let list = SecretStoreList()
+ let cryptoKit = SecureEnclave.Store()
+ let migrator = SecureEnclave.CryptoKitMigrator()
+ try? migrator.migrate(to: cryptoKit)
+ list.add(store: cryptoKit)
+ list.add(store: SmartCard.Store())
+ return list
+ }
+
+ guard let modifiableStore = await storeList.modifiableStore else {
+ throw CLIError("No modifiable store available")
+ }
+
+ switch subcommand {
+ case "generate":
+ let name = args.count > 1 ? args[1] : "Secretive Key"
+ try await generateKey(name: name, store: modifiableStore)
+ case "list":
+ try await listKeys(storeList: storeList)
+ case "show":
+ guard args.count > 1 else {
+ print("Key name required for show command")
+ exit(1)
+ }
+ try await showKey(name: args[1], storeList: storeList)
+ case "delete":
+ guard args.count > 1 else {
+ print("Key name required for delete command")
+ exit(1)
+ }
+ try await deleteKey(name: args[1], store: modifiableStore, storeList: storeList)
+ case "update":
+ guard args.count > 1 else {
+ print("Key name required for update command")
+ exit(1)
+ }
+ try await updateKey(name: args[1], store: modifiableStore, storeList: storeList)
+ default:
+ print("Unknown key subcommand: \(subcommand)")
+ exit(1)
+ }
+ }
+
+ static func generateKey(name: String, store: AnySecretStoreModifiable) async throws {
+ let attributes = Attributes(
+ keyType: .ecdsa256,
+ authentication: .presenceRequired,
+ publicKeyAttribution: nil
+ )
+
+ let secret = try await store.create(name: name, attributes: attributes)
+ let writer = OpenSSHPublicKeyWriter()
+ let publicKeyString = writer.openSSHString(secret: secret)
+
+ print("Key '\(name)' generated successfully")
+ print("Public key:")
+ print(publicKeyString)
+ }
+
+ static func listKeys(storeList: SecretStoreList) async throws {
+ let secrets = await storeList.allSecrets
+
+ if secrets.isEmpty {
+ print("No keys found")
+ return
+ }
+
+ print("Keys:")
+ for secret in secrets {
+ let authIndicator = secret.authenticationRequirement.required ? "🔒" : "🔓"
+ print(" \(authIndicator) \(secret.name) (\(secret.keyType.description))")
+ }
+ }
+
+ static func showKey(name: String, storeList: SecretStoreList) async throws {
+ let secrets = await storeList.allSecrets
+ guard let secret = secrets.first(where: { $0.name == name }) else {
+ throw CLIError("Key '\(name)' not found")
+ }
+
+ let writer = OpenSSHPublicKeyWriter()
+ let publicKeyString = writer.openSSHString(secret: secret)
+
+ print("Public key for '\(name)':")
+ print(publicKeyString)
+ print("\nFingerprints:")
+ print(" SHA256: \(writer.openSSHSHA256Fingerprint(secret: secret))")
+ print(" MD5: \(writer.openSSHMD5Fingerprint(secret: secret))")
+ }
+
+ static func deleteKey(name: String, store: AnySecretStoreModifiable, storeList: SecretStoreList) async throws {
+ let secrets = await storeList.allSecrets
+ guard let secret = secrets.first(where: { $0.name == name }) else {
+ throw CLIError("Key '\(name)' not found")
+ }
+
+ print("Deleting key '\(name)'...")
+ try await store.delete(secret: secret)
+ print("Key '\(name)' deleted successfully")
+ }
+
+ static func updateKey(name: String, store: AnySecretStoreModifiable, storeList: SecretStoreList) async throws {
+ let secrets = await storeList.allSecrets
+ guard let secret = secrets.first(where: { $0.name == name }) else {
+ throw CLIError("Key '\(name)' not found")
+ }
+
+ // For now, just update the name (attributes can't be changed after creation)
+ // In a full implementation, you might want to add prompts for new name
+ print("Note: Key attributes cannot be changed after creation.")
+ print("Current key: \(secret.name)")
+ print("To rename, use: secretive-cli key delete '\(name)' && secretive-cli key generate ''")
+ }
+}
+
+// MARK: - Errors
+
+struct CLIError: Error, CustomStringConvertible {
+ let message: String
+
+ init(_ message: String) {
+ self.message = message
+ }
+
+ var description: String {
+ message
+ }
+}
+