Skip to content

feat(cli): add headless mode for non-TTY environments#143

Open
SupremaLex wants to merge 1 commit into
Mr-Leshiy:mainfrom
SupremaLex:feat/headless-mode
Open

feat(cli): add headless mode for non-TTY environments#143
SupremaLex wants to merge 1 commit into
Mr-Leshiy:mainfrom
SupremaLex:feat/headless-mode

Conversation

@SupremaLex
Copy link
Copy Markdown

Problem

scell -d (and scell ls / stop / cleanup) crash with No such device or address (os error 6) when stdout is not a TTY:

$ scell -d /path/to/project
Error:
   0: No such device or address (os error 6)

This makes shell-cell unusable from CI, scripts, agents (e.g. Claude Code), nohup, container init systems — any environment without an interactive terminal. The --detach flag specifically advertises a non-interactive use case, yet was the most prominently broken path.

Why -d didn't work

src/cli/run/mod.rs calls Terminal::new() before the detach branch:

pub async fn run(...) -> Result<()> {
    let buildkit = BuildKitD::start().await?;
    let mut terminal = Terminal::new()?;   // ← always runs, even when detached
    let res = App::run(&buildkit, ..., detach, ..., &mut terminal).await;
    ratatui::try_restore()?;
    res
}

Terminal::new() enables crossterm raw mode (tcsetattr + ioctl on stdout fd). The kernel returns ENXIO when the fd isn't a real TTY, so Terminal::new() aborts before any detach logic runs.

The same pattern exists in ls/mod.rs, stop/mod.rs, and cleanup/mod.rs — all unconditionally initialise the TUI even though their work (listing/stopping/removing containers) is naturally plain text. So 4 of 5 subcommands were headless-unusable; only init worked (it never touched Terminal).

A latent panic in src/cli/run/app/preparing/ui.rs:45 compounded the problem under script-wrapped PTYs:

let area_width = area_width.saturating_sub(5);
line.chars().chunks(area_width)   // itertools panics on chunks(0)

When the render area is ≤5 cols (or 0, which can happen when terminal width can't be detected), the app panics with assertion failed: size != 0.

Why it works now

  1. Auto-detect non-TTY via std::io::IsTerminal on stdout in Cli::exec_inner. Non-TTY stdout implicitly enables --headless; --headless implicitly enables --detach for run (an interactive shell session still needs a TTY).
  2. Explicit --headless flag for forcing headless mode even on a TTY (useful for scripted invocations).
  3. run::run skips Terminal::new() when detached, dispatching to a new App::run_headless() that reuses the existing PreparingState — same tokio task, same channels — but drains build logs to stderr instead of rendering them. Business logic is shared with the TUI path; only the sink differs.
  4. PreparingState::prepare propagates UserError through tx in detach mode instead of hanging on future::pending() forever. TUI behaviour preserved when interactive.
  5. ls / stop / cleanup gain headless.rs siblings that call the same BuildKitD::{list,stop,cleanup}_* methods used by the TUI state machines and print plain text:
    • ls: tab-aligned table (NAME STATUS TARGET LOCATION FLAGS).
    • stop: stopped <name> per success / error: failed to stop <name>: <err> per failure / stopped N, failed M summary, non-zero exit on any failure.
    • cleanup: removed container/image <name> per item / same error+summary format / non-zero exit on any failure.
  6. chunks(area_width.saturating_sub(5).max(1)) clamps the preparing UI splitter so the latent size != 0 panic can't fire on tiny terminals.

Routing summary:

stdout flag run behaviour
TTY none TUI + attach interactive shell (unchanged)
TTY -d TUI + detach (unchanged)
TTY --headless headless, implicit detach
non-TTY any auto-headless, implicit detach
subcommand TTY non-TTY
init plain text (unchanged) plain text (unchanged)
ls TUI plain-text table
stop TUI line-per-action summary
cleanup TUI line-per-action summary
run TUI auto-headless build, container stays up

Verified

  • cargo build --release — clean
  • cargo clippy --all-targets --release — clean (strict pedantic config kept)
  • cargo test -- --test-threads 1 — 107 / 107 passed
  • Manual smoke from a non-TTY shell:
    $ scell <path>           # auto-detach, builds, container hangs
    $ scell ls               # plain table
    $ scell stop             # stopped <name> + summary
    $ scell cleanup --all    # removed container/image lines + summary
    

🤖 Generated with Claude Code

`scell -d` previously failed with `No such device or address (os error 6)`
when stdout was not a TTY (CI, scripts, agents, `nohup`). Root cause:
`run::run` unconditionally called `Terminal::new()`, which enables
crossterm raw mode via `tcsetattr` on stdout — `ENXIO` on non-TTY fds.
The detach flag was checked only later, so `-d` never had a chance.

The same `Terminal::new()` was called from `ls`, `stop`, and `cleanup`,
so all four TUI subcommands were unusable headless. The TUI `preparing`
renderer also panicked in `chunks(0)` when render area was ≤5 cols.

This change:

- Adds `--headless` flag plus auto-detect via `std::io::IsTerminal` on
  stdout. Non-TTY stdout implies `--headless`; `--headless` implies
  `--detach` for `run` (interactive shell needs a TTY).
- `run::run` now skips `Terminal::new()` when detached and dispatches
  to a new `App::run_headless()` that reuses the existing
  `PreparingState` channels and streams build logs to stderr.
- `PreparingState::prepare` propagates `UserError` through `tx` in
  detach mode instead of hanging on `future::pending()` — TUI behaviour
  preserved when interactive.
- New `headless` modules under `src/cli/{ls,stop,cleanup}` print
  plain-text output (table for `ls`, line-per-action for `stop`/
  `cleanup`). All share the same `BuildKitD` business logic as the TUI
  variants — only presentation differs.
- Clamps `chunks(area_width.saturating_sub(5).max(1))` in the
  preparing UI to avoid the latent `size != 0` panic on tiny terminals.

`init` already prints plain text — no change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant