Skip to main content
Glama
AgentCommand.swift37.7 kB
import Commander import Darwin import Dispatch import Foundation import Logging import PeekabooAgentRuntime import PeekabooCore import PeekabooFoundation import Spinner import Tachikoma import TachikomaMCP import TauTUI // Temporary session info struct until PeekabooAgentService implements session management // Test: Icon notifications are now working struct AgentSessionInfo: Codable { let id: String let task: String let created: Date let lastModified: Date let messageCount: Int } // Simple debug logging check private var isDebugLoggingEnabled: Bool { // Check if verbose mode is enabled via log level if let logLevel = ProcessInfo.processInfo.environment["PEEKABOO_LOG_LEVEL"]?.lowercased() { return logLevel == "debug" || logLevel == "trace" } // Check if agent is in verbose mode if ProcessInfo.processInfo.arguments.contains("-v") || ProcessInfo.processInfo.arguments.contains("--verbose") { return true } return false } private let defaultMCPServerName = "chrome-devtools" private func aiDebugPrint(_ message: String) { if isDebugLoggingEnabled { print(message) } } /// Output modes for agent execution with progressive enhancement enum OutputMode { case minimal // CI/pipes - no colors, simple text case compact // Basic colors and icons (legacy default) case enhanced // Rich formatting with progress indicators case quiet // Only final result case verbose // Full JSON debug information } /// Get icon for tool name in compact mode func iconForTool(_ toolName: String) -> String { AgentDisplayTokens.icon(for: toolName) } /// AI Agent command that uses new Chat Completions API architecture @available(macOS 14.0, *) struct AgentCommand: RuntimeOptionsConfigurable { static let commandDescription = CommandDescription( commandName: "agent", abstract: "Execute complex automation tasks using the Peekaboo agent", discussion: """ Launches the autonomous Peekaboo operator so it can interpret a natural-language goal, choose tools (see, click, type, etc.), and report progress back to you. Supports resuming previous sessions, dry-run planning, audio input, and JSON/quiet output modes for CI. """, usageExamples: [ CommandUsageExample( command: "peekaboo agent \"Prepare the TestFlight build for review\"", description: "Start a brand-new session with a natural-language brief." ), CommandUsageExample( command: "peekaboo agent --resume", description: "Resume the most recent session without retyping the task." ), CommandUsageExample( command: "peekaboo agent --resume-session SESSION_ID --max-steps 12", description: "Resume a known session while capping the step budget." ) ] ) @Argument(help: "Natural language description of the task to perform (optional when using --resume)") var task: String? @Flag(name: .customLong("debug-terminal"), help: "Show detailed terminal detection info") var debugTerminal = false @Flag(names: [.short("q"), .long], help: "Quiet mode - only show final result") var quiet = false @Flag(name: .long, help: "Dry run - show planned steps without executing") var dryRun = false @Option(name: .long, help: "Maximum number of steps the agent can take") var maxSteps: Int? @Option(name: .long, help: "Queue mode for queued prompts: one-at-a-time (default) or all") var queueMode: String? @Option(name: .long, help: "AI model to use (allowed: gpt-5.1 or claude-sonnet-4.5)") var model: String? @Flag(name: .long, help: "Resume the most recent session (use with task argument)") var resume = false @Option(name: .long, help: "Resume a specific session by ID") var resumeSession: String? @Flag(name: .long, help: "List available sessions") var listSessions = false @Flag(name: .long, help: "Disable session caching (always create new session)") var noCache = false @Flag(name: .long, help: "Enable audio input mode (record from microphone)") var audio = false @Option(name: .long, help: "Audio input file path (instead of microphone)") var audioFile: String? @Flag(name: .long, help: "Use real-time audio streaming (OpenAI only)") var realtime = false @Flag(name: .long, help: "Force simple output mode (no colors or rich formatting)") var simple = false @Flag(name: .long, help: "Disable colors in output") var noColor = false @Flag(name: .long, help: "Start an interactive chat session") var chat = false /// Computed property for output mode with smart detection and progressive enhancement private var outputMode: OutputMode { // Explicit user overrides first if self.quiet { return .quiet } if self.verbose || self.debugTerminal { return .verbose } if self.simple { return .minimal } if self.noColor { return .minimal } // Check for environment-based forced modes if let forcedMode = TerminalDetector.shouldForceOutputMode() { return forcedMode } // Smart detection based on terminal capabilities let capabilities = TerminalDetector.detectCapabilities() return capabilities.recommendedOutputMode } @RuntimeStorage private var runtime: CommandRuntime? var runtimeOptions = CommandRuntimeOptions() private var resolvedRuntime: CommandRuntime { guard let runtime else { preconditionFailure("CommandRuntime must be configured before accessing runtime resources") } return runtime } @MainActor var services: any PeekabooServiceProviding { self.resolvedRuntime.services } private var logger: Logger { self.resolvedRuntime.logger } var jsonOutput: Bool { self.runtime?.configuration.jsonOutput ?? self.runtimeOptions.jsonOutput } var verbose: Bool { self.runtime?.configuration.verbose ?? self.runtimeOptions.verbose } } @MainActor private final class TerminalModeGuard { private let fd: Int32 private var original = termios() private var active = false init?(fd: Int32 = STDIN_FILENO) { guard isatty(fd) == 1 else { return nil } guard tcgetattr(fd, &self.original) == 0 else { return nil } var raw = self.original cfmakeraw(&raw) raw.c_lflag |= tcflag_t(ISIG) // keep signals like Ctrl+C enabled guard tcsetattr(fd, TCSANOW, &raw) == 0 else { return nil } self.fd = fd self.active = true } var fileDescriptor: Int32 { self.fd } func restore() { guard self.active else { return } _ = tcsetattr(self.fd, TCSANOW, &self.original) self.active = false } @MainActor deinit { self.restore() } } final class EscapeKeyMonitor { private var source: (any DispatchSourceRead)? private var terminalGuard: TerminalModeGuard? private let handler: @Sendable () async -> Void private let queue = DispatchQueue(label: "peekaboo.escape.monitor") init(handler: @escaping @Sendable () async -> Void) { self.handler = handler } func start() { guard self.source == nil else { return } guard let termGuard = TerminalModeGuard() else { return } let fd = termGuard.fileDescriptor let handler = self.handler let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: self.queue) source.setEventHandler { var buffer = [UInt8](repeating: 0, count: 16) let count = read(fd, &buffer, buffer.count) guard count > 0 else { return } if buffer[..<count].contains(0x1B) { Task.detached(priority: .userInitiated) { await handler() } } } source.setCancelHandler { termGuard.restore() } source.resume() self.source = source self.terminalGuard = termGuard } func stop() { self.source?.cancel() self.source = nil self.terminalGuard = nil } } @available(macOS 14.0, *) extension AgentCommand { @MainActor mutating func run() async throws { let runtime = await CommandRuntime.makeDefaultAsync() try await self.run(using: runtime) } @MainActor mutating func run(using runtime: CommandRuntime) async throws { self.runtime = runtime do { try await self.runInternal(runtime: runtime) } catch let error as DecodingError { aiDebugPrint("DEBUG: Caught DecodingError in run(): \(error)") throw error } catch let error as NSError { aiDebugPrint("DEBUG: Caught NSError in run(): \(error)") aiDebugPrint("DEBUG: Domain: \(error.domain)") aiDebugPrint("DEBUG: Code: \(error.code)") aiDebugPrint("DEBUG: UserInfo: \(error.userInfo)") throw error } catch { aiDebugPrint("DEBUG: Caught unknown error in run(): \(error)") throw error } } @MainActor mutating func runInternal(runtime: CommandRuntime) async throws { if self.isAgentDisabled() { self.emitAgentUnavailableMessage() return } let services = runtime.services guard let agentService = services.agent else { self.emitAgentUnavailableMessage() return } let terminalCapabilities = TerminalDetector.detectCapabilities() if self.debugTerminal { self.printTerminalDetectionDebug(terminalCapabilities, actualMode: self.outputMode) } if self.listSessions { try await self.showSessions(agentService) return } guard self.hasConfiguredAIProvider(configuration: services.configuration) else { self.emitAgentUnavailableMessage() return } let shouldSuppressMCPLogs = !self.verbose && !self.debugTerminal self.configureLogging(suppressingMCPLogs: shouldSuppressMCPLogs) // Warm up MCP servers off the main actor so chat can start immediately. Task.detached(priority: .utility) { await Self.initializeMCP() } guard let peekabooAgent = agentService as? PeekabooAgentService else { throw PeekabooError.commandFailed("Agent service not properly initialized") } let requestedModel: LanguageModel? do { requestedModel = try self.validatedModelSelection() } catch { self.printAgentExecutionError(error.localizedDescription) return } guard await self.ensureAgentHasCredentials(peekabooAgent, requestedModel: requestedModel) else { return } let chatPolicy = AgentChatLaunchPolicy() let chatContext = AgentChatLaunchContext( chatFlag: self.chat, hasTaskInput: self.hasTaskInput, listSessions: self.listSessions, normalizedTaskInput: self.normalizedTaskInput, capabilities: terminalCapabilities ) let queueMode: QueueMode do { queueMode = try self.resolvedQueueMode() } catch { self.printAgentExecutionError(error.localizedDescription) return } switch chatPolicy.strategy(for: chatContext) { case .helpOnly: self.printNonInteractiveChatHelp() return case let .interactive(initialPrompt): try await self.runChatLoop( peekabooAgent, requestedModel: requestedModel, initialPrompt: initialPrompt, capabilities: terminalCapabilities, queueMode: queueMode ) return case .none: break } if try await self.handleSessionResumption(peekabooAgent, requestedModel: requestedModel) { return } guard let executionTask = try await self.buildExecutionTask() else { return } _ = try await self.executeAgentTask( peekabooAgent, task: executionTask, requestedModel: requestedModel, maxSteps: self.maxSteps ?? 100, queueMode: queueMode ) } private func isAgentDisabled() -> Bool { let value = ProcessInfo.processInfo.environment["PEEKABOO_DISABLE_AGENT"]?.lowercased() return value == "1" || value == "true" } private func configureLogging(suppressingMCPLogs: Bool) { if suppressingMCPLogs { LoggingSystem.bootstrap { label in var handler = StreamLogHandler.standardOutput(label: label) if label.hasPrefix("tachikoma.mcp") { handler.logLevel = .critical // hide MCP init chatter unless --verbose } else { handler.logLevel = .info } return handler } } else { LoggingSystem.bootstrap(StreamLogHandler.standardOutput) } } private static func initializeMCP() async { if ProcessInfo.processInfo.environment["PEEKABOO_ENABLE_BROWSER_MCP"] == "1" { let defaultChromeDevTools = ChromeDevToolsServerFactory.tachikomaConfig(timeout: 60.0, autoReconnect: true) TachikomaMCPClientManager.shared.registerDefaultServers( [defaultMCPServerName: defaultChromeDevTools]) } await TachikomaMCPClientManager.shared.initializeFromProfile() } private func ensureAgentHasCredentials( _ peekabooAgent: PeekabooAgentService, requestedModel: LanguageModel? ) async -> Bool { if let requestedModel { if self.hasCredentials(for: requestedModel) { return true } let providerName = self.providerDisplayName(for: requestedModel) let envVar = self.providerEnvironmentVariable(for: requestedModel) self.printAgentExecutionError( "Missing API key for \(providerName). Set \(envVar) and retry." ) return false } let hasCredential = await peekabooAgent.maskedApiKey != nil if !hasCredential { self.emitAgentUnavailableMessage() } return hasCredential } private func handleSessionResumption( _ agentService: PeekabooAgentService, requestedModel: LanguageModel? ) async throws -> Bool { if let sessionId = self.resumeSession { guard let continuationTask = self.task else { self.printMissingTaskError( message: "Task argument required when resuming session", usage: "Usage: peekaboo agent --resume-session <session-id> \"<continuation-task>\"" ) return true } try await self.resumeAgentSession( agentService, sessionId: sessionId, task: continuationTask, requestedModel: requestedModel ) return true } if self.resume { guard let continuationTask = self.task else { self.printMissingTaskError( message: "Task argument required when resuming", usage: "Usage: peekaboo agent --resume \"<continuation-task>\"" ) return true } let sessions = try await agentService.listSessions() if let mostRecent = sessions.first { try await self.resumeAgentSession( agentService, sessionId: mostRecent.id, task: continuationTask, requestedModel: requestedModel ) } else { if self.jsonOutput { let error = ["success": false, "error": "No sessions found to resume"] as [String: Any] let jsonData = try JSONSerialization.data(withJSONObject: error, options: .prettyPrinted) print(String(data: jsonData, encoding: .utf8) ?? "{}") } else { print("\(TerminalColor.red)Error: No sessions found to resume\(TerminalColor.reset)") } } return true } return false } func printMissingTaskError(message: String, usage: String) { if self.jsonOutput { let error = ["success": false, "error": message] as [String: Any] if let jsonData = try? JSONSerialization.data(withJSONObject: error, options: .prettyPrinted), let jsonString = String(data: jsonData, encoding: .utf8) { print(jsonString) } else { print("{\"success\":false,\"error\":\"\(message)\"}") } } else { print("\(TerminalColor.red)Error: \(message)\(TerminalColor.reset)") if !usage.isEmpty { print(usage) } } } /// Render the agent execution result using either JSON output or a rich CLI transcript. @MainActor func displayResult(_ result: AgentExecutionResult, delegate: AgentOutputDelegate? = nil) { if self.jsonOutput { let response = [ "success": true, "result": [ "content": result.content, "sessionId": result.sessionId as Any, "toolCalls": result.messages.flatMap { message in message.content.compactMap { content in if case let .toolCall(toolCall) = content { return [ "id": toolCall.id, "name": toolCall.name, "arguments": String(describing: toolCall.arguments) ] } return nil } }, "metadata": [ "executionTime": result.metadata.executionTime, "toolCallCount": result.metadata.toolCallCount, "modelName": result.metadata.modelName ], "usage": result.usage.map { usage in [ "inputTokens": usage.inputTokens, "outputTokens": usage.outputTokens, "totalTokens": usage.totalTokens ] } as Any ] ] as [String: Any] if let jsonData = try? JSONSerialization.data(withJSONObject: response, options: .prettyPrinted) { print(String(data: jsonData, encoding: .utf8) ?? "{}") } } else if self.outputMode == .quiet { // Quiet mode - only show final result print(result.content) } delegate?.showFinalSummaryIfNeeded(result) } // MARK: - Session Management @MainActor func showSessions(_ agentService: any AgentServiceProtocol) async throws { guard let peekabooService = agentService as? PeekabooAgentService else { throw PeekabooError.commandFailed("Agent service not properly initialized") } let sessionSummaries = try await peekabooService.listSessions() let sessions = sessionSummaries.map { summary in AgentSessionInfo( id: summary.id, task: summary.summary ?? "Unknown task", created: summary.createdAt, lastModified: summary.lastAccessedAt, messageCount: summary.messageCount ) } guard !sessions.isEmpty else { self.printNoAgentSessions() return } if self.jsonOutput { self.printSessionsJSON(sessions) } else { self.printSessionsList(sessions) } } private func printNoAgentSessions() { if self.jsonOutput { let response = ["success": true, "sessions": []] as [String: Any] let jsonData = try? JSONSerialization.data(withJSONObject: response, options: .prettyPrinted) print(String(data: jsonData ?? Data(), encoding: .utf8) ?? "{}") } else { print("No agent sessions found.") } } private func printSessionsJSON(_ sessions: [AgentSessionInfo]) { let sessionData = sessions.map { session in [ "id": session.id, "createdAt": ISO8601DateFormatter().string(from: session.created), "updatedAt": ISO8601DateFormatter().string(from: session.lastModified), "messageCount": session.messageCount ] } let response = ["success": true, "sessions": sessionData] as [String: Any] if let jsonData = try? JSONSerialization.data(withJSONObject: response, options: .prettyPrinted) { print(String(data: jsonData, encoding: .utf8) ?? "{}") } } private func printSessionsList(_ sessions: [AgentSessionInfo]) { let headerLine = [ "\(TerminalColor.cyan)\(TerminalColor.bold)Agent Sessions:\(TerminalColor.reset)", "\n" ].joined() print(headerLine) let dateFormatter = DateFormatter() dateFormatter.dateStyle = .short dateFormatter.timeStyle = .short for (index, session) in sessions.prefix(10).indexed() { self.printSessionLine(index: index, session: session, dateFormatter: dateFormatter) if index < sessions.count - 1 { print() } } if sessions.count > 10 { print([ "\n", "\(TerminalColor.dim)... and \(sessions.count - 10) more sessions\(TerminalColor.reset)" ].joined()) } let resumeHintLine = [ "\n", "\(TerminalColor.dim)To resume: peekaboo agent --resume <session-id>", " \"<continuation>\"\(TerminalColor.reset)" ].joined() print(resumeHintLine) } private func printSessionLine(index: Int, session: AgentSessionInfo, dateFormatter: DateFormatter) { let timeAgo = formatTimeAgo(session.lastModified) let sessionLine = [ "\(TerminalColor.blue)\(index + 1).\(TerminalColor.reset)", " ", "\(TerminalColor.bold)\(session.id.prefix(8))\(TerminalColor.reset)" ].joined() print(sessionLine) print(" Messages: \(session.messageCount)") print(" Last activity: \(timeAgo)") } func resumeAgentSession( _ agentService: PeekabooAgentService, sessionId: String, task: String, requestedModel: LanguageModel? ) async throws { if !self.jsonOutput { let resumingLine = [ "\(TerminalColor.cyan)\(TerminalColor.bold)", "\(AgentDisplayTokens.Status.info)", " Resuming session \(sessionId.prefix(8))...", "\(TerminalColor.reset)", "\n" ].joined() print(resumingLine) } let outputDelegate = self.makeDisplayDelegate(for: task) let streamingDelegate = self.makeStreamingDelegate(using: outputDelegate) do { let result = try await agentService.resumeSession( sessionId: sessionId, model: requestedModel, eventDelegate: streamingDelegate ) self.displayResult(result, delegate: outputDelegate) } catch { self.printAgentExecutionError("Failed to resume session: \(error.localizedDescription)") throw error } } func makeDisplayDelegate(for task: String) -> AgentOutputDelegate? { guard !self.jsonOutput, !self.quiet else { return nil } return AgentOutputDelegate(outputMode: self.outputMode, jsonOutput: self.jsonOutput, task: task) } func makeStreamingDelegate(using displayDelegate: AgentOutputDelegate?) -> (any AgentEventDelegate)? { if let displayDelegate { return displayDelegate } if self.jsonOutput || self.quiet { return SilentAgentEventDelegate() } return nil } final class SilentAgentEventDelegate: AgentEventDelegate { func agentDidEmitEvent(_ event: AgentEvent) {} } func printAgentExecutionError(_ message: String) { if self.jsonOutput { let error: [String: Any] = ["success": false, "error": message] if let jsonData = try? JSONSerialization.data(withJSONObject: error, options: .prettyPrinted), let jsonString = String(data: jsonData, encoding: .utf8) { print(jsonString) } else { print("{\"success\":false,\"error\":\"\(message)\"}") } } else { print("\(TerminalColor.red)Error: \(message)\(TerminalColor.reset)") } } func executeAgentTask( _ agentService: PeekabooAgentService, task: String, requestedModel: LanguageModel?, maxSteps: Int, queueMode: QueueMode ) async throws -> AgentExecutionResult { let outputDelegate = self.makeDisplayDelegate(for: task) let streamingDelegate = self.makeStreamingDelegate(using: outputDelegate) do { let result = try await agentService.executeTask( task, maxSteps: maxSteps, sessionId: nil, model: requestedModel, dryRun: self.dryRun, queueMode: queueMode, eventDelegate: streamingDelegate, verbose: self.verbose ) self.displayResult(result, delegate: outputDelegate) let duration = String(format: "%.2f", result.metadata.executionTime) let sessionId = result.sessionId ?? "none" let finalTokens = result.usage?.totalTokens ?? 0 let status = result.metadata.context["status"] ?? "completed" AutomationEventLogger.log( .agent, "result status=\(status) task='\(task)' model=\(result.metadata.modelName) duration=\(duration)s " + "tools=\(result.metadata.toolCallCount) dry_run=\(self.dryRun) " + "session=\(sessionId) tokens=\(finalTokens)" ) return result } catch { self.printAgentExecutionError("Agent execution failed: \(error.localizedDescription)") throw error } } private var normalizedTaskInput: String? { guard let task else { return nil } let trimmed = task.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } private var hasTaskInput: Bool { self.normalizedTaskInput != nil || self.audio || self.audioFile != nil } var resolvedMaxSteps: Int { self.maxSteps ?? 100 } private func resolvedQueueMode() throws -> QueueMode { guard let raw = self.queueMode?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return .oneAtATime } switch raw.lowercased() { case "one", "one-at-a-time", "single", "sequential", "1": return .oneAtATime case "all", "batch", "together": return .all default: throw PeekabooError.invalidInput("Invalid queue mode '\(raw)'. Use one-at-a-time or all.") } } func printChatWelcome(sessionId: String?, modelDescription: String, queueMode: QueueMode) { guard !self.quiet else { return } let header = [ TerminalColor.cyan, TerminalColor.bold, "Interactive agent chat", TerminalColor.reset, " – model: ", modelDescription, " • queue: ", queueMode == .all ? "all" : "one-at-a-time" ].joined() print(header) if let sessionId { print("\(TerminalColor.dim)Resuming session \(sessionId.prefix(8))\(TerminalColor.reset)") } else { print("\(TerminalColor.dim)A new session will be created on the first prompt\(TerminalColor.reset)") } print() } func printChatHelpIntro() { guard !self.quiet else { return } print("Type /help for chat commands (Ctrl+C to exit).") self.printChatHelpMenu() } func printChatHelpMenu() { guard !self.quiet else { return } self.chatHelpLines.forEach { print($0) } } private var chatHelpText: String { """ Chat commands: • Type any prompt and press Return to run it. • /help Show this menu again. • Esc Cancel the active run (if one is in progress). • Ctrl+C Cancel when running; exit immediately when idle. • Ctrl+D Exit when idle (EOF). """ } var chatHelpLines: [String] { self.chatHelpText .split(separator: "\n", omittingEmptySubsequences: false) .map(String.init) } private func printCapabilityFlag(_ label: String, supported: Bool, detail: String? = nil) { let status = supported ? AgentDisplayTokens.Status.success : AgentDisplayTokens.Status.failure let detailSuffix = detail.map { " (\($0))" } ?? "" print(" • \(label): \(status)\(detailSuffix)") } /// Print detailed terminal detection debugging information func printTerminalDetectionDebug(_ capabilities: TerminalCapabilities, actualMode: OutputMode) { // Print detailed terminal detection debugging information print("\n" + String(repeating: "=", count: 60)) print("\(TerminalColor.bold)\(TerminalColor.cyan)TERMINAL DETECTION DEBUG (-vv)\(TerminalColor.reset)") print(String(repeating: "=", count: 60)) // Basic terminal info print("[term] \(TerminalColor.bold)Terminal Type:\(TerminalColor.reset) \(capabilities.termType ?? "unknown")") print( "[size] \(TerminalColor.bold)Dimensions:\(TerminalColor.reset) \(capabilities.width)x\(capabilities.height)" ) // Capability flags print("\(AgentDisplayTokens.Status.running) \(TerminalColor.bold)Capabilities:\(TerminalColor.reset)") self.printCapabilityFlag("Interactive", supported: capabilities.isInteractive, detail: "isatty check") self.printCapabilityFlag("Colors", supported: capabilities.supportsColors, detail: "ANSI support") self.printCapabilityFlag("True Color", supported: capabilities.supportsTrueColor, detail: "24-bit") print(" • Dimensions: \(capabilities.width)x\(capabilities.height)") // Environment info print("[env] \(TerminalColor.bold)Environment:\(TerminalColor.reset)") self.printCapabilityFlag("CI Environment", supported: capabilities.isCI) self.printCapabilityFlag("Piped Output", supported: capabilities.isPiped) // Environment variables let env = ProcessInfo.processInfo.environment print("\(AgentDisplayTokens.Status.running) \(TerminalColor.bold)Environment Variables:\(TerminalColor.reset)") print(" • TERM: \(env["TERM"] ?? "not set")") print(" • COLORTERM: \(env["COLORTERM"] ?? "not set")") print(" • NO_COLOR: \(env["NO_COLOR"] != nil ? "set" : "not set")") print(" • FORCE_COLOR: \(env["FORCE_COLOR"] ?? "not set")") print(" • PEEKABOO_OUTPUT_MODE: \(env["PEEKABOO_OUTPUT_MODE"] ?? "not set")") // Recommended vs actual mode let recommendedMode = capabilities.recommendedOutputMode print("[focus] \(TerminalColor.bold)Recommended Mode:\(TerminalColor.reset) \(recommendedMode.description)") print("[focus] \(TerminalColor.bold)Actual Mode:\(TerminalColor.reset) \(actualMode.description)") if recommendedMode != actualMode { let modeOverrideLine = [ "\(AgentDisplayTokens.Status.warning) ", "\(TerminalColor.yellow)Mode Override Detected\(TerminalColor.reset)", " - explicit flag or environment variable used" ].joined() print(modeOverrideLine) } // Show decision logic if !capabilities.isInteractive || capabilities.isCI || capabilities.isPiped { print(" → Minimal mode (non-interactive/CI/piped)") } else if capabilities.supportsColors { print(" → Enhanced mode (colors available)") } else { print(" → Compact mode (basic terminal)") } print(String(repeating: "=", count: 60) + "\n") } private func hasConfiguredAIProvider(configuration: PeekabooCore.ConfigurationManager) -> Bool { let hasOpenAI = configuration.getOpenAIAPIKey()?.isEmpty == false let hasAnthropic = configuration.getAnthropicAPIKey()?.isEmpty == false return hasOpenAI || hasAnthropic } private func emitAgentUnavailableMessage() { if self.jsonOutput { let error = [ "success": false, "error": "Agent service not available. Please set OPENAI_API_KEY environment variable." ] as [String: Any] if let jsonData = try? JSONSerialization.data(withJSONObject: error, options: .prettyPrinted), let jsonString = String(data: jsonData, encoding: .utf8) { print(jsonString) } else { print("{\"success\":false,\"error\":\"Agent service not available\"}") } } else { let errorPrefix = [ "\(TerminalColor.red)Error: Agent service not available.", " Please set OPENAI_API_KEY environment variable." ].joined() let errorMessageLine = [errorPrefix, "\(TerminalColor.reset)"].joined() print(errorMessageLine) } } // MARK: - Model Parsing func parseModelString(_ modelString: String) -> LanguageModel? { let trimmed = modelString.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } guard let parsed = LanguageModel.parse(from: trimmed) else { return nil } switch parsed { case let .openai(model): if Self.supportedOpenAIInputs.contains(model) { return .openai(.gpt51) } case let .anthropic(model): if Self.supportedAnthropicInputs.contains(model) { return .anthropic(.sonnet45) } default: break } return nil } func validatedModelSelection() throws -> LanguageModel? { guard let modelString = self.model else { return nil } guard let parsed = self.parseModelString(modelString) else { throw PeekabooError.invalidInput( "Unsupported model '\(modelString)'. Allowed values: \(Self.allowedModelList)" ) } return parsed } private static let supportedOpenAIInputs: Set<LanguageModel.OpenAI> = [ .gpt51, .gpt51Mini, .gpt51Nano, .gpt5, .gpt5Pro, .gpt5Mini, .gpt5Nano, .gpt5Thinking, .gpt5ThinkingMini, .gpt5ThinkingNano, .gpt5ChatLatest, .gpt4o, .gpt4oMini, .gpt4oRealtime, .o4Mini, ] private static let supportedAnthropicInputs: Set<LanguageModel.Anthropic> = [ .sonnet45, .sonnet4, .sonnet4Thinking, .opus4, .opus4Thinking, ] private static var allowedModelList: String { let openAIModels = Self.supportedOpenAIInputs.map(\.modelId) let anthropicModels = Self.supportedAnthropicInputs.map(\.modelId) return (openAIModels + anthropicModels).sorted().joined(separator: ", ") } @MainActor private func hasCredentials(for model: LanguageModel) -> Bool { let configuration = self.services.configuration switch model { case .openai: return configuration.getOpenAIAPIKey()?.isEmpty == false case .anthropic: return configuration.getAnthropicAPIKey()?.isEmpty == false default: return false } } private func providerDisplayName(for model: LanguageModel) -> String { switch model { case .openai: "OpenAI" case .anthropic: "Anthropic" default: "the selected provider" } } private func providerEnvironmentVariable(for model: LanguageModel) -> String { switch model { case .openai: "OPENAI_API_KEY" case .anthropic: "ANTHROPIC_API_KEY" default: "provider API key" } } } extension AgentCommand: ParsableCommand {} extension AgentCommand: AsyncRuntimeCommand {}

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/steipete/Peekaboo'

If you have feedback or need assistance with the MCP directory API, please join our Discord server