import Foundation /// Turns a non-git shell command an agent ran — `grep`, `cat`, `find`, `ls`, `sed`, `npm`, … — /// into a short, human phrase + glyph for the Control panel's command-activity feed and the /// structured log. This is the non-git counterpart to ``GitCommandSummary``: where that one /// models git subcommands, this models the ordinary commands agents lean on in Bash /// (file inspection, search, text munging, build/run), as reported by the in-container /// command-interceptor shim and the bash command tracer. /// /// Unlike `GitCommandSummary.classify` (which returns nil for anything it doesn't recognize), /// this ALWAYS yields an ``ObservedCommand`` for a non-empty argv: every command the shim/tracer /// bothered to report is worth a feed row, so an unrecognized command falls back to a generic /// "Run " entry rather than being dropped. The recognized cases get a friendlier label, /// a category, and a fitting SF Symbol. public enum CommandSummary { /// A command observed with certainty from a raw argv (or tokenized command line). Carries a /// glanceable label, a coarse `category` (for grouping/coloring), an SF Symbol for the feed /// glyph, and whether the op mutates the filesystem (so a consumer can flag writes/deletes). public struct ObservedCommand: Equatable, Sendable { /// The resolved command name (argv[0]'s basename), e.g. `grep`, `cat`, `npm`. public let command: String /// A coarse bucket: `read`, `search`, `list`, `text`, `fs`, `delete`, `build`, `run`, /// `network`, `archive`, `info`, or `other`. public let category: String /// Human one-liner (e.g. `Search for "TODO"`, `Read file.txt`, `List src/`). public let label: String /// SF Symbol name for the op's glyph in the feed. public let symbol: String /// True for commands that write/delete on the filesystem (`rm`, `cp`, `mv`, `mkdir`, …), /// so the UI can emphasize them; false for the read/search/inspect majority. public let isMutating: Bool /// True when the command word matched a case we specifically model (vs. the generic /// "Run " fallback) — lets a consumer treat recognized ops with more confidence. public let recognized: Bool public init( command: String, category: String, label: String, symbol: String, isMutating: Bool, recognized: Bool ) { self.command = command self.category = category self.label = label self.symbol = symbol self.isMutating = isMutating self.recognized = recognized } } /// Classify a tokenized argv (the command word at index 0, then its arguments — exactly what /// the interceptor shim reports). Returns nil only for an empty argv. public static func classify(argv: [String]) -> ObservedCommand? { let stripped = ShellLexer.stripLeadingPrefixes(argv) guard let head = stripped.first, !head.isEmpty else { return nil } let name = basename(head) let args = Array(stripped.dropFirst()) return classify(name: name, args: args) } /// As `classify(argv:)`, but from a raw command line (the form the bash tracer reports). The /// line is tokenized with the shared ``ShellLexer`` so quoting/heredocs match the rest of the /// summarizers; only the first segment's leading command is described. public static func classify(commandLine: String) -> ObservedCommand? { let (flattened, _) = ShellLexer.stripHeredocs(commandLine) guard let segment = ShellLexer.splitSegments(flattened).first else { return nil } return classify(argv: ShellLexer.tokenize(segment)) } // MARK: - Core mapping private static func classify(name: String, args: [String]) -> ObservedCommand { switch name { // ── Reading file contents ──────────────────────────────────────────────────────────── case "cat", "bat", "less", "more", "tac": return read("Read \(fileOperand(args) ?? "input")", name) case "head": return read(fileOperand(args).map { "Show start of \($0)" } ?? "Show file head", name) case "tail": return read(fileOperand(args).map { "Show end of \($0)" } ?? "Show file tail", name) case "od", "xxd", "hexdump", "strings": return read(fileOperand(args).map { "Inspect bytes of \($0)" } ?? "Inspect bytes", name) // ── Searching ──────────────────────────────────────────────────────────────────────── case "grep", "egrep", "fgrep", "rg", "ag", "ack", "ugrep": return op("search", searchLabel(args), "magnifyingglass", name, mutating: false) case "find", "fd", "fdfind": return op("search", findLabel(args), "folder.badge.questionmark", name, mutating: false) case "locate", "mdfind", "which", "whereis", "type", "command": return op("search", "Locate \(firstPositional(args) ?? "a command")", "magnifyingglass", name, mutating: false) // ── Listing ────────────────────────────────────────────────────────────────────────── case "ls", "exa", "eza", "ll", "tree", "lsd": return op("list", listLabel(args), "list.bullet", name, mutating: false) case "pwd", "dirname", "basename", "realpath", "readlink": return op("info", "Resolve a path", "folder", name, mutating: false) // ── Text processing ────────────────────────────────────────────────────────────────── case "sed": return op("text", "Transform text (sed)", "text.append", name, mutating: false) case "awk", "gawk", "mawk": return op("text", "Process text (awk)", "text.append", name, mutating: false) case "wc": return op("text", fileOperand(args).map { "Count lines in \($0)" } ?? "Count lines", "number", name, mutating: false) case "sort", "uniq", "cut", "tr", "paste", "join", "comm", "column", "fold", "fmt", "rev", "nl", "expand", "tee": return op("text", "Process text (\(name))", "text.alignleft", name, mutating: false) case "jq", "yq", "xmllint": return op("text", "Query structured data (\(name))", "curlybraces", name, mutating: false) case "diff", "cmp", "comm-diff", "delta": return op("text", "Compare files", "plusminus", name, mutating: false) case "echo", "printf", "yes", "seq": return op("text", "Print text", "text.bubble", name, mutating: false) case "xargs": return op("run", "Run a command over input (xargs)", "arrow.triangle.branch", name, mutating: false) // ── Filesystem mutations ───────────────────────────────────────────────────────────── case "rm": return op("delete", deleteLabel(args), "trash", name, mutating: true) case "rmdir": return op("delete", "Remove directory \(firstPositional(args) ?? "")".trimmingCharacters(in: .whitespaces), "trash", name, mutating: true) case "mkdir": return op("fs", "Create directory \(firstPositional(args) ?? "")".trimmingCharacters(in: .whitespaces), "folder.badge.plus", name, mutating: true) case "touch": return op("fs", "Touch \(firstPositional(args) ?? "a file")", "doc.badge.plus", name, mutating: true) case "cp", "rsync", "install": return op("fs", "Copy files", "doc.on.doc", name, mutating: true) case "mv", "rename": return op("fs", "Move/rename files", "arrow.left.arrow.right", name, mutating: true) case "ln": return op("fs", "Create a link", "link", name, mutating: true) case "chmod", "chown", "chgrp", "umask": return op("fs", "Change permissions", "lock.shield", name, mutating: true) // ── Build / package / run ──────────────────────────────────────────────────────────── case "npm", "pnpm", "yarn", "bun", "npx", "pip", "pip3", "poetry", "uv", "gem", "bundle", "cargo", "go", "mvn", "gradle", "brew", "apt", "apt-get": return op("build", "Package/build: \(name) \(firstPositional(args) ?? "")".trimmingCharacters(in: .whitespaces), "shippingbox", name, mutating: true) case "make", "cmake", "ninja", "swift", "xcodebuild", "tsc", "webpack", "vite", "rollup", "esbuild": return op("build", "Build (\(name))", "hammer", name, mutating: true) case "gcc", "g++", "clang", "clang++", "cc", "rustc", "javac", "ld": return op("build", "Compile (\(name))", "hammer", name, mutating: true) case "node", "deno", "python", "python3", "ruby", "perl", "php", "bash", "sh", "zsh": return op("run", runLabel(name, args), "terminal", name, mutating: false) case "pytest", "jest", "vitest", "mocha", "rspec", "tox": return op("run", "Run tests (\(name))", "checkmark.diamond", name, mutating: false) // ── Network ────────────────────────────────────────────────────────────────────────── case "curl", "wget", "http", "https": return op("network", networkLabel(name, args), "network", name, mutating: false) case "ssh", "scp", "sftp", "rsync-remote", "nc", "ncat", "telnet", "ping", "dig", "nslookup", "host": return op("network", "Network (\(name))", "network", name, mutating: false) // ── Archives ───────────────────────────────────────────────────────────────────────── case "tar", "zip", "unzip", "gzip", "gunzip", "bzip2", "xz", "zstd", "7z", "unrar": return op("archive", "Archive (\(name))", "archivebox", name, mutating: true) // ── Process / system info ──────────────────────────────────────────────────────────── case "ps", "top", "htop", "kill", "pkill", "killall", "jobs", "wait", "nohup", "timeout": return op("info", "Process control (\(name))", "gauge", name, mutating: false) case "df", "du", "free", "uname", "hostname", "whoami", "id", "uptime", "date", "env", "printenv", "lsof", "stat", "file": return op("info", "System info (\(name))", "info.circle", name, mutating: false) case "cd", "pushd", "popd", "export", "set", "unset", "source", "alias", "true", "false", "test", "[", "[[", "read": return op("other", "Shell builtin (\(name))", "chevron.left.forwardslash.chevron.right", name, mutating: false) default: // Unknown command: keep it in the feed with a generic label so nothing the agent ran // is silently invisible. Include the first operand for a touch of context. let detail = firstPositional(args).map { " \($0)" } ?? "" return ObservedCommand( command: name, category: "other", label: "Run \(name)\(clip(detail, 32))", symbol: "terminal", isMutating: false, recognized: false) } } // MARK: - Label builders /// `Search for ""` — the first non-flag operand is the pattern for grep-family tools. private static func searchLabel(_ args: [String]) -> String { guard let pattern = firstPositional(args) else { return "Search files" } return "Search for \(quote(clip(pattern, 40)))" } /// `Find files matching ` for `find -name `, else `Find in ` / `Find files`. private static func findLabel(_ args: [String]) -> String { if let glob = ShellLexer.flagValue(args, short: "-name", long: "-iname") ?? ShellLexer.flagValue(args, short: "-path", long: "-ipath") { return "Find files matching \(quote(clip(glob, 36)))" } if let dir = firstPositional(args) { return "Find files in \(clip(dir, 36))" } return "Find files" } /// `List ` naming the directory operand when present, else a bare `List directory`. private static func listLabel(_ args: [String]) -> String { guard let dir = firstPositional(args) else { return "List directory" } return "List \(clip(dir, 40))" } /// `Delete ` with `(recursive, forced)` notes for the dangerous flags — mirrors /// ``ShellCommandSummary`` so an `rm` reads the same everywhere it surfaces. private static func deleteLabel(_ args: [String]) -> String { let recursive = hasShortOrLong(args, shortChars: ["r", "R"], long: ["--recursive"]) let force = hasShortOrLong(args, shortChars: ["f"], long: ["--force"]) var notes: [String] = [] if recursive { notes.append("recursive") } if force { notes.append("forced") } let suffix = notes.isEmpty ? "" : " (\(notes.joined(separator: ", ")))" let target: String let paths = ShellLexer.positionals(args, valueFlags: []) switch paths.count { case 0: target = "files" case 1: target = clip(paths[0], 40) default: target = "\(paths.count) paths" } return "Delete \(target)\(suffix)" } /// `Run