629 lines
33 KiB
Swift
629 lines
33 KiB
Swift
import Foundation
|
|
|
|
/// Parses a `host_exec` shell command into a structured, **deterministic** breakdown — the
|
|
/// program, its actions (subcommands/targets), its flags, its operands, and an *inferred
|
|
/// purpose* — so the approval UI can tell the user, in plain language, what the agent is about
|
|
/// to run on their machine. The point is verification: a host command escapes the sandbox, and
|
|
/// "Allow" is too easy to click blindly, so the user needs the command laid out, not a raw blob.
|
|
///
|
|
/// Everything here is derived **only from the command string** — never from anything the agent
|
|
/// said about it. The agent's own description of its intent may be wrong (or a lie); this
|
|
/// inference is computed from the literal tokens, so "Likely purpose: Build the Swift package"
|
|
/// is something the user can check against the command shown right beside it.
|
|
///
|
|
/// The command-agnostic lexing (segment splitting, tokenizing, heredocs, env/`sudo` prefixes)
|
|
/// is reused from ``ShellLexer`` — the same tokenizer ``GitCommandSummary`` and
|
|
/// ``ShellCommandSummary`` use, so there's one source of truth, not three that drift.
|
|
public enum HostCommandSummary {
|
|
/// A `NAME=value` environment assignment prefixed before a program (`NUCLEIC_CHANNEL=dev swift …`).
|
|
public struct EnvAssignment: Equatable, Sendable, Identifiable {
|
|
public let name: String
|
|
public let value: String
|
|
public var id: String { name }
|
|
public var display: String { "\(name)=\(value)" }
|
|
}
|
|
|
|
/// A parsed flag: its name (leading dashes kept) and its value when it takes one, recovered
|
|
/// from `--flag value`, `--flag=value`, or `-f value`. A boolean flag has a nil value.
|
|
public struct Flag: Equatable, Sendable, Identifiable {
|
|
public let name: String
|
|
public let value: String?
|
|
public var id: String { name + "\u{1}" + (value ?? "") }
|
|
/// "--product nucleic-local" / "--verbose" — how the flag reads back to the user.
|
|
public var display: String { value.map { "\(name) \($0)" } ?? name }
|
|
}
|
|
|
|
/// A recognized shell control-flow header — a loop (`for`/`while`/`until`/`select`) or a
|
|
/// conditional (`if`/`elif`/`case`). Surfaced so a scripted pipeline reads as its structure —
|
|
/// one "for i in $(seq 1 50)" step under a loop glyph — instead of shredding into bogus
|
|
/// `for`/`do`/`done`/`fi` program steps. See ``Invocation/control``.
|
|
public struct ControlFlow: Equatable, Sendable {
|
|
public enum Kind: Equatable, Sendable { case loop, conditional }
|
|
public let kind: Kind
|
|
/// The header rendered readably — verbatim from the command, so `$(…)` substitutions and
|
|
/// tests survive: "for i in $(seq 1 50)", "if [ -f /tmp/x ]".
|
|
public let text: String
|
|
}
|
|
|
|
/// One program invocation within a command pipeline — `cd … && FOO=bar swift build …` yields
|
|
/// one of these per real command (the `cd` becomes ``Summary/workingDirectory``).
|
|
public struct Invocation: Equatable, Sendable, Identifiable {
|
|
public let id: Int
|
|
/// The program name, reduced to its basename (`/usr/bin/swift` → `swift`). Empty for a
|
|
/// ``control`` step, which has no program.
|
|
public let program: String
|
|
/// The subcommands / targets that follow it, in order (`build`, `run`, `test`).
|
|
public let actions: [String]
|
|
/// The parsed flags, in order.
|
|
public let flags: [Flag]
|
|
/// Positional operands that are neither actions nor flag values (a script path, a branch).
|
|
public let arguments: [String]
|
|
/// `NAME=value` assignments prefixed before the program.
|
|
public let env: [EnvAssignment]
|
|
/// True when the invocation is `sudo`-elevated — a privilege escalation worth flagging.
|
|
public let elevated: Bool
|
|
/// True for an inherently destructive program (`rm`/`rmdir`) — flagged in the UI.
|
|
public let destructive: Bool
|
|
/// Set when this "invocation" is actually a shell loop/conditional *header* rather than a
|
|
/// program — the UI renders it as one control-flow step (a loop/branch glyph + the header
|
|
/// text). Nil for an ordinary program invocation.
|
|
public let control: ControlFlow?
|
|
|
|
/// "swift build", for a compact one-liner — or the loop/conditional header for a
|
|
/// ``control`` step ("for i in $(seq 1 50)").
|
|
public var headline: String {
|
|
if let control { return control.text }
|
|
return ([program] + actions).joined(separator: " ")
|
|
}
|
|
}
|
|
|
|
/// A whole `host_exec` command, parsed. The `invocations` list the real programs the
|
|
/// pipeline runs (in order); `workingDirectory` is the directory it `cd`s into first, if
|
|
/// any; and `purpose` is the deterministic, command-derived one-liner described above.
|
|
public struct Summary: Equatable, Sendable {
|
|
public let invocations: [Invocation]
|
|
public let workingDirectory: String?
|
|
/// A plain-language guess at what the command does, inferred from the parsed tokens
|
|
/// (never from the agent). Always present; a fallback restates the command when no
|
|
/// known program is recognized.
|
|
public let purpose: String
|
|
|
|
/// Whether any step runs under `sudo` — surfaced so the UI can warn before "Allow".
|
|
public var isElevated: Bool { invocations.contains { $0.elevated } }
|
|
/// Whether any step is an inherently destructive program (`rm`/`rmdir`).
|
|
public var isDestructive: Bool { invocations.contains { $0.destructive } }
|
|
}
|
|
|
|
/// Parses `command` into a structured ``Summary``. Returns nil only for a blank command;
|
|
/// otherwise it always yields a summary (with a restated-command `purpose` for an
|
|
/// unrecognized program), since a host_exec call always has a command worth laying out.
|
|
public static func summary(for command: String) -> Summary? {
|
|
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
|
|
// Drop heredoc bodies so a here-doc payload isn't parsed as commands, then walk each
|
|
// segment of the pipeline. A `cd` segment becomes the working directory, not an op.
|
|
let (flattened, _) = ShellLexer.stripHeredocs(command)
|
|
var invocations: [Invocation] = []
|
|
var workingDirectory: String?
|
|
var nextID = 0
|
|
for segment in ShellLexer.splitSegments(flattened) {
|
|
// Peel any leading `do`/`then`/`else`/`{` so the command a loop or conditional body
|
|
// introduces (`then rm -rf x`) parses as that command, then strip env/`sudo` off it.
|
|
let peeled = ShellLexer.stripBodyIntroducers(ShellLexer.tokenize(segment))
|
|
let (env, elevated, rest) = stripPrefixes(peeled)
|
|
guard let head = rest.first else { continue }
|
|
// A pure structural keyword (`done`/`fi`/`esac`/`}`/`break`/…) carries no operation —
|
|
// skip it like `cd`, so it never renders as a bogus program step.
|
|
if ShellLexer.structuralKeywords.contains(head) { continue }
|
|
// A loop or conditional *header* reads as one control-flow step (a loop/branch glyph +
|
|
// the verbatim header) rather than shredding across the keyword tokens.
|
|
if let control = controlFlow(head: head, segment: segment) {
|
|
invocations.append(makeControlInvocation(id: nextID, control: control))
|
|
nextID += 1
|
|
continue
|
|
}
|
|
if basename(head).lowercased() == "cd" {
|
|
if workingDirectory == nil, rest.count >= 2 {
|
|
workingDirectory = normalizeDirectory(rest[1])
|
|
}
|
|
continue
|
|
}
|
|
invocations.append(makeInvocation(
|
|
id: nextID, program: head, args: Array(rest.dropFirst()),
|
|
env: env, elevated: elevated, raw: segment))
|
|
nextID += 1
|
|
}
|
|
|
|
let purpose = inferPurpose(
|
|
invocations: invocations, workingDirectory: workingDirectory, command: trimmed)
|
|
return Summary(invocations: invocations, workingDirectory: workingDirectory, purpose: purpose)
|
|
}
|
|
|
|
// MARK: - Substitution expansion
|
|
|
|
/// Expands simple shell variable substitutions — `$VAR` and `${VAR}` — in a command using
|
|
/// `environment`, so the approval card can show what the command *actually* resolves to
|
|
/// (e.g. `$PWD` → the host working directory) instead of an opaque `$PWD`. This is a display
|
|
/// aid for parsing a host command, not a shell, so it deliberately does only variable
|
|
/// expansion — the one substitution a user routinely has to read past.
|
|
///
|
|
/// It mirrors shell quoting so it never lies about what will run: a reference inside single
|
|
/// quotes is left verbatim (single quotes suppress expansion), while unquoted and
|
|
/// double-quoted references expand. A `$(…)` command substitution is left untouched — we
|
|
/// can't run it — and an *unset* variable is left as its literal `$NAME` so an unresolved
|
|
/// reference reads as unresolved, rather than silently vanishing the way a real shell blanks it.
|
|
public static func expand(_ command: String, environment: [String: String]) -> String {
|
|
guard command.contains("$") else { return command }
|
|
let chars = Array(command)
|
|
var result = ""
|
|
var index = 0
|
|
// The active quote, or nil outside quotes. A `'` inside a double-quoted run (and vice
|
|
// versa) is literal, so we only open a quote when not already in one.
|
|
var quote: Character? = nil
|
|
while index < chars.count {
|
|
let c = chars[index]
|
|
if c == "'" || c == "\"" {
|
|
if quote == c { quote = nil } else if quote == nil { quote = c }
|
|
result.append(c)
|
|
index += 1
|
|
continue
|
|
}
|
|
// Single quotes suppress all expansion; anything that isn't a `$` is copied as-is.
|
|
guard quote != "'", c == "$" else {
|
|
result.append(c)
|
|
index += 1
|
|
continue
|
|
}
|
|
// At a `$` eligible for expansion. Try `${NAME}` then `$NAME`; leave anything else
|
|
// (`$(`, `$$`, a trailing `$`) untouched.
|
|
let after = index + 1
|
|
if after < chars.count, chars[after] == "{",
|
|
let close = chars[(after + 1)...].firstIndex(of: "}") {
|
|
let name = String(chars[(after + 1)..<close])
|
|
if isVariableName(name), let value = environment[name] {
|
|
result.append(value)
|
|
} else {
|
|
result.append(contentsOf: chars[index...close])
|
|
}
|
|
index = close + 1
|
|
} else if after < chars.count, isNameStart(chars[after]) {
|
|
var end = after + 1
|
|
while end < chars.count, isNameChar(chars[end]) { end += 1 }
|
|
let name = String(chars[after..<end])
|
|
if let value = environment[name] {
|
|
result.append(value)
|
|
} else {
|
|
result.append(contentsOf: chars[index..<end])
|
|
}
|
|
index = end
|
|
} else {
|
|
result.append(c)
|
|
index += 1
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
/// Whether `name` is a valid shell variable name (`[A-Za-z_][A-Za-z0-9_]*`) — the gate for
|
|
/// expanding a `${…}` reference.
|
|
private static func isVariableName(_ name: String) -> Bool {
|
|
guard let first = name.first, isNameStart(first) else { return false }
|
|
return name.dropFirst().allSatisfy(isNameChar)
|
|
}
|
|
|
|
/// A valid first character of a variable name: a letter or underscore.
|
|
private static func isNameStart(_ c: Character) -> Bool { c.isLetter || c == "_" }
|
|
|
|
/// A valid subsequent character of a variable name: a letter, digit, or underscore.
|
|
private static func isNameChar(_ c: Character) -> Bool { c.isLetter || c.isNumber || c == "_" }
|
|
|
|
// MARK: - Segment parsing
|
|
|
|
/// Pulls the leading `sudo` and `NAME=value` env assignments off a tokenized segment (in any
|
|
/// order), returning the captured env, whether it was `sudo`-elevated, and the remaining
|
|
/// tokens starting at the real program word.
|
|
private static func stripPrefixes(_ tokens: [String]) -> (env: [EnvAssignment], elevated: Bool, rest: [String]) {
|
|
var env: [EnvAssignment] = []
|
|
var elevated = false
|
|
var rest = tokens[...]
|
|
loop: while let first = rest.first {
|
|
if first == "sudo" {
|
|
elevated = true
|
|
rest = rest.dropFirst()
|
|
} else if ShellLexer.isEnvAssignment(first), let eq = first.firstIndex(of: "=") {
|
|
env.append(EnvAssignment(
|
|
name: String(first[..<eq]), value: String(first[first.index(after: eq)...])))
|
|
rest = rest.dropFirst()
|
|
} else {
|
|
break loop
|
|
}
|
|
}
|
|
return (env, elevated, Array(rest))
|
|
}
|
|
|
|
/// Classifies a segment whose (post-peel) head word opens a shell control-flow construct —
|
|
/// a loop (`for`/`while`/`until`/`select`) or a conditional (`if`/`elif`/`case`) — into a
|
|
/// ``ControlFlow`` carrying the verbatim header for display. Nil when `head` isn't such a
|
|
/// keyword (the segment parses as an ordinary program).
|
|
private static func controlFlow(head: String, segment: String) -> ControlFlow? {
|
|
let text = segment.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if ShellLexer.loopKeywords.contains(head) { return ControlFlow(kind: .loop, text: text) }
|
|
if ShellLexer.conditionalKeywords.contains(head) { return ControlFlow(kind: .conditional, text: text) }
|
|
return nil
|
|
}
|
|
|
|
/// A control-flow step — a loop/conditional header — as an ``Invocation`` with only its
|
|
/// ``Invocation/control`` set (no program/actions/flags), so it slots into the same step list.
|
|
private static func makeControlInvocation(id: Int, control: ControlFlow) -> Invocation {
|
|
Invocation(id: id, program: "", actions: [], flags: [], arguments: [],
|
|
env: [], elevated: false, destructive: false, control: control)
|
|
}
|
|
|
|
/// Builds one ``Invocation`` from a program token and the tokens after it: it splits off the
|
|
/// program's known actions, then the flags and operands.
|
|
private static func makeInvocation(
|
|
id: Int, program rawProgram: String, args: [String],
|
|
env: [EnvAssignment], elevated: Bool, raw: String
|
|
) -> Invocation {
|
|
let program = basename(rawProgram)
|
|
let spec = spec(for: program)
|
|
let (actions, rest) = splitActions(args, spec: spec)
|
|
let (flags, arguments) = splitFlags(rest, valueFlags: spec.valueFlags)
|
|
let destructive = program == "rm" || program == "rmdir"
|
|
return Invocation(
|
|
id: id, program: program, actions: actions, flags: flags, arguments: arguments,
|
|
env: env, elevated: elevated, destructive: destructive, control: nil)
|
|
}
|
|
|
|
/// Peels the leading action tokens (subcommands/targets) off a program's arguments, per its
|
|
/// ``ProgramSpec``: a fixed-vocabulary tool (swift, git, npm) takes only tokens it knows as
|
|
/// subcommands; an open-action tool (make, and any unknown program) takes leading
|
|
/// identifier-like words as targets. Capped at `spec.maxActions` so an operand that happens
|
|
/// to look like a word (a branch, a product name) doesn't get swallowed as an action.
|
|
private static func splitActions(_ args: [String], spec: ProgramSpec) -> (actions: [String], rest: [String]) {
|
|
var actions: [String] = []
|
|
var index = 0
|
|
while index < args.count, actions.count < spec.maxActions {
|
|
let token = args[index]
|
|
guard isIdentifierLike(token) else { break }
|
|
guard spec.openActions || spec.subcommands.contains(token) else { break }
|
|
actions.append(token)
|
|
index += 1
|
|
}
|
|
return (actions, Array(args[index...]))
|
|
}
|
|
|
|
/// Splits the remaining tokens into flags and positional operands. A flag is `-x` / `--flag`;
|
|
/// it takes a value when written `--flag=value`, or `--flag value` for a known value-flag
|
|
/// whose next token isn't itself a flag. A bare `--` ends option parsing.
|
|
private static func splitFlags(_ tokens: [String], valueFlags: Set<String>) -> (flags: [Flag], arguments: [String]) {
|
|
var flags: [Flag] = []
|
|
var arguments: [String] = []
|
|
var index = 0
|
|
var endOfOptions = false
|
|
while index < tokens.count {
|
|
let token = tokens[index]
|
|
if endOfOptions { arguments.append(token); index += 1; continue }
|
|
if token == "--" { endOfOptions = true; index += 1; continue }
|
|
if token.hasPrefix("-"), token.count > 1 {
|
|
if let eq = token.firstIndex(of: "=") {
|
|
flags.append(Flag(name: String(token[..<eq]),
|
|
value: String(token[token.index(after: eq)...])))
|
|
index += 1
|
|
} else if valueFlags.contains(token), index + 1 < tokens.count,
|
|
!tokens[index + 1].hasPrefix("-") {
|
|
flags.append(Flag(name: token, value: tokens[index + 1]))
|
|
index += 2
|
|
} else {
|
|
flags.append(Flag(name: token, value: nil))
|
|
index += 1
|
|
}
|
|
} else {
|
|
arguments.append(token)
|
|
index += 1
|
|
}
|
|
}
|
|
return (flags, arguments)
|
|
}
|
|
|
|
// MARK: - Program specs
|
|
|
|
/// How a given program exposes its actions and which of its flags take a value — enough to
|
|
/// parse the common build/dev tools precisely while degrading gracefully for the rest.
|
|
private struct ProgramSpec {
|
|
/// When true, leading identifier-like words are actions/targets without a fixed
|
|
/// vocabulary (make targets, an unknown CLI's subcommand). When false, only tokens in
|
|
/// `subcommands` count, so operands aren't mistaken for actions.
|
|
var openActions: Bool = false
|
|
/// The recognized subcommands, when `openActions` is false.
|
|
var subcommands: Set<String> = []
|
|
/// How many leading action tokens to take at most.
|
|
var maxActions: Int = 1
|
|
/// The flags whose following token is a value (`--product nucleic-local`), so it's paired
|
|
/// with the flag rather than read as an operand.
|
|
var valueFlags: Set<String> = []
|
|
}
|
|
|
|
/// The spec for a program (by basename). An unrecognized program gets the open-action
|
|
/// fallback: one leading word as its action, no value-flags.
|
|
private static func spec(for program: String) -> ProgramSpec {
|
|
switch program {
|
|
case "swift":
|
|
return ProgramSpec(
|
|
subcommands: ["build", "run", "test", "package", "sdk"], maxActions: 2,
|
|
valueFlags: ["--product", "--target", "--build-system", "-c", "--configuration",
|
|
"--filter", "--package-path", "--scratch-path", "--jobs", "-j",
|
|
"-Xswiftc", "-Xcc", "-Xlinker", "--triple"])
|
|
case "make":
|
|
return ProgramSpec(openActions: true, maxActions: 4, valueFlags: ["-f", "-C", "-j"])
|
|
case "npm", "pnpm", "yarn", "bun":
|
|
return ProgramSpec(
|
|
subcommands: ["install", "i", "ci", "run", "run-script", "test", "build", "start",
|
|
"exec", "publish", "add", "remove", "update", "lint", "dev"],
|
|
valueFlags: ["--prefix", "-w", "--workspace", "--filter"])
|
|
case "npx":
|
|
return ProgramSpec(openActions: true, maxActions: 1)
|
|
case "cargo":
|
|
return ProgramSpec(
|
|
subcommands: ["build", "test", "run", "check", "clippy", "fmt", "bench", "doc",
|
|
"install", "update", "publish", "clean"],
|
|
valueFlags: ["--package", "-p", "--bin", "--example", "--features", "--target",
|
|
"--manifest-path", "--jobs", "-j"])
|
|
case "git":
|
|
return ProgramSpec(
|
|
subcommands: ["commit", "push", "pull", "fetch", "clone", "merge", "rebase",
|
|
"checkout", "switch", "branch", "tag", "stash", "restore", "reset",
|
|
"revert", "cherry-pick", "add", "rm", "mv", "status", "log", "diff",
|
|
"show", "blame", "rev-parse", "worktree", "init", "clean"],
|
|
maxActions: 2,
|
|
valueFlags: ["-m", "--message", "-F", "--file", "-b", "-B", "-C", "-c"])
|
|
case "xcodebuild":
|
|
return ProgramSpec(
|
|
openActions: true, maxActions: 3,
|
|
valueFlags: ["-scheme", "-project", "-workspace", "-configuration", "-sdk",
|
|
"-destination", "-derivedDataPath", "-arch", "-target"])
|
|
case "python", "python3", "python2":
|
|
return ProgramSpec(valueFlags: ["-m", "-c", "-W", "-X"])
|
|
case "pip", "pip3":
|
|
return ProgramSpec(
|
|
subcommands: ["install", "uninstall", "download", "list", "show", "freeze",
|
|
"wheel", "check"],
|
|
valueFlags: ["-r", "--requirement", "-c", "--constraint", "-t", "--target"])
|
|
case "node":
|
|
return ProgramSpec(valueFlags: ["-e", "--eval", "-r", "--require"])
|
|
case "docker", "podman":
|
|
return ProgramSpec(
|
|
subcommands: ["build", "run", "exec", "compose", "push", "pull", "up", "down",
|
|
"start", "stop", "ps", "images", "logs"],
|
|
maxActions: 2,
|
|
valueFlags: ["-t", "--tag", "-f", "--file", "-p", "--publish", "-v", "--volume"])
|
|
case "gh":
|
|
return ProgramSpec(
|
|
subcommands: ["pr", "issue", "repo", "release", "run", "workflow", "auth", "api",
|
|
"create", "list", "view", "merge", "checkout", "status"],
|
|
maxActions: 3)
|
|
case "brew":
|
|
return ProgramSpec(
|
|
subcommands: ["install", "uninstall", "upgrade", "update", "list", "info",
|
|
"search", "tap", "bundle"],
|
|
maxActions: 2)
|
|
case "sh", "bash", "zsh", "fish":
|
|
return ProgramSpec(valueFlags: ["-c"])
|
|
default:
|
|
return ProgramSpec(openActions: true, maxActions: 1)
|
|
}
|
|
}
|
|
|
|
// MARK: - Inferred purpose
|
|
|
|
/// The deterministic, command-derived purpose line. With no real op (a bare `cd`), it names
|
|
/// the directory change; otherwise it describes the highest-salience invocation — the one
|
|
/// that most defines what the command is *for* (a destructive `rm`, a build/test/deploy)
|
|
/// rather than the plumbing around it.
|
|
private static func inferPurpose(
|
|
invocations: [Invocation], workingDirectory: String?, command: String
|
|
) -> String {
|
|
guard !invocations.isEmpty else {
|
|
if let workingDirectory { return "Change directory to \(workingDirectory)" }
|
|
return "Run a host command"
|
|
}
|
|
let interpreted = invocations.map { interpret($0) }
|
|
let best = interpreted.max { $0.salience < $1.salience }
|
|
return best?.purpose ?? "Run \(invocations[0].headline)"
|
|
}
|
|
|
|
/// A program-aware reading of a single invocation: a plain-language purpose and a salience
|
|
/// that ranks it against the pipeline's other steps for the headline. The phrasing is
|
|
/// imperative ("Build the Swift package") so it reads as "what this does".
|
|
private static func interpret(_ inv: Invocation) -> (purpose: String, salience: Int) {
|
|
// A control-flow header describes the shape of the script, not what it does — rank it low
|
|
// so a real body command (a build/test/delete) leads the headline instead.
|
|
if let control = inv.control {
|
|
return (control.kind == .loop ? "Run a loop" : "Run a conditional", 18)
|
|
}
|
|
// A destructive or elevated step dominates the headline — it's what the user most needs
|
|
// to notice before allowing the command.
|
|
if inv.destructive {
|
|
let target = inv.arguments.first.map { " \($0)" } ?? " files"
|
|
return ("Delete\(target) on the host", 100)
|
|
}
|
|
let action = inv.actions.first
|
|
switch inv.program {
|
|
case "swift":
|
|
return swiftPurpose(inv, action: action)
|
|
case "make":
|
|
let targets = inv.actions
|
|
if targets.isEmpty { return ("Run the default make target", 60) }
|
|
let list = backticked(targets.joined(separator: ", "))
|
|
return ("Run the make target\(targets.count == 1 ? "" : "s") \(list)", 64)
|
|
case "npm", "pnpm", "yarn", "bun":
|
|
return packageManagerPurpose(inv, action: action)
|
|
case "npx":
|
|
let tool = inv.arguments.first ?? inv.actions.first
|
|
return (tool.map { "Run \(backticked($0)) via npx" } ?? "Run a tool via npx", 55)
|
|
case "cargo":
|
|
return cargoPurpose(action: action)
|
|
case "git":
|
|
// Reuse the git phrasing so a host git command reads like git does everywhere else.
|
|
let purpose = GitCommandSummary.summary(for: inv.headline + flagsSuffix(inv))
|
|
?? "Run a git command"
|
|
return (purpose, 75)
|
|
case "xcodebuild":
|
|
let testing = inv.actions.contains("test") || inv.actions.contains("test-without-building")
|
|
let scheme = flagValue(inv, ["-scheme"]).map { " (scheme \($0))" } ?? ""
|
|
return (testing ? "Run Xcode tests\(scheme)" : "Build the Xcode project\(scheme)", testing ? 70 : 66)
|
|
case "python", "python3", "python2":
|
|
if let module = flagValue(inv, ["-m"]) { return ("Run the Python module \(backticked(module))", 62) }
|
|
if let script = inv.arguments.first { return ("Run the Python script \(backticked(script))", 62) }
|
|
if flagValue(inv, ["-c"]) != nil { return ("Run inline Python code", 58) }
|
|
return ("Run Python", 50)
|
|
case "pip", "pip3":
|
|
if action == "install" {
|
|
let pkgs = inv.arguments.isEmpty ? "" : " " + backticked(inv.arguments.prefix(3).joined(separator: ", "))
|
|
return ("Install Python packages\(pkgs)", 60)
|
|
}
|
|
return ("Run pip\(action.map { " \($0)" } ?? "")", 45)
|
|
case "node":
|
|
if let script = inv.arguments.first { return ("Run the Node script \(backticked(script))", 60) }
|
|
if flagValue(inv, ["-e", "--eval"]) != nil { return ("Run inline Node code", 56) }
|
|
return ("Run Node", 50)
|
|
case "docker", "podman":
|
|
let sub = inv.actions.joined(separator: " ")
|
|
return (sub.isEmpty ? "Run a container command" : "Run `\(inv.program) \(sub)`", 60)
|
|
case "gh":
|
|
let sub = inv.actions.joined(separator: " ")
|
|
return (sub.isEmpty ? "Run a GitHub CLI command" : "Run `gh \(sub)`", 55)
|
|
case "brew":
|
|
switch action {
|
|
case "install": return ("Install Homebrew packages", 60)
|
|
case "uninstall": return ("Uninstall Homebrew packages", 60)
|
|
case "upgrade", "update": return ("Update Homebrew packages", 55)
|
|
default: return ("Run a Homebrew command", 45)
|
|
}
|
|
case "sh", "bash", "zsh", "fish":
|
|
if let script = inv.arguments.first { return ("Run the shell script \(backticked(script))", 58) }
|
|
if flagValue(inv, ["-c"]) != nil { return ("Run an inline shell command", 54) }
|
|
return ("Start a \(inv.program) shell", 40)
|
|
default:
|
|
return defaultPurpose(inv)
|
|
}
|
|
}
|
|
|
|
private static func swiftPurpose(_ inv: Invocation, action: String?) -> (String, Int) {
|
|
let channel = flagValue(inv, [], env: "NUCLEIC_CHANNEL")
|
|
let channelSuffix = channel.map { " (\($0) channel)" } ?? ""
|
|
let release = (flagValue(inv, ["-c", "--configuration"]) == "release")
|
|
let releaseSuffix = release ? " in release" : ""
|
|
switch action {
|
|
case "build":
|
|
let what = flagValue(inv, ["--product"]).map { "the Swift product \(backticked($0))" }
|
|
?? "the Swift package"
|
|
return ("Build \(what)\(releaseSuffix)\(channelSuffix)", 66)
|
|
case "test":
|
|
let filter = flagValue(inv, ["--filter"]).map { " (filter: \($0))" } ?? ""
|
|
return ("Run the Swift test suite\(filter)", 70)
|
|
case "run":
|
|
let what = (inv.arguments.first ?? flagValue(inv, ["--product"])).map(backticked) ?? "the Swift package"
|
|
return ("Build and run \(what)\(channelSuffix)", 68)
|
|
case "package":
|
|
let sub = inv.actions.count > 1 ? inv.actions[1] : (inv.arguments.first ?? "")
|
|
return (sub.isEmpty ? "Run a SwiftPM package command" : "Run SwiftPM `package \(sub)`", 55)
|
|
default:
|
|
return defaultPurpose(inv)
|
|
}
|
|
}
|
|
|
|
private static func packageManagerPurpose(_ inv: Invocation, action: String?) -> (String, Int) {
|
|
let pm = inv.program
|
|
switch action {
|
|
case "install", "i", "ci", "add":
|
|
return ("Install \(pm) dependencies", 60)
|
|
case "run", "run-script", "exec", "dev":
|
|
let script = inv.arguments.first ?? (inv.actions.count > 1 ? inv.actions[1] : nil)
|
|
return (script.map { "Run the \(backticked($0)) \(pm) script" } ?? "Run an \(pm) script", 60)
|
|
case "test":
|
|
return ("Run \(pm) tests", 68)
|
|
case "build":
|
|
return ("Run the \(pm) build", 66)
|
|
case "start":
|
|
return ("Start the \(pm) app", 60)
|
|
case "publish":
|
|
return ("Publish the \(pm) package", 80)
|
|
case "lint":
|
|
return ("Lint with \(pm)", 50)
|
|
default:
|
|
return defaultPurpose(inv)
|
|
}
|
|
}
|
|
|
|
private static func cargoPurpose(action: String?) -> (String, Int) {
|
|
switch action {
|
|
case "build": return ("Build the Rust crate", 66)
|
|
case "test": return ("Run the Rust tests", 70)
|
|
case "run": return ("Build and run the Rust crate", 68)
|
|
case "check": return ("Type-check the Rust crate", 60)
|
|
case "clippy": return ("Lint the Rust crate", 55)
|
|
case "fmt": return ("Format the Rust code", 50)
|
|
case "publish": return ("Publish the Rust crate", 80)
|
|
default: return ("Run cargo\(action.map { " \($0)" } ?? "")", 50)
|
|
}
|
|
}
|
|
|
|
/// The fallback purpose for an unrecognized program (or a recognized one in an unmodeled
|
|
/// mode): restate the command as imperatively as the tokens allow.
|
|
private static func defaultPurpose(_ inv: Invocation) -> (String, Int) {
|
|
let readOnly: Set<String> = ["ls", "cat", "pwd", "echo", "which", "head", "tail", "grep",
|
|
"find", "file", "stat", "env", "printenv", "whoami", "date"]
|
|
if readOnly.contains(inv.program) {
|
|
return ("Inspect the host (\(backticked(inv.program)))", 15)
|
|
}
|
|
return ("Run \(backticked(inv.headline))", 30)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
/// The value of the first flag whose name is in `names`, or — when `env` is given — the value
|
|
/// of that environment assignment. Lets a purpose read both `--configuration release` and
|
|
/// `NUCLEIC_CHANNEL=dev`.
|
|
private static func flagValue(_ inv: Invocation, _ names: [String], env: String? = nil) -> String? {
|
|
if let env, let assignment = inv.env.first(where: { $0.name == env }) { return assignment.value }
|
|
for name in names {
|
|
if let flag = inv.flags.first(where: { $0.name == name }), let value = flag.value {
|
|
return value
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// The flags re-rendered as a trailing string, so a git invocation can be re-summarized by
|
|
/// ``GitCommandSummary`` (which parses a command line) from its parsed pieces.
|
|
private static func flagsSuffix(_ inv: Invocation) -> String {
|
|
let parts = inv.flags.map(\.display) + inv.arguments
|
|
return parts.isEmpty ? "" : " " + parts.joined(separator: " ")
|
|
}
|
|
|
|
/// Wraps text in backticks for the `code`-span markdown the UI renders inline.
|
|
private static func backticked(_ text: String) -> String { "`\(text)`" }
|
|
|
|
/// A token's basename — its last path component — so `/usr/bin/swift` and `./scripts/x.sh`
|
|
/// read as `swift` and `x.sh`.
|
|
private static func basename(_ token: String) -> String {
|
|
(token as NSString).lastPathComponent
|
|
}
|
|
|
|
/// Whether a token is a bare identifier word (a subcommand/target) rather than a flag, path,
|
|
/// or operand: letters/digits/`-`/`_`, leading with a letter, no dots or slashes.
|
|
private static func isIdentifierLike(_ token: String) -> Bool {
|
|
guard let first = token.first, first.isLetter else { return false }
|
|
return token.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }
|
|
}
|
|
|
|
/// A readable working directory: the common `cd "$(git rev-parse --show-toplevel …)"` form
|
|
/// is recognized and named, since it's noise to print verbatim; anything else shows as-is.
|
|
private static func normalizeDirectory(_ raw: String) -> String {
|
|
if raw.contains("git rev-parse --show-toplevel") { return "the repository root" }
|
|
return raw
|
|
}
|
|
}
|