basis-cli 0.4.0

The basis CLI, over the basis SDK and basis-acp: headless runs, an ACP server, and a websocket bridge. Installs as `basis`.
//! Rewriting the command line before clap sees it.
//!
//! ADR-0017's shorthand — `basis "fix the failing test"` is exactly
//! `basis spawn "fix the failing test"` — is a statement about one token, and clap
//! has no way to express "this positional is a subcommand unless it isn't". So
//! it happens out here on the raw argv, and [`cli`](crate::cli) then parses
//! whatever comes out, none the wiser.
//!
//! The two word lists are the whole rule, which is why they live beside the
//! function that consults them rather than beside the grammar they describe.
//! They have to be changed together: add a subcommand to
//! [`Command`](crate::cli::Command) without adding its word to
//! [`SUBCOMMANDS`], and the new subcommand silently becomes a prompt.

use std::ffi::{OsStr, OsString};

/// The lifecycle commands, adapters, and clap's own help command. Anything
/// else is a prompt.
const SUBCOMMANDS: [&str; 12] = [
    "spawn",
    "run",
    "send",
    "ask",
    "wait",
    "cancel",
    "watch",
    "inbox",
    "fingerprint",
    "serve",
    // Retired with the daemon (ADR-0019) but still reserved: an old script's
    // `basis __daemon …` must be told the subcommand is gone, not have the word
    // silently become a spawned prompt.
    "__daemon",
    "help",
];

/// Flags the top level answers itself. Every other flag belongs to `run`,
/// which is what lets `basis --json "hi"` mean what it looks like.
const TOP_LEVEL_FLAGS: [&str; 4] = ["-h", "--help", "-V", "--version"];

/// Rewrites `basis "<prompt>"` as `basis spawn "<prompt>"`.
///
/// Deciding on the first argument alone is what makes the rule total: a flag's
/// *value* can look like anything (`basis --model gpt-5 "hi"`), and scanning
/// further would have to know every flag's arity to avoid mistaking `gpt-5`
/// for the prompt.
pub(crate) fn normalize(argv: impl IntoIterator<Item = OsString>) -> Vec<OsString> {
    let mut argv: Vec<OsString> = argv.into_iter().collect();

    // Bare `basis` is left alone so main can return usage without inventing a
    // long-lived server mode.
    let Some(first) = argv.get(1) else {
        return argv;
    };

    if starts_a_run(first) {
        argv.insert(1, OsString::from("spawn"));
    }

    argv
}

/// Whether the first argument opens a run, rather than naming a subcommand or
/// asking the top level for help.
///
/// `--` lands here as a run too, which is what makes it the escape: `basis -- run`
/// becomes `basis spawn -- run`, and the word arrives as a prompt.
fn starts_a_run(first: &OsStr) -> bool {
    match first.to_str() {
        // Not UTF-8, so it is neither a reserved word nor a flag basis defines.
        // `spawn` takes it as a prompt and clap reports the encoding.
        None => true,
        Some(word) => !SUBCOMMANDS.contains(&word) && !TOP_LEVEL_FLAGS.contains(&word),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    use std::path::PathBuf;

    use clap::Parser;

    use crate::{
        cli::{Cli, Command, RunArgs},
        exit::EXIT_USAGE,
    };

    /// The command line as basis sees it after the shorthand is resolved.
    fn normalized(argv: &[&str]) -> Vec<String> {
        normalize(argv.iter().map(OsString::from))
            .into_iter()
            .map(|argument| argument.to_string_lossy().into_owned())
            .collect()
    }

    /// The `spawn` the shorthand produced, so a test can check what it carries.
    fn shorthand(argv: &[&str]) -> RunArgs {
        let parsed = Cli::try_parse_from(normalize(argv.iter().map(OsString::from)))
            .expect("the shorthand should parse");

        let Some(Command::Spawn(args)) = parsed.command else {
            panic!("a prompt should have become a spawn: {argv:?}");
        };
        args
    }

    #[test]
    fn a_positional_that_is_not_a_subcommand_is_a_prompt() {
        assert_eq!(
            shorthand(&["basis", "fix the failing test"]).prompt,
            "fix the failing test"
        );
    }

    #[test]
    fn the_shorthand_is_exactly_the_spawn_subcommand() {
        assert_eq!(
            normalized(&["basis", "fix the failing test"]),
            ["basis", "spawn", "fix the failing test"]
        );
    }

    #[test]
    fn flags_pass_through_the_shorthand() {
        // The interesting half is `--model gpt-5`: a flag's value looks like a
        // positional, so anything that scanned past the first argument would
        // take "gpt-5" for the prompt.
        let args = shorthand(&["basis", "--json", "--model", "gpt-5", "hi"]);

        assert!(args.json);
        assert_eq!(args.model.as_deref(), Some("gpt-5"));
        assert_eq!(args.prompt, "hi");
    }

    #[test]
    fn a_prompt_that_starts_with_a_dash_is_still_a_prompt() {
        assert_eq!(shorthand(&["basis", "-"]).prompt, "-");
        assert_eq!(
            shorthand(&["basis", "-C", "/repo", "hi"]).workspace,
            Some(PathBuf::from("/repo"))
        );
    }

    #[test]
    fn a_prompt_that_names_a_subcommand_needs_the_escape() {
        // Without `--`, `basis spawn` is the subcommand with its prompt missing —
        // clap says so rather than basis guessing which was meant.
        let ambiguous =
            Cli::try_parse_from(normalize(["basis", "spawn"].iter().map(OsString::from)))
                .expect_err("a bare subcommand name is not a prompt");
        assert_eq!(ambiguous.exit_code(), i32::from(EXIT_USAGE));

        assert_eq!(shorthand(&["basis", "--", "run"]).prompt, "run");
        assert_eq!(shorthand(&["basis", "--", "serve"]).prompt, "serve");
    }

    #[test]
    fn lifecycle_verbs_are_never_rewritten_as_prompts() {
        for verb in ["send", "wait", "cancel", "watch", "inbox"] {
            assert_eq!(
                normalized(&["basis", verb, "task"])[1],
                verb,
                "{verb} must reach clap as a subcommand"
            );
        }
    }

    #[test]
    fn a_prompt_that_merely_begins_with_a_subcommand_name_needs_nothing() {
        assert_eq!(
            shorthand(&["basis", "run the tests and summarize"]).prompt,
            "run the tests and summarize"
        );
    }

    #[test]
    fn bare_lan_is_left_alone_for_usage_to_handle() {
        assert_eq!(normalized(&["basis"]), ["basis"]);

        let parsed = Cli::try_parse_from(["basis"]).expect("parses");
        assert!(
            parsed.command.is_none(),
            "no subcommand is handled by main as usage, never as a server"
        );
    }

    #[test]
    fn every_subcommand_still_reaches_itself() {
        for subcommand in SUBCOMMANDS {
            assert_eq!(
                normalized(&["basis", subcommand]),
                ["basis", subcommand],
                "{subcommand} must not be rewritten as a prompt"
            );
        }
    }

    #[test]
    fn the_top_level_still_answers_for_help_and_version() {
        for flag in TOP_LEVEL_FLAGS {
            assert_eq!(normalized(&["basis", flag]), ["basis", flag]);
        }
    }
}