// // AgentCLI.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Headless command-line modes: // // --run "" [--model ] [--mode manual|guarded|autonomous] // [--workspace ] [--max-steps N] [--yes] [--allow-destructive] // The Phase 3 agent POC: runs a real agent task end-to-end with live // rendering, a stdin approval presenter, and a transcript summary. // API keys come from the environment (ANTHROPIC_API_KEY, …) or the // encrypted vault. // // --run-mock [""] // Hidden CI smoke test: same engine, scripted MockProviderClient, // scratch workspace, no network/keys. // // --verify [--provider ] [--model ] // Phase 7.1: live provider tool-calling verification over every // agent-capable model (see Verify/VerifyHarness.swift). Writes // docs/VERIFICATION.md. // // --load-vault Imports environment API keys into the encrypted vault. // --verify-policy PolicyEngine safety self-check (Phase 3.C). // // Hidden scripted-eval flag (Phase 7.2): --compact-threshold <0..1> // overrides the memory compaction threshold AND relaxes the compaction // thrash guard (keepRecentSteps→2, minStepsBetweenCompactions→3) so an // evaluation scenario can force a compaction inside a small step budget. // // `--yes` auto-approves mode-driven approvals for scripted runs, but NEVER // silently approves the always-ask class (destructive/elevated actions, // file access outside the workspace): those are auto-DENIED with a message // unless `--allow-destructive` is also passed. Guard trips auto-stop under // `--yes` (a scripted run must not raise its own budgets forever). // import Foundation enum AgentCLI { // MARK: - Entry static func run(arguments: [String]) async -> Int32 { if arguments.contains("--verify-policy") { let allPassed = await PolicyEngineSelfCheck.run() return allPassed ? 0 : 1 } if arguments.contains("--verify") { return await VerifyHarness.run(arguments: arguments) } if arguments.contains("--run") || arguments.contains("--run-mock") { switch parseRunOptions(arguments) { case .failure(let error): FileHandle.standardError.write(Data((error.message + "\n" + usage + "\n").utf8)) return 64 case .success(let options): return await runAgent(options: options) } } FileHandle.standardError.write(Data((usage + "\n").utf8)) return 64 } /// `--load-vault`: seeds the encrypted vault from environment variables. /// Prints stored/skipped per provider; NEVER prints any part of a key. static func loadVault() { let store = SecureKeyStore() let environment = ProcessInfo.processInfo.environment print("Zyquo Agent — importing API keys from the environment into the vault") for provider in ProviderID.builtIn { let names = environmentKeyNames(for: provider) guard let name = names.first(where: { !(environment[$0] ?? "").isEmpty }), let key = environment[name] else { print(" skipped \(provider.rawValue) — no \(names.joined(separator: "/")) set") continue } do { try store.setKey(key, for: provider) print(" stored \(provider.rawValue) (from \(name))") } catch { print(" FAILED \(provider.rawValue) — \(error.localizedDescription)") } } print("Vault: \(store.vaultURL.path)") } private static let usage = """ Usage: ZyquoAgent --run "" [--model ] [--mode manual|guarded|autonomous] [--workspace ] [--max-steps N] [--yes] [--allow-destructive] """ // MARK: - Options private struct CLIUsageError: Error { let message: String } private struct RunOptions { var task: String var modelSpec: String? var mode: SafetyMode = .guarded var workspacePath: String? var maxSteps: Int? var autoApprove = false var allowDestructive = false var mock = false /// Hidden eval flag — see the header comment. var compactionThreshold: Double? } private static func parseRunOptions(_ arguments: [String]) -> Result { func fail(_ message: String) -> Result { .failure(CLIUsageError(message: message)) } var options = RunOptions(task: "") var index = 1 var sawRunFlag = false let args = arguments while index < args.count { let arg = args[index] func value(for flag: String) -> String? { guard index + 1 < args.count else { return nil } index += 1 return args[index] } switch arg { case "--run": sawRunFlag = true guard let task = value(for: arg), !task.hasPrefix("--"), !task.isEmpty else { return fail("--run requires a task string.") } options.task = task case "--run-mock": sawRunFlag = true options.mock = true options.autoApprove = true // scripted, non-interactive by design if index + 1 < args.count, !args[index + 1].hasPrefix("--") { index += 1 options.task = args[index] } if options.task.isEmpty { options.task = "Create a demo folder in the workspace with a shell command and verify it exists." } case "--model": guard let spec = value(for: arg) else { return fail("--model requires a model id.") } options.modelSpec = spec case "--mode": guard let raw = value(for: arg), let mode = SafetyMode(rawValue: raw) else { return fail("--mode must be manual, guarded, or autonomous.") } options.mode = mode case "--workspace": guard let path = value(for: arg) else { return fail("--workspace requires a path.") } options.workspacePath = path case "--max-steps": guard let raw = value(for: arg), let steps = Int(raw), steps > 0 else { return fail("--max-steps requires a positive integer.") } options.maxSteps = steps case "--compact-threshold": guard let raw = value(for: arg), let threshold = Double(raw), threshold > 0, threshold < 1 else { return fail("--compact-threshold requires a fraction strictly between 0 and 1.") } options.compactionThreshold = threshold case "--yes": options.autoApprove = true case "--allow-destructive": options.allowDestructive = true default: break // tolerate unrelated flags (e.g. process serial numbers) } index += 1 } guard sawRunFlag, !options.task.isEmpty else { return fail("No task given.") } return .success(options) } // MARK: - Run orchestration private static func runAgent(options: RunOptions) async -> Int32 { let ansi = Ansi() // ---- Model + client + key ----------------------------------------- let model: AIModel let client: any ProviderClient let apiKey: String if options.mock { model = MockProviderClient.model client = MockProviderClient() apiKey = "mock" } else { guard let resolved = resolveModel(spec: options.modelSpec) else { FileHandle.standardError.write(Data("No model matches “\(options.modelSpec ?? "")”. Use --model or / from the shared catalog.\n".utf8)) return 64 } model = resolved guard model.agentCapable else { FileHandle.standardError.write(Data("\(model.displayName) is not in the agent-capable subset (docs/PROVIDER-REUSE.md §3) — it cannot run agent tasks reliably.\n".utf8)) return 64 } client = ProviderRegistry.client(for: model) guard let key = resolveAPIKey(for: model.provider) else { let names = environmentKeyNames(for: model.provider).joined(separator: " or ") FileHandle.standardError.write(Data("No API key for \(model.provider.displayName). Set \(names), or store one in the vault.\n".utf8)) return 64 } apiKey = key } // ---- Workspace ----------------------------------------------------- let workspace: WorkspaceManager do { if let path = options.workspacePath { let url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath).standardizedFileURL try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) workspace = try WorkspaceManager(existingAt: url) } else if options.mock { workspace = try WorkspaceManager.scratch(label: "mockrun") } else { workspace = try WorkspaceManager(taskTitle: options.task) } } catch { FileHandle.standardError.write(Data("Could not prepare the workspace: \(error.localizedDescription)\n".utf8)) return 1 } // ---- Engine assembly ------------------------------------------------ var configuration = AgentConfiguration.default if let maxSteps = options.maxSteps { configuration.loopGuard.maxSteps = maxSteps } if let threshold = options.compactionThreshold { // Scripted-eval override: force compaction within a small run. configuration.memory.compactionThreshold = threshold configuration.memory.keepRecentSteps = min(configuration.memory.keepRecentSteps, 2) configuration.memory.minStepsBetweenCompactions = min(configuration.memory.minStepsBetweenCompactions, 3) } var executionConfiguration = ExecutionConfiguration.default if let timeout = configuration.perCommandTimeout { executionConfiguration.defaultTimeout = timeout } let executor = ExecutionService(configuration: executionConfiguration) let audit = AuditLog(fileURL: workspace.internalDirectory.appendingPathComponent("audit.jsonl")) let approvals = CLIApprovalPresenter( autoApprove: options.autoApprove, allowDestructive: options.allowDestructive, ansi: ansi ) // Mock runs use temp-rooted persistence so remembered rules never // touch the user's real policy-rules.json. let persistence: PersistenceService = options.mock ? PersistenceService(rootDirectory: FileManager.default.temporaryDirectory .appendingPathComponent("ZyquoAgent-mockrun-\(UUID().uuidString.prefix(8))")) : .shared let policy = PolicyEngine(mode: options.mode, approvals: approvals, persistence: persistence) let tools = ToolRegistry.standard(executor: executor) let loop = AgentLoop( model: model, client: client, apiKey: apiKey, tools: tools, policy: policy, audit: audit, workspace: workspace, configuration: configuration ) // ---- Graceful Ctrl-C / kill ------------------------------------------ // SIGINT/SIGTERM cancel the run through AgentLoop.cancel(): the loop // kills any in-flight child process (SIGTERM→SIGKILL) and the // transcript records a `cancelled` outcome — the CLI must never die // abruptly and orphan a running command. signal(SIGINT, SIG_IGN) signal(SIGTERM, SIG_IGN) let signalSources: [DispatchSourceSignal] = [SIGINT, SIGTERM].map { number in let source = DispatchSource.makeSignalSource(signal: number, queue: .global()) source.setEventHandler { Task { await loop.cancel() } } source.resume() return source } defer { signalSources.forEach { $0.cancel() } } // ---- Banner ---------------------------------------------------------- print(ansi.bold("Zyquo Agent") + " — headless run") print(" task: \(options.task)") print(" model: \(model.displayName) (\(model.provider.displayName))") print(" mode: \(options.mode.displayName)\(options.autoApprove ? " [--yes]" : "")") print(" workspace: \(workspace.root.path)") // ---- Consume the event stream --------------------------------------- let renderer = CLIRenderer(ansi: ansi) var finalOutcome: AgentRunOutcome? do { for try await event in await loop.run(task: options.task) { renderer.render(event) switch event { case .guardTripped: if options.autoApprove { print(ansi.yellow(" Guard trip auto-answered with STOP (--yes runs never raise their own budgets).")) await loop.stop() } else { print(ansi.yellow(" [c] continue with raised budget / [s] stop > "), terminator: "") fflush(stdout) let answer = readLine()?.lowercased() ?? "s" if answer.hasPrefix("c") { await loop.resume(raisingBudget: true) } else { await loop.stop() } } case .runFinished(let outcome): finalOutcome = outcome default: break } } } catch { print(ansi.red("\nRun stream failed: \(error.localizedDescription)")) return 1 } renderer.printSummary(workspacePath: workspace.root.path) if case .completed = finalOutcome { return 0 } return 1 } // MARK: - Model & key resolution /// Accepts a bare model id or "provider/id" ("anthropic/claude-sonnet-5"). /// Bare ids that exist under several providers prefer the agent-capable, /// then recommended entry. private static func resolveModel(spec: String?) -> AIModel? { let catalog = ModelCatalogData.all guard let spec, !spec.isEmpty else { return catalog.first { $0.provider == AgentModelSupport.defaultModelProvider && $0.id == AgentModelSupport.defaultModelID } ?? catalog.first(where: \.agentCapable) } if let slash = spec.firstIndex(of: "/"), let provider = ProviderID(rawValue: String(spec[spec.startIndex.. [String] { switch provider { case .openai: return ["OPENAI_API_KEY"] case .anthropic: return ["ANTHROPIC_API_KEY"] case .xai: return ["XAI_API_KEY"] case .mistral: return ["MISTRAL_API_KEY"] case .gemini: return ["GEMINI_API_KEY", "GOOGLE_API_KEY"] case .qwen: return ["QWEN_API_KEY", "DASHSCOPE_API_KEY"] case .deepseek: return ["DEEPSEEK_API_KEY"] case .kimi: return ["KIMI_API_KEY", "MOONSHOT_API_KEY"] case .perplexity: return ["PERPLEXITY_API_KEY"] case .together: return ["TOGETHER_API_KEY"] case .deepinfra: return ["DEEPINFRA_API_KEY"] case .cerebras: return ["CEREBRAS_API_KEY"] case .custom: return ["ZYQUO_CUSTOM_API_KEY"] } } static func resolveAPIKey(for provider: ProviderID) -> String? { let environment = ProcessInfo.processInfo.environment for name in environmentKeyNames(for: provider) { if let value = environment[name], !value.isEmpty { return value } } return ((try? SecureKeyStore().key(for: provider)) ?? nil) } } // MARK: - Approval presenter (stdin) /// Prints the approval card and reads the decision from stdin. With /// `autoApprove` (--yes): mode-driven asks are approved, but the always-ask /// class (destructive/elevated risk, any file access outside the workspace) /// is auto-DENIED unless `allowDestructive` (--allow-destructive) is set — /// a scripted run must never silently authorize a destructive action. struct CLIApprovalPresenter: ApprovalPresenting { let autoApprove: Bool let allowDestructive: Bool let ansi: Ansi func requestApproval(for action: ActionRequest, risk: RiskAssessment) async -> ApprovalResolution { printCard(for: action, risk: risk) if autoApprove { let alwaysAskClass = risk.level == .destructive || risk.level == .elevated || action.kind == .fileWriteOutsideWorkspace || action.kind == .fileReadOutsideWorkspace if alwaysAskClass && !allowDestructive { print(ansi.red(" ✗ auto-DENIED under --yes: \(risk.reason) (pass --allow-destructive to permit)")) return .deny } print(ansi.green(" ✓ auto-approved (--yes)")) return .approve } while true { print(ansi.bold(" [a]pprove / [e]dit / [d]eny > "), terminator: "") fflush(stdout) guard let answer = readLine()?.lowercased() else { return .deny } if answer.hasPrefix("a") { return .approve } if answer.hasPrefix("d") { return .deny } if answer.hasPrefix("e") { print(" edited payload > ", terminator: "") fflush(stdout) guard let edited = readLine(), !edited.trimmingCharacters(in: .whitespaces).isEmpty else { print(ansi.red(" empty edit — denied.")) return .deny } return .approveEdited(edited) } } } private func printCard(for action: ActionRequest, risk: RiskAssessment) { let kind: String switch action.kind { case .shellCommand: kind = "shell command" case .appleScript: kind = "AppleScript" case .fileWrite: kind = "file write (workspace)" case .fileWriteOutsideWorkspace: kind = "file write OUTSIDE the workspace" case .fileReadOutsideWorkspace: kind = "file read OUTSIDE the workspace" } print("") print(ansi.yellow(" ┌─ APPROVAL REQUIRED ─────────────────────────────")) print(ansi.yellow(" │ ") + "kind: \(kind)") print(ansi.yellow(" │ ") + "risk: \(risk.level.rawValue) — \(risk.reason)") print(ansi.yellow(" │ ") + "cwd: \(action.cwd.path)") for line in action.payload.split(separator: "\n", omittingEmptySubsequences: false) { print(ansi.yellow(" │ ") + ansi.bold(" \(line)")) } if let explanation = action.explanation, !explanation.isEmpty { print(ansi.yellow(" │ ") + "why: \(explanation)") } print(ansi.yellow(" └──────────────────────────────────────────────────")) } } // MARK: - Live renderer /// Renders the AgentEvent stream to the terminal: step headers, streamed /// text, dimmed thinking, tool chips with payloads, live stdout/stderr, /// plan checklists, compactions, and the highlighted final answer. final class CLIRenderer { private let ansi: Ansi private let startedAt = Date() private var stepsCompleted = 0 private var totalInputTokens = 0 private var totalOutputTokens = 0 private var midLine = false init(ansi: Ansi) { self.ansi = ansi } func render(_ event: AgentEvent) { switch event { case .statusChanged(let status): if status == .compacting { breakLine() print(ansi.dim(" ⟳ compacting context…")) } case .stepStarted(let step): breakLine() print("\n" + ansi.bold("── Step \(step.index) ") + ansi.dim(String(repeating: "─", count: 40))) case .thinkingDelta(_, let delta): print(ansi.dim(delta), terminator: "") midLine = true fflush(stdout) case .textDelta(_, let delta): print(delta, terminator: "") midLine = true fflush(stdout) case .toolCallStreaming(_, _, _, let name): breakLine() print(ansi.violet(" ⚙ \(name) "), terminator: "") midLine = true fflush(stdout) case .toolCallArgumentsDelta(_, _, let delta): print(ansi.dim(delta), terminator: "") midLine = true fflush(stdout) case .toolCallStarted(_, let invocation): breakLine() print(ansi.violet(" ▶ \(invocation.call.name)") + ansi.dim(" \(truncate(invocation.call.argumentsJSON, to: 200))")) case .toolOutput(_, _, let chunk): breakLine() switch chunk { case .stdout(let line): print(" │ \(line)") case .stderr(let line): print(ansi.red(" │ \(line)")) case .note(let line): print(ansi.dim(" · \(line)")) } case .toolCallFinished(_, let invocation): breakLine() if let result = invocation.result { if result.isError { print(ansi.red(" ✘ \(invocation.call.name) failed: \(truncate(firstLine(of: result.content), to: 160))")) } else { print(ansi.green(" ✔ \(invocation.call.name)") + ansi.dim(" \(truncate(firstLine(of: result.content), to: 120))")) } } case .stepCompleted(let step): breakLine() stepsCompleted = max(stepsCompleted, step.index) totalInputTokens += step.inputTokens ?? 0 totalOutputTokens += step.outputTokens ?? 0 case .planUpdated(let plan): breakLine() print(ansi.bold(" Plan (\(plan.doneCount)/\(plan.items.count) done):")) for line in plan.rendered().split(separator: "\n") { print(" \(line)") } case .guardTripped(let trip): breakLine() print(ansi.yellow("\n ⏸ LOOP GUARD [\(trip.reason.rawValue)] at step \(trip.stepIndex): \(trip.message)")) case .compactionPerformed(let record): breakLine() print(ansi.dim(" ⟳ compacted \(record.summarizedSteps) step(s) through step \(record.throughStep): ~\(record.beforeTokens) → ~\(record.afterTokens) tokens")) case .runFinished(let outcome): breakLine() switch outcome { case .completed(let finalAnswer): print("\n" + ansi.green(ansi.bold("✔ Task complete"))) let answer = finalAnswer.trimmingCharacters(in: .whitespacesAndNewlines) if !answer.isEmpty { for line in answer.split(separator: "\n", omittingEmptySubsequences: false) { print(" \(line)") } } case .failed(let reason): print("\n" + ansi.red(ansi.bold("✘ Task failed")) + " — \(reason)") case .cancelled: print("\n" + ansi.yellow(ansi.bold("■ Task cancelled"))) case .stoppedByUser(let reason): print("\n" + ansi.yellow(ansi.bold("■ Task stopped")) + " — \(reason)") } } } func printSummary(workspacePath: String) { let seconds = Date().timeIntervalSince(startedAt) print(ansi.dim("\n steps: \(stepsCompleted) tokens: \(totalInputTokens) in / \(totalOutputTokens) out duration: \(String(format: "%.1f", seconds))s")) print(ansi.dim(" workspace: \(workspacePath)")) print(ansi.dim(" transcript: \(workspacePath)/.zyquo/transcript.json")) } private func breakLine() { if midLine { print("") midLine = false } } private func truncate(_ text: String, to limit: Int) -> String { text.count > limit ? String(text.prefix(limit)) + "…" : text } private func firstLine(of text: String) -> String { text.split(separator: "\n", omittingEmptySubsequences: true).first.map(String.init) ?? text } } // MARK: - ANSI colors /// Minimal ANSI styling, disabled when stdout is not a TTY or NO_COLOR is set. struct Ansi: Sendable { let enabled: Bool init() { self.enabled = isatty(STDOUT_FILENO) == 1 && ProcessInfo.processInfo.environment["NO_COLOR"] == nil } private func wrap(_ text: String, _ code: String) -> String { enabled ? "\u{1B}[\(code)m\(text)\u{1B}[0m" : text } func bold(_ text: String) -> String { wrap(text, "1") } func dim(_ text: String) -> String { wrap(text, "2") } func red(_ text: String) -> String { wrap(text, "31") } func green(_ text: String) -> String { wrap(text, "32") } func yellow(_ text: String) -> String { wrap(text, "33") } func violet(_ text: String) -> String { wrap(text, "35") } }