Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 32 additions & 113 deletions crates/minimal/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ pub mod prompt;
)]
#[command(subcommand_required = false)]
pub struct Cli {
// Optional: a bare `min` (no subcommand) resolves or activates a session
// for the current directory — see `cmd_default`. Keeps every named
// subcommand reachable unchanged when one is supplied.
// Optional: a bare `min` (no subcommand) prints the top-level help — see
// `cmd_default`. Keeps every named subcommand reachable unchanged when one
// is supplied.
#[command(subcommand)]
pub command: Option<Command>,

Expand Down Expand Up @@ -292,10 +292,10 @@ pub struct GlobalArgs {
pub provider: Option<Provider>,
/// Skip interactive prompts that need a terminal.
///
/// Affects e.g. the session picker shown by bare `min` or `min session
/// attach` with no session argument. When a choice is ambiguous, the
/// command errors with a list of candidates instead of opening a picker.
/// Implied when stdin/stdout is not a terminal.
/// Affects e.g. the session picker shown by `min session attach` with no
/// session argument. When a choice is ambiguous, the command errors with a
/// list of candidates instead of opening a picker. Implied when
/// stdin/stdout is not a terminal.
#[arg(long, global = true, default_value_t = false)]
pub no_input: bool,
}
Expand Down Expand Up @@ -638,10 +638,9 @@ async fn run_command(cli: Cli) -> Result<(), anyhow::Error> {
client::migrate_legacy_provider_dirs(cli.global_args.minimal_dir.as_deref());

match cli.command {
// A bare `min` (no subcommand) resolves-or-activates and attaches. A
// deliberate exception to the `<noun> <verb>` convention, documented
// in docs/reference/cli.md — see `cmd_default`.
None => cmd_default(&cli.global_args).await,
// A bare `min` (no subcommand) prints the top-level help — see
// `cmd_default`.
None => cmd_default(),
Some(Command::Ls(args)) => cmd_ls(&cli.global_args, args).await,
Some(Command::Stop(args)) => cmd_stop(&cli.global_args, args).await,
Some(Command::Session(SessionArgs { command })) => match command {
Expand Down Expand Up @@ -709,68 +708,18 @@ fn session_announce_label(id: &sessions::SessionId, name: Option<&str>) -> Strin
}
}

/// The default action for a bare `min` (no subcommand): get the operator into
/// a session for the current directory with the least ceremony.
/// The default action for a bare `min` (no subcommand): print the top-level
/// help and exit successfully, the same text `min --help` produces.
///
/// - No sessions exist → [`cmd_activate`] a new one with `--attach`, so a
/// fresh `min` lands the user in a shell.
/// - A session built from the current directory exists → attach to it
/// (auto-resolve, or picker if ambiguous).
/// - Otherwise → attach to the only session, or open a picker over all.
///
/// Shares smart resolution with `min session attach` (no session arg) via
/// [`resolve_smart_attach`]; the only difference is the `NoSessions` case,
/// which activates here instead of erroring.
async fn cmd_default(global: &GlobalArgs) -> Result<(), anyhow::Error> {
ensure_daemon(global)?;

let sock = client::resolve_socket_path(global.minimal_dir.as_deref(), global.use_minvmd())
.context("Failed to resolve daemon socket path")?;

let mut client = client::Client::connect(&sock)
.await
.context("Failed to connect to minimald")?;

match resolve_smart_attach(&mut client, global).await? {
Some(entry) => {
tracing::info!(
session_id = %entry.id,
session_name = ?entry.name,
"bare `min`: attaching to resolved session"
);
// Drop the listing connection before shelling out; the ssh child
// holds its own proxy connection to the daemon.
drop(client);
attach_to_session(&sock, entry.id, None).await
}
None => {
// No sessions exist: activate a new one for the current directory
// (or -C/--repo-dir if set) and chain into attach, mirroring
// `min session activate --attach`. `cmd_activate` resolves the path from
// `global.repo_dir` when no positional is given, matching the
// attach side's `cwd_host_path(global)`. But refuse first when that
// chained attach could never run: creating then failing orphans the
// session (#1031).
ensure_activate_on_empty_allowed(global.no_input, std::io::stdin().is_terminal())?;
drop(client);
let activate_args = ActivateArgs {
name: None,
path: None,
sync: SyncMode::Tarball,
network: CliNetworkMode::HostNet,
ingress: Vec::new(),
loadout: Vec::new(),
no_loadouts: false,
// A non-interactive caller (--no-input, CI, a script) can't
// answer the activation policy prompt; `cmd_activate` already
// falls back to the `--no-prompt` path when stderr isn't a
// terminal, so mirror that here rather than forcing a hang.
no_prompt: global.no_input || !can_prompt_interactively(),
attach: true,
};
cmd_activate(global, activate_args).await
}
}
/// Deliberately inert — it starts no daemon, creates no session, and attaches
/// to nothing. Getting into a session is spelled explicitly:
/// `min session attach` (which still auto-resolves from the current directory)
/// or `min session activate`.
pub fn cmd_default() -> Result<(), anyhow::Error> {
use clap::CommandFactory as _;
Cli::command()
.print_help()
.context("Failed to write help output")
}

/// Connect to the daemon, resolving the socket path from global args.
Expand Down Expand Up @@ -1795,9 +1744,8 @@ pub async fn cmd_attach(global: &GlobalArgs, args: AttachArgs) -> Result<(), any
/// and either attaches directly (unambiguous), opens the interactive picker
/// (ambiguous), or errors (ambiguous but non-interactive).
///
/// Returns `Ok(None)` when no sessions exist at all — the caller decides
/// whether that is an error (`min session attach`) or a cue to activate a new session
/// (bare `min`).
/// Returns `Ok(None)` when no sessions exist at all, which `min session attach`
/// reports as an error pointing at `min session activate`.
async fn resolve_smart_attach(
client: &mut client::Client,
global: &GlobalArgs,
Expand Down Expand Up @@ -1835,25 +1783,6 @@ async fn resolve_smart_attach(
}
}

/// Guard for bare `min` on an empty daemon: activating there chains straight
/// into an interactive attach, which needs a TTY on stdin. Under `--no-input`,
/// or when stdin is not a terminal, that attach can never succeed — so refuse
/// before creating anything, emitting the same message the explicit
/// `min session attach` produces, instead of creating a session and then
/// failing the attach guard, which leaves the session orphaned (#1031).
///
/// Pure in its inputs so both branches are unit-testable without a controlled
/// terminal.
fn ensure_activate_on_empty_allowed(
no_input: bool,
stdin_is_tty: bool,
) -> Result<(), anyhow::Error> {
if no_input || !stdin_is_tty {
bail!("no sessions exist; use 'min session activate' to create one");
}
Ok(())
}

/// Guard for the interactive attach path: the PTY-backed session shell must be
/// driven from a real terminal. When stdin is not a TTY there is nothing to
/// drive the remote shell and no EOF ever reaches it through the forced `-tt`
Expand All @@ -1879,9 +1808,9 @@ fn ensure_interactive_attach_tty(stdin_is_tty: bool) -> Result<(), anyhow::Error
/// daemon's shell_request handler mints a PTY-backed shell, and ssh handles
/// termios/PTY management.
///
/// Split from [`cmd_attach`] so the bare-`min` default dispatch
/// ([`cmd_default`]) and the smart-resolution picker can attach without
/// re-resolving an entry they already hold.
/// Split from [`cmd_attach`] so the activate-then-attach chain and the
/// smart-resolution picker can attach without re-resolving an entry they
/// already hold.
async fn attach_to_session(
sock: &std::path::Path,
id: sessions::SessionId,
Expand Down Expand Up @@ -2772,23 +2701,13 @@ mod tests {
ensure_interactive_attach_tty(true).expect("a real terminal must pass the guard");
}

/// Bare `min` on an empty daemon must refuse before creating a session when
/// it could not follow through with the interactive attach — under
/// `--no-input` or over a non-TTY stdin — so it never orphans a session
/// (#1031). Only a plain interactive invocation proceeds to activate.
/// A bare `min` must be inert: it prints the top-level help and succeeds,
/// touching no daemon and creating no session. The `Cli` command tree has
/// to stay renderable for that (a malformed clap definition panics in
/// `print_help`, not at parse time).
#[test]
fn bare_min_refuses_to_activate_on_empty_daemon_non_interactively() {
let err = ensure_activate_on_empty_allowed(true, true)
.unwrap_err()
.to_string();
assert!(
err.contains("no sessions exist") && err.contains("min session activate"),
"expected the explicit-verb refusal, got: {err}"
);
ensure_activate_on_empty_allowed(false, false)
.expect_err("a non-TTY stdin must be refused even without --no-input");
ensure_activate_on_empty_allowed(false, true)
.expect("an interactive terminal without --no-input must be allowed to activate");
fn bare_min_prints_help() {
cmd_default().expect("bare `min` must print help and succeed");
}

/// The attach/create confirmation prefers the session name and appends a
Expand Down
9 changes: 5 additions & 4 deletions docs/reference/cli-min.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ development sessions. Most commands start the daemon automatically when it
isn't running, with `bug`, `stop`, and `version` being the exceptions. The
daemon starts natively on Linux (and under `--provider local-minvmd`) or
inside the [`minvmd`](./cli-minvmd.md) microVM host daemon on macOS.
Running bare `min` with no subcommand resolves a session for the current
directory (activating one if needed) and attaches to it.
Running bare `min` with no subcommand prints this help and exits; use
`min session attach` to get into a session for the current directory, or
`min session activate` to create one.

Commands are spelled `min <noun> <verb>`, and every noun accepts its singular
and plural form (`session`/`sessions`, `loadout`/`loadouts`). Bare `min` and a
handful of bare verbs (`ls`, `stop`,
and plural form (`session`/`sessions`, `loadout`/`loadouts`). A handful of
bare verbs (`ls`, `stop`,
`init`, `add`, `update`) survive at the top level as deliberate ergonomic
exceptions, called out as such below; see
[the CLI convention](./cli.md#command-naming-convention) for the rule and the
Expand Down
3 changes: 2 additions & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,13 @@ Three rules follow from that:

### Documented exceptions

`min` with no arguments prints the top-level help.

A few high-traffic forms stay bare at the top level. These are deliberate
ergonomic choices, not leftovers — do not "fix" them:

| Form | Why it stays |
|------|--------------|
| `min` (no subcommand) | Resolve-or-activate-and-attach for the current directory: the single most common thing anyone does with `min`. |
| `min ls` | The highest-traffic command in the CLI; the break is not worth the consistency. |
| `min stop` | Acts on the daemon backend rather than any session, and is the daemon-lifecycle command people reach for. |
| `min init`, `min add`, `min update` | Passthroughs to the `mip` commands of the same name; keeping the spelling identical across the two CLIs beats the hierarchy. |
Expand Down