diff --git a/Sources/imsg/CommandRouter.swift b/Sources/imsg/CommandRouter.swift index 7d819f9..9c4c9f2 100644 --- a/Sources/imsg/CommandRouter.swift +++ b/Sources/imsg/CommandRouter.swift @@ -15,6 +15,7 @@ struct CommandRouter { WatchCommand.spec, SendCommand.spec, RpcCommand.spec, + CompletionsCommand.spec, ] let descriptor = CommandDescriptor( name: rootName, diff --git a/Sources/imsg/Commands/CompletionsCommand.swift b/Sources/imsg/Commands/CompletionsCommand.swift new file mode 100644 index 0000000..2642275 --- /dev/null +++ b/Sources/imsg/Commands/CompletionsCommand.swift @@ -0,0 +1,459 @@ +import Commander +import Foundation + +/// Generates shell completion scripts and LLM context documentation. +/// +/// Supports bash, zsh, fish shells and a special "llm" format that produces +/// markdown documentation suitable for AI assistant context windows. +enum CompletionsCommand { + /// Note: This command intentionally does not use CommandSignatures.withRuntimeFlags() + /// because --json, --verbose, and --log-level don't make sense for generating + /// completion scripts. The output is always plain text shell/markdown. + static let spec = CommandSpec( + name: "completions", + abstract: "Generate shell completions or LLM context", + discussion: "Outputs completion scripts for bash, zsh, fish, or context for LLM assistants.", + signature: CommandSignature( + arguments: [ + ArgumentDefinition.make(label: "shell", help: "Shell type: bash, zsh, fish, or llm") + ] + ), + usageExamples: [ + "imsg completions bash > ~/.bash_completion.d/imsg", + "imsg completions zsh > ~/.zsh/completions/_imsg", + "imsg completions fish > ~/.config/fish/completions/imsg.fish", + "imsg completions llm | pbcopy", + ] + ) { values, _ in + try run(shell: values.argument(0)) + } + + /// Generates and prints completion output for the specified shell. + /// - Parameter shell: The target shell (bash, zsh, fish) or "llm" for markdown. + /// - Throws: `CompletionsError` if shell is nil or unrecognized. + static func run(shell: String?) throws { + let output = try generateOutput(shell: shell) + Swift.print(output) + } + + /// Generates completion output as a string without printing. + /// + /// - Parameter shell: The target shell (bash, zsh, fish) or "llm" for markdown. + /// - Returns: The generated completion script or documentation. + /// - Throws: `CompletionsError` if shell is nil or unrecognized. + static func generateOutput(shell: String?) throws -> String { + guard let shell = shell else { + throw CompletionsError.missingShell + } + switch shell.lowercased() { + case "bash": + return BashCompletionGenerator.generate() + case "zsh": + return ZshCompletionGenerator.generate() + case "fish": + return FishCompletionGenerator.generate() + case "llm": + return LLMContextGenerator.generate() + default: + throw CompletionsError.unknownShell(shell) + } + } +} + +/// Errors that can occur during completion generation. +enum CompletionsError: Error, CustomStringConvertible, Sendable { + /// No shell argument was provided. + case missingShell + /// The provided shell name is not recognized. + case unknownShell(String) + + /// Human-readable error message for display. + var description: String { + switch self { + case .missingShell: + return "Missing shell argument. Use: bash, zsh, fish, or llm" + case .unknownShell(let shell): + return "Unknown shell '\(shell)'. Use: bash, zsh, fish, or llm" + } + } +} + +// MARK: - Bash + +/// Generates bash completion scripts using the bash-completion framework. +private enum BashCompletionGenerator { + /// Creates a bash completion script for the imsg CLI. + /// - Returns: Complete bash completion script as a string. + static func generate() -> String { + let meta = CompletionMetadata.self + let commands = meta.commands.map { $0.name }.joined(separator: " ") + let logLevels = meta.logLevelChoices.joined(separator: " ") + let services = meta.serviceChoices.joined(separator: " ") + + var commandCases = "" + for cmd in meta.commands { + let allOpts = (meta.runtimeOptions + cmd.options).map { "--\($0.long)" }.joined( + separator: " ") + commandCases += """ + \(cmd.name)) + COMPREPLY=($(compgen -W "\(allOpts)" -- "$cur")) + ;; + + """ + } + + return """ + # Bash completion for \(meta.cliName) + # Generated by: \(meta.cliName) completions bash + + _\(meta.cliName)() { + local cur prev words cword + _init_completion || return + + local commands="\(commands)" + local log_levels="\(logLevels)" + local services="\(services)" + + local cmd="" + local i + for ((i=1; i < cword; i++)); do + case "${words[i]}" in + -*) + case "${words[i]}" in + --db|--log-level) ((i++)) ;; + esac + ;; + *) + if [[ " $commands " =~ " ${words[i]} " ]]; then + cmd="${words[i]}" + break + fi + ;; + esac + done + + case "$prev" in + --log-level) COMPREPLY=($(compgen -W "$log_levels" -- "$cur")); return ;; + --service) COMPREPLY=($(compgen -W "$services" -- "$cur")); return ;; + --db|--file) _filedir; return ;; + esac + + if [[ -z "$cmd" ]]; then + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--help -h --version -V" -- "$cur")) + else + COMPREPLY=($(compgen -W "$commands" -- "$cur")) + fi + return + fi + + case "$cmd" in + \(commandCases) esac + } + + complete -F _\(meta.cliName) \(meta.cliName) + """ + } +} + +// MARK: - Zsh + +/// Generates zsh completion scripts using the zsh completion system. +private enum ZshCompletionGenerator { + /// Creates a zsh completion script for the imsg CLI. + /// - Returns: Complete zsh completion script as a string. + static func generate() -> String { + let meta = CompletionMetadata.self + + var commandDescriptions = "" + for cmd in meta.commands { + commandDescriptions += " '\(cmd.name):\(cmd.description)'\n" + } + + var commandCases = "" + for cmd in meta.commands { + var opts = "" + for opt in meta.runtimeOptions + cmd.options { + let desc = opt.description.replacingOccurrences(of: "'", with: "\\'") + if opt.takesValue { + if let choices = opt.choices { + opts += + " '--\(opt.long)[\(desc)]:\(opt.long):(\(choices.joined(separator: " ")))'\n" + } else { + let hint = opt.valueHint ?? opt.long + opts += " '--\(opt.long)[\(desc)]:\(hint)'\n" + } + } else { + if let short = opt.short { + opts += + " '(-\(short) --\(opt.long))'{-\(short),--\(opt.long)}'[\(desc)]'\n" + } else { + opts += " '--\(opt.long)[\(desc)]'\n" + } + } + } + commandCases += """ + \(cmd.name)) + _arguments \\ + \(opts) && return 0 + ;; + + """ + } + + return """ + #compdef \(meta.cliName) + # Zsh completion for \(meta.cliName) + # Generated by: \(meta.cliName) completions zsh + + _\(meta.cliName)() { + local context state state_descr line + typeset -A opt_args + + local -a commands + commands=( + \(commandDescriptions) ) + + _arguments -C \\ + '(- *)'{-h,--help}'[Show help]' \\ + '(- *)'{-V,--version}'[Show version]' \\ + '1: :->command' \\ + '*:: :->args' \\ + && return 0 + + case $state in + command) + _describe -t commands '\(meta.cliName) commands' commands + ;; + args) + case $words[1] in + \(commandCases) esac + ;; + esac + } + + _\(meta.cliName) "$@" + """ + } +} + +// MARK: - Fish + +/// Generates fish shell completion scripts. +private enum FishCompletionGenerator { + /// Creates a fish completion script for the imsg CLI. + /// - Returns: Complete fish completion script as a string. + static func generate() -> String { + let meta = CompletionMetadata.self + var lines: [String] = [] + + lines.append("# Fish completion for \(meta.cliName)") + lines.append("# Generated by: \(meta.cliName) completions fish") + lines.append("") + lines.append("complete -c \(meta.cliName) -f") + lines.append("") + + // Helper functions + lines.append( + """ + function __\(meta.cliName)_needs_command + set -l cmd (commandline -opc) + test (count $cmd) -eq 1 + end + + function __\(meta.cliName)_using_command + set -l cmd (commandline -opc) + test (count $cmd) -gt 1 && contains -- $cmd[2] $argv + end + """ + ) + lines.append("") + + // Global options + lines.append("# Global options") + lines.append( + "complete -c \(meta.cliName) -n __\(meta.cliName)_needs_command -s h -l help -d 'Show help'") + lines.append( + "complete -c \(meta.cliName) -n __\(meta.cliName)_needs_command -s V -l version -d 'Show version'" + ) + lines.append("") + + // Commands + lines.append("# Commands") + for cmd in meta.commands { + let desc = cmd.description.replacingOccurrences(of: "'", with: "\\'") + lines.append( + "complete -c \(meta.cliName) -n __\(meta.cliName)_needs_command -a \(cmd.name) -d '\(desc)'" + ) + } + lines.append("") + + // Runtime options (available for all commands) + lines.append("# Runtime options") + for opt in meta.runtimeOptions { + let desc = opt.description.replacingOccurrences(of: "'", with: "\\'") + var line = "complete -c \(meta.cliName)" + if let short = opt.short { + line += " -s \(short)" + } + line += " -l \(opt.long) -d '\(desc)'" + if let choices = opt.choices { + line += " -xa '\(choices.joined(separator: " "))'" + } else if opt.takesValue { + if opt.valueHint == "file" { + line += " -r -F" + } else { + line += " -x" + } + } + lines.append(line) + } + lines.append("") + + // Command-specific options + for cmd in meta.commands { + guard !cmd.options.isEmpty else { continue } + lines.append("# \(cmd.name) options") + for opt in cmd.options { + let desc = opt.description.replacingOccurrences(of: "'", with: "\\'") + var line = "complete -c \(meta.cliName) -n '__\(meta.cliName)_using_command \(cmd.name)'" + line += " -l \(opt.long) -d '\(desc)'" + if let choices = opt.choices { + line += " -xa '\(choices.joined(separator: " "))'" + } else if opt.takesValue { + if opt.valueHint == "file" { + line += " -r -F" + } else { + line += " -x" + } + } + lines.append(line) + } + lines.append("") + } + + return lines.joined(separator: "\n") + } +} + +// MARK: - LLM Context + +/// Generates markdown documentation suitable for AI assistant context windows. +private enum LLMContextGenerator { + /// Creates a comprehensive CLI reference in markdown format. + /// - Returns: Markdown documentation with commands, options, and examples. + static func generate() -> String { + let meta = CompletionMetadata.self + var lines: [String] = [] + + lines.append("# \(meta.cliName) CLI Reference") + lines.append("") + lines.append(meta.description) + lines.append("") + lines.append("## Requirements") + lines.append("- macOS 14+ with Messages.app signed in") + lines.append("- Full Disk Access permission for terminal (to read chat.db)") + lines.append("- Automation permission for Messages.app (for sending)") + lines.append("") + + lines.append("## Global Options") + lines.append("```") + lines.append("-h, --help Show help") + lines.append("-V, --version Show version") + lines.append("```") + lines.append("") + + lines.append("## Runtime Options (available on all commands)") + lines.append("```") + for opt in meta.runtimeOptions { + var line = "--\(opt.long)" + if let short = opt.short { + line = "-\(short), " + line + } + if opt.takesValue { + line += " <\(opt.valueHint ?? "value")>" + } + let padding = String(repeating: " ", count: max(1, 30 - line.count)) + line += padding + opt.description + if let choices = opt.choices { + line += " [\(choices.joined(separator: "|"))]" + } + lines.append(line) + } + lines.append("```") + lines.append("") + + lines.append("## Commands") + lines.append("") + for cmd in meta.commands { + lines.append("### \(cmd.name)") + lines.append(cmd.description) + lines.append("") + if !cmd.options.isEmpty { + lines.append("Options:") + lines.append("```") + for opt in cmd.options { + var line = "--\(opt.long)" + if opt.takesValue { + line += " <\(opt.valueHint ?? "value")>" + } + let padding = String(repeating: " ", count: max(1, 28 - line.count)) + line += padding + opt.description + if let choices = opt.choices { + line += " [\(choices.joined(separator: "|"))]" + } + lines.append(line) + } + lines.append("```") + lines.append("") + } + lines.append("Examples:") + lines.append("```bash") + for example in cmd.examples { + lines.append(example) + } + lines.append("```") + lines.append("") + } + + lines.append("## JSON Output") + lines.append("") + lines.append("Use `--json` flag for machine-readable output (JSON Lines format).") + lines.append("") + lines.append("### Chat object") + lines.append("```json") + lines.append( + """ + {"id": 1, "name": "John Doe", "identifier": "+14155551212", "service": "iMessage", "last_message_at": "2025-01-01T12:00:00.000Z"} + """ + ) + lines.append("```") + lines.append("") + lines.append("### Message object") + lines.append("```json") + lines.append( + """ + {"id": 123, "chat_id": 1, "guid": "...", "sender": "+14155551212", "is_from_me": false, "text": "Hello", "created_at": "2025-01-01T12:00:00.000Z", "attachments": [], "reactions": []} + """ + ) + lines.append("```") + lines.append("") + + lines.append("## Common Workflows") + lines.append("") + lines.append("1. List chats to find chat ID:") + lines.append(" `imsg chats --limit 10`") + lines.append("") + lines.append("2. Get history for a chat:") + lines.append(" `imsg history --chat-id 1 --limit 20`") + lines.append("") + lines.append("3. Watch for new messages:") + lines.append(" `imsg watch --chat-id 1 --json`") + lines.append("") + lines.append("4. Send a message:") + lines.append(" `imsg send --to +14155551212 --text \"Hello\"`") + lines.append("") + + return lines.joined(separator: "\n") + } +} diff --git a/Sources/imsg/CompletionMetadata.swift b/Sources/imsg/CompletionMetadata.swift new file mode 100644 index 0000000..33d9314 --- /dev/null +++ b/Sources/imsg/CompletionMetadata.swift @@ -0,0 +1,177 @@ +import Foundation +import IMsgCore + +/// Metadata for shell and LLM completion generation. +/// +/// This structure exists separately from CommandSpec/CommandSignature because Commander's +/// OptionDefinition doesn't support value hints (file, rowid, timestamp) or enumerated choices +/// needed for shell completion. Rather than forking Commander, we maintain this parallel +/// structure. The consistency test verifies command names stay in sync with CommandRouter. +/// +/// If maintainers prefer, this could be integrated into CommandSpec by extending +/// Commander's types or adding a completion metadata property to CommandSpec. +/// +/// Update this file when commands or options change. +enum CompletionMetadata { + /// The CLI executable name used in completion scripts. + static let cliName = "imsg" + + /// Human-readable description of the CLI for help text and LLM context. + static let description = "macOS CLI for iMessage/SMS - send, read, and stream messages" + + /// Service values derived from MessageService enum to avoid hardcoding + static let serviceChoices: [String] = MessageService.allCases.map { $0.rawValue } + + /// Log levels as defined in Commander's withStandardRuntimeFlags. + /// Commander doesn't expose these as an enum, so we define them here. + static let logLevelChoices: [String] = [ + "trace", "verbose", "debug", "info", "warning", "error", "critical", + ] + + /// Describes a CLI option for completion generation. + struct Option { + /// Long form of the option (e.g., "chat-id" for --chat-id) + let long: String + /// Optional short form (e.g., "v" for -v) + let short: String? + /// Help text describing what the option does + let description: String + /// Whether the option requires a value + let takesValue: Bool + /// Hint for the type of value expected (e.g., "file", "rowid") + let valueHint: String? + /// Valid choices for enumerated options + let choices: [String]? + + /// Creates an option definition for completion generation. + init( + _ long: String, + short: String? = nil, + description: String, + takesValue: Bool = true, + valueHint: String? = nil, + choices: [String]? = nil + ) { + self.long = long + self.short = short + self.description = description + self.takesValue = takesValue + self.valueHint = valueHint + self.choices = choices + } + } + + /// Describes a CLI command for completion generation. + struct Command { + /// Command name (e.g., "chats", "send") + let name: String + /// Brief description of what the command does + let description: String + /// Command-specific options (runtime options are added automatically) + let options: [Option] + /// Example usage strings for help text + let examples: [String] + } + + /// Options available on all commands via Commander's withStandardRuntimeFlags. + static let runtimeOptions: [Option] = [ + Option("db", description: "Path to chat.db", valueHint: "file"), + Option("log-level", description: "Set log level", choices: logLevelChoices), + Option("verbose", short: "v", description: "Enable verbose logging", takesValue: false), + Option("json", short: "j", description: "Emit machine-readable JSON output", takesValue: false), + ] + + /// All CLI commands with their options and examples for completion generation. + static let commands: [Command] = [ + Command( + name: "chats", + description: "List recent conversations", + options: [ + Option("limit", description: "Number of chats to list", valueHint: "number") + ], + examples: [ + "imsg chats --limit 5", + "imsg chats --json", + ] + ), + Command( + name: "history", + description: "Show recent messages for a chat", + options: [ + Option("chat-id", description: "Chat rowid from 'imsg chats'", valueHint: "rowid"), + Option("limit", description: "Number of messages to show", valueHint: "number"), + Option( + "participants", + description: "Filter by participant handles (comma-separated)", + valueHint: "handles" + ), + Option("start", description: "ISO8601 start timestamp (inclusive)", valueHint: "timestamp"), + Option("end", description: "ISO8601 end timestamp (exclusive)", valueHint: "timestamp"), + Option("attachments", description: "Include attachment metadata", takesValue: false), + ], + examples: [ + "imsg history --chat-id 1 --limit 10 --attachments", + "imsg history --chat-id 1 --start 2025-01-01T00:00:00Z --json", + ] + ), + Command( + name: "watch", + description: "Stream incoming messages", + options: [ + Option("chat-id", description: "Limit to chat rowid", valueHint: "rowid"), + Option("debounce", description: "Debounce interval (e.g., 250ms)", valueHint: "duration"), + Option("since-rowid", description: "Start watching after this rowid", valueHint: "rowid"), + Option( + "participants", + description: "Filter by participant handles (comma-separated)", + valueHint: "handles" + ), + Option("start", description: "ISO8601 start timestamp (inclusive)", valueHint: "timestamp"), + Option("end", description: "ISO8601 end timestamp (exclusive)", valueHint: "timestamp"), + Option("attachments", description: "Include attachment metadata", takesValue: false), + ], + examples: [ + "imsg watch --chat-id 1 --attachments --debounce 250ms", + "imsg watch --json", + ] + ), + Command( + name: "send", + description: "Send a message (text and/or attachment)", + options: [ + Option("to", description: "Phone number or email", valueHint: "recipient"), + Option("chat-id", description: "Chat rowid (alternative to --to)", valueHint: "rowid"), + Option("chat-identifier", description: "Chat identifier string", valueHint: "identifier"), + Option("chat-guid", description: "Chat GUID", valueHint: "guid"), + Option("text", description: "Message body", valueHint: "message"), + Option("file", description: "Path to attachment", valueHint: "file"), + Option("service", description: "Service to use", choices: serviceChoices), + Option("region", description: "Default region for phone normalization", valueHint: "code"), + ], + examples: [ + "imsg send --to +14155551212 --text \"hello\"", + "imsg send --chat-id 1 --text \"hi\" --file ~/photo.jpg", + ] + ), + Command( + name: "rpc", + description: "Run JSON-RPC server over stdin/stdout", + options: [], + examples: [ + "imsg rpc", + "imsg rpc --db ~/Library/Messages/chat.db", + ] + ), + Command( + name: "completions", + description: "Generate shell completions or LLM context", + options: [], + examples: [ + "imsg completions bash", + "imsg completions zsh", + "imsg completions fish", + "imsg completions llm", + ] + ), + ] +} diff --git a/Tests/imsgTests/CommandTests.swift b/Tests/imsgTests/CommandTests.swift index 9f5a7a3..48730c4 100644 --- a/Tests/imsgTests/CommandTests.swift +++ b/Tests/imsgTests/CommandTests.swift @@ -327,3 +327,111 @@ func watchCommandRunsWithJsonOutput() async throws { streamProvider: streamProvider ) } + +// MARK: - Completions Command Tests + +@Test +func completionsCommandGeneratesBash() throws { + try CompletionsCommand.run(shell: "bash") +} + +@Test +func completionsCommandGeneratesZsh() throws { + try CompletionsCommand.run(shell: "zsh") +} + +@Test +func completionsCommandGeneratesFish() throws { + try CompletionsCommand.run(shell: "fish") +} + +@Test +func completionsCommandGeneratesLLM() throws { + try CompletionsCommand.run(shell: "llm") +} + +@Test +func completionsCommandRejectsMissingShell() { + do { + try CompletionsCommand.run(shell: nil) + #expect(Bool(false)) + } catch let error as CompletionsError { + #expect(error.description.contains("Missing shell")) + } catch { + #expect(Bool(false)) + } +} + +@Test +func completionsCommandRejectsUnknownShell() { + do { + try CompletionsCommand.run(shell: "powershell") + #expect(Bool(false)) + } catch let error as CompletionsError { + #expect(error.description.contains("Unknown shell")) + } catch { + #expect(Bool(false)) + } +} + +@Test +func completionMetadataContainsAllCommands() { + // Verify CompletionMetadata stays in sync with registered commands + let router = CommandRouter() + let registeredNames = Set(router.specs.map { $0.name }) + let metadataNames = Set(CompletionMetadata.commands.map { $0.name }) + + // Every registered command should be in metadata + for name in registeredNames { + #expect(metadataNames.contains(name), "Command '\(name)' missing from CompletionMetadata") + } + + // Every metadata command should be registered (catches stale entries) + for name in metadataNames { + #expect(registeredNames.contains(name), "Command '\(name)' in metadata but not registered") + } +} + +@Test +func completionMetadataServiceChoicesMatchEnum() { + // Verify serviceChoices derives from MessageService enum + let expected = Set(MessageService.allCases.map { $0.rawValue }) + let actual = Set(CompletionMetadata.serviceChoices) + #expect(expected == actual, "serviceChoices doesn't match MessageService.allCases") +} + +@Test +func bashCompletionContainsAllCommands() throws { + let output = try CompletionsCommand.generateOutput(shell: "bash") + for cmd in CompletionMetadata.commands { + #expect(output.contains(cmd.name), "Bash completion missing command: \(cmd.name)") + } + #expect(output.contains("complete -F _imsg imsg")) +} + +@Test +func zshCompletionContainsAllCommands() throws { + let output = try CompletionsCommand.generateOutput(shell: "zsh") + for cmd in CompletionMetadata.commands { + #expect(output.contains(cmd.name), "Zsh completion missing command: \(cmd.name)") + } + #expect(output.contains("#compdef imsg")) +} + +@Test +func fishCompletionContainsAllCommands() throws { + let output = try CompletionsCommand.generateOutput(shell: "fish") + for cmd in CompletionMetadata.commands { + #expect(output.contains(cmd.name), "Fish completion missing command: \(cmd.name)") + } + #expect(output.contains("complete -c imsg")) +} + +@Test +func llmCompletionContainsAllCommands() throws { + let output = try CompletionsCommand.generateOutput(shell: "llm") + for cmd in CompletionMetadata.commands { + #expect(output.contains("### \(cmd.name)"), "LLM context missing command: \(cmd.name)") + } + #expect(output.contains("# imsg CLI Reference")) +}