diff --git a/crates/cli/src/agent_io.rs b/crates/cli/src/agent_io.rs new file mode 100644 index 000000000..71b2f7a14 --- /dev/null +++ b/crates/cli/src/agent_io.rs @@ -0,0 +1,271 @@ +//! Agent-mode I/O: JSONL emit for direct print sites under NO_DNA. +//! +//! Surfpool has two ways of producing user-visible output: +//! +//! 1. **Logger calls** (`info!`/`warn!`/`error!`) — flow through `fern` and +//! are formatted by the dispatch's format closure (see [`crate::cli::setup_logger`]). +//! 2. **Direct prints** (`println!`/`eprintln!`) — bypass the logger entirely. +//! +//! Under NO_DNA, both paths must produce the same JSONL contract. +//! The logger path is handled by the format closure; this module +//! handles direct prints via [`cli_emit`] and the `cli_info!`/`cli_warn!`/ +//! `cli_error!` macros. +//! +//! The JSON shape is `{"ts","level","target","msg"}` +//! - `ts` — RFC3339 UTC milliseconds +//! - `level` — lowercase string (`"trace"`/`"debug"`/`"info"`/`"warn"`/`"error"`) +//! - `target` — pseudo-event tag (caller-provided, e.g. `"surfpool.update.download"`) +//! - `msg` — `serde_json`-escaped string of the original human text +//! +//! Outside NO_DNA, `cli_emit` routes to `println!` (info/debug/trace) or +//! `eprintln!` (warn/error) — i.e. the legacy split human callers expect. + +use chrono::{DateTime, SecondsFormat, Utc}; + +use crate::no_dna::{AgentMode, OutputFormat}; + +/// Format an agent-mode JSON record at the given timestamp. +pub fn format_agent_json_at(ts: DateTime, level: &str, target: &str, msg: &str) -> String { + format_agent_json_with_data_at(ts, level, target, msg, None) +} + +/// Like [`format_agent_json_at`] but optionally includes a `data` object for +/// structured payloads (used by `surfpool ls` to attach runbook metadata). +/// +/// `msg` is passed through [`strip_ansi`] before serialization +/// (defense-in-depth against any caller that leaks color escapes into a CLI +/// emit; the primary line of defense is V38 in macros.rs). +pub fn format_agent_json_with_data_at( + ts: DateTime, + level: &str, + target: &str, + msg: &str, + data: Option, +) -> String { + let mut obj = serde_json::Map::with_capacity(5); + obj.insert( + "ts".into(), + serde_json::Value::String(ts.to_rfc3339_opts(SecondsFormat::Millis, true)), + ); + obj.insert("level".into(), serde_json::Value::String(level.to_string())); + obj.insert( + "target".into(), + serde_json::Value::String(target.to_string()), + ); + obj.insert("msg".into(), serde_json::Value::String(strip_ansi(msg))); + if let Some(d) = data { + obj.insert("data".into(), d); + } + serde_json::Value::Object(obj).to_string() +} + +/// Strip ANSI CSI escape sequences (e.g., color codes like `\x1b[35m`) from a +/// string. Used by [`format_agent_json_with_data_at`] to ensure no ANSI bytes +/// land in the JSON `msg` field even if a caller accidentally leaks them +fn strip_ansi(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == 0x1b { + if i + 1 < bytes.len() && bytes[i + 1] == b'[' { + // CSI: ESC [ params* final-byte (ASCII letter) + i += 2; + while i < bytes.len() && !bytes[i].is_ascii_alphabetic() { + i += 1; + } + if i < bytes.len() { + i += 1; + } + continue; + } + // Other ESC sequence — skip the ESC byte itself. + i += 1; + continue; + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8(out).unwrap_or_else(|_| s.to_string()) +} + +/// Write a single JSONL line to stderr in agent-mode shape. +/// +/// Bypasses the `log` crate so this works even before `setup_logger` has +/// installed the fern dispatch. +pub fn agent_emit(level: &str, target: &str, msg: &str) { + eprintln!("{}", format_agent_json_at(Utc::now(), level, target, msg)); +} + +/// Like [`agent_emit`] but attaches a `data` payload. Used by `surfpool ls` +/// to emit structured runbook records. +pub fn agent_emit_with_data(level: &str, target: &str, msg: &str, data: serde_json::Value) { + eprintln!( + "{}", + format_agent_json_with_data_at(Utc::now(), level, target, msg, Some(data)) + ); +} + +/// Emit a CLI line, branching on agent mode. +/// +/// Under NO_DNA: JSON to stderr via [`agent_emit`]. +/// Outside NO_DNA: plain text — `stderr` with a `Warning: ` / `Error: ` +/// prefix for `warn`/`error`, `stdout` for everything else. The prefix +/// preserves the legacy human-mode look from the `eprintln!("Warning: ...")` +/// sites this macro replaces; call sites pass the bare message +/// without a level prefix. +pub fn cli_emit(level: &str, target: &str, msg: &str) { + let mode = AgentMode::from_env(); + if mode.output_format == OutputFormat::Json { + agent_emit(level, target, msg); + } else { + match level { + "error" => eprintln!("Error: {msg}"), + "warn" => eprintln!("Warning: {msg}"), + _ => println!("{msg}"), + } + } +} + +/// `trace`-level direct emit. See [`cli_emit`]. +#[macro_export] +macro_rules! cli_trace { + ($target:literal, $($arg:tt)*) => { + $crate::agent_io::cli_emit("trace", $target, &format!($($arg)*)) + }; +} + +/// `debug`-level direct emit. See [`cli_emit`]. +#[macro_export] +macro_rules! cli_debug { + ($target:literal, $($arg:tt)*) => { + $crate::agent_io::cli_emit("debug", $target, &format!($($arg)*)) + }; +} + +/// `info`-level direct emit. See [`cli_emit`]. +#[macro_export] +macro_rules! cli_info { + ($target:literal, $($arg:tt)*) => { + $crate::agent_io::cli_emit("info", $target, &format!($($arg)*)) + }; +} + +/// `warn`-level direct emit. See [`cli_emit`]. +#[macro_export] +macro_rules! cli_warn { + ($target:literal, $($arg:tt)*) => { + $crate::agent_io::cli_emit("warn", $target, &format!($($arg)*)) + }; +} + +/// `error`-level direct emit. See [`cli_emit`]. +#[macro_export] +macro_rules! cli_error { + ($target:literal, $($arg:tt)*) => { + $crate::agent_io::cli_emit("error", $target, &format!($($arg)*)) + }; +} + +#[cfg(test)] +mod tests { + use chrono::TimeZone; + + use super::*; + + // agent_emit output parses as JSON via serde_json; + // all required fields present; msg escaped. + #[test] + fn format_agent_json_is_valid_json() { + let ts = Utc.with_ymd_and_hms(2026, 6, 4, 17, 30, 0).unwrap(); + let s = format_agent_json_at(ts, "info", "surfpool.test", "hello world"); + let parsed: serde_json::Value = serde_json::from_str(&s).expect("must be valid JSON"); + assert_eq!(parsed["level"], "info"); + assert_eq!(parsed["target"], "surfpool.test"); + assert_eq!(parsed["msg"], "hello world"); + assert!(parsed["ts"].is_string()); + } + + #[test] + fn format_agent_json_escapes_msg() { + let ts = Utc.with_ymd_and_hms(2026, 6, 4, 17, 30, 0).unwrap(); + let s = format_agent_json_at(ts, "error", "surfpool.test", "line1\nline2 \"quoted\""); + let parsed: serde_json::Value = serde_json::from_str(&s).expect("must be valid JSON"); + assert_eq!(parsed["msg"], "line1\nline2 \"quoted\""); + // Raw string must NOT contain bare newlines or unescaped quotes. + assert!(!s.contains("line1\nline2"), "newlines must be escaped"); + } + + #[test] + fn timestamp_is_rfc3339_utc_with_z_suffix() { + let ts = Utc.with_ymd_and_hms(2026, 6, 4, 17, 30, 0).unwrap(); + let s = format_agent_json_at(ts, "info", "t", "m"); + let parsed: serde_json::Value = serde_json::from_str(&s).unwrap(); + let ts_str = parsed["ts"].as_str().unwrap(); + assert!( + ts_str.ends_with('Z'), + "RFC3339 UTC must end with Z, got {ts_str}" + ); + assert!( + ts_str.starts_with("2026-06-04T17:30:00"), + "expected fixed timestamp, got {ts_str}" + ); + // Re-parse to confirm round-trip + let _: DateTime = ts_str.parse().expect("must round-trip parse"); + } + + #[test] + fn agent_json_contains_no_ansi_escapes() { + let ts = Utc.with_ymd_and_hms(2026, 6, 4, 17, 30, 0).unwrap(); + // Pass a message that COULD contain colors if we accidentally let them through. + let s = format_agent_json_at(ts, "warn", "surfpool.test", "plain msg"); + assert!( + !s.contains('\x1b'), + "JSON output must contain no ANSI ESC byte (0x1b)" + ); + } + + // Defensive — strip ANSI escapes from `msg` so a leaky + // caller (color macros, third-party libs) cannot pollute the JSON shape. + #[test] + fn strip_ansi_removes_csi_sequences() { + assert_eq!(super::strip_ansi("\x1b[35mfoo\x1b[0m"), "foo"); + assert_eq!(super::strip_ansi("plain"), "plain"); + assert_eq!( + super::strip_ansi("\x1b[1;31merror:\x1b[0m hello"), + "error: hello" + ); + assert_eq!(super::strip_ansi(""), ""); + } + + #[test] + fn format_agent_json_strips_ansi_in_msg() { + let ts = Utc.with_ymd_and_hms(2026, 6, 4, 17, 30, 0).unwrap(); + // Simulate what would happen if a caller leaked ANSI bytes into msg. + let s = format_agent_json_at( + ts, + "info", + "surfpool.test", + "\x1b[35m→\x1b[0m \x1b[35mdeploy\x1b[0m - done", + ); + let parsed: serde_json::Value = serde_json::from_str(&s).expect("must be valid JSON"); + let msg = parsed["msg"].as_str().unwrap(); + assert!( + !msg.contains('\x1b'), + "msg field must not contain ANSI ESC after strip_ansi: {msg:?}" + ); + assert!( + !msg.contains("[35m"), + "CSI params must be stripped: {msg:?}" + ); + assert_eq!(msg, "→ deploy - done"); + } + + #[test] + fn format_agent_json_handles_unicode_in_msg() { + let ts = Utc.with_ymd_and_hms(2026, 6, 4, 17, 30, 0).unwrap(); + let s = format_agent_json_at(ts, "info", "t", "✓ done — 日本語"); + let parsed: serde_json::Value = serde_json::from_str(&s).expect("must be valid JSON"); + assert_eq!(parsed["msg"], "✓ done — 日本語"); + } +} diff --git a/crates/cli/src/cli/mod.rs b/crates/cli/src/cli/mod.rs index 53d9fe987..de957a0ef 100644 --- a/crates/cli/src/cli/mod.rs +++ b/crates/cli/src/cli/mod.rs @@ -31,7 +31,12 @@ use surfpool_types::{ use txtx_core::manifest::WorkspaceManifest; use txtx_gql::kit::{helpers::fs::FileLocation, types::frontend::LogLevel}; -use crate::{cli::update::handle_update_command, runbook::handle_execute_runbook_command}; +use crate::{ + agent_io::format_agent_json_at, + cli::update::handle_update_command, + no_dna::{AgentMode, ColorChoice, OutputFormat}, + runbook::handle_execute_runbook_command, +}; mod simnet; mod update; @@ -105,7 +110,25 @@ impl Context { } #[derive(Parser, Debug)] -#[clap(author, version, about, long_about = None, name = "surfpool", bin_name = "surfpool")] +#[clap( + author, + version, + about, + long_about = "Surfpool — a local Solana surfnet for development and testing.\n\ + \n\ + AGENT MODE (NO_DNA)\n\ + Set the NO_DNA environment variable to any non-empty value to run \ + non-interactively for AI agents and automation. Disables interactive \ + prompts, the TUI, spinners, and progress bars; auto-selects \ + scaffolding defaults; routes log output to stderr as JSONL with \ + RFC3339 UTC timestamps; and disables ANSI color. NO_COLOR is honored \ + independently. Detection follows the NO_DNA standard at \ + https://no-dna.org — any non-empty value activates, including NO_DNA=0 \ + (same semantics as NO_COLOR). NO_DNA is orthogonal to --ci; both can \ + be set and apply independently.", + name = "surfpool", + bin_name = "surfpool" +)] struct Opts { #[clap(subcommand)] command: Command, @@ -868,6 +891,13 @@ impl ExecuteRunbook { } pub fn main() { + // AgentMode's OnceLock before any logger init so downstream `setup_logger` + // calls (cli/mod.rs and runbook/mod.rs) see the same cached value. + // The hiro_system_kit early init below is best-effort our fern dispatch + // replaces the global logger inside handle_command and is where + // stderr routing actually takes effect. + let _agent_mode = AgentMode::from_env(); + let logger = hiro_system_kit::log::setup_logger(); let _guard = hiro_system_kit::log::setup_global_logger(logger.clone()); let ctx = Context { @@ -884,7 +914,9 @@ pub fn main() { }; if let Err(e) = handle_command(opts, &ctx) { - eprintln!("Error: {e}"); + // cli_emit (under cli_error!) prepends "Error: " automatically in + // human mode. Agent mode gets `{"level":"error",...}`. + crate::cli_error!("surfpool.cli", "{e}"); std::thread::sleep(std::time::Duration::from_millis(500)); process::exit(1); } @@ -898,15 +930,50 @@ pub async fn handle_mcp_command(_ctx: &Context) -> Result<(), String> { Ok(()) } +/// Apply NO_DNA agent-mode rewrites to a `start` (Simnet) command. See +/// Under agent mode we force the no-TUI and auto-runbook-scaffolding knobs +/// that the user would otherwise toggle via CLI flags. +fn apply_agent_mode_to_simnet(cmd: &mut StartSimnet, agent_mode: &AgentMode) { + if agent_mode.is_active() { + cmd.runtime.no_tui = true; + cmd.project.skip_runbook_generation_prompts = true; + } +} + +/// Apply NO_DNA agent-mode rewrites to a `run` (ExecuteRunbook) command +fn apply_agent_mode_to_run(cmd: &mut ExecuteRunbook, agent_mode: &AgentMode) { + if agent_mode.is_active() { + cmd.unsupervised = true; + } +} + +/// Apply NO_DNA agent-mode rewrites to an `update` command +fn apply_agent_mode_to_update(cmd: &mut UpdateCommand, agent_mode: &AgentMode) { + if agent_mode.is_active() { + cmd.skip_confirm = true; + } +} + +/// Apply `--ci` rewrites to a `start` (Simnet) command. Extracted so that +/// composition with `apply_agent_mode_to_simnet` is testable; +/// these two helpers are independent inputs and should both apply when set. +fn apply_ci_mode_to_simnet(cmd: &mut StartSimnet) { + if cmd.runtime.ci { + cmd.observability.disable_instruction_profiling = true; + cmd.runtime.no_studio = true; + cmd.runtime.no_tui = true; + cmd.observability.log_level = "none".to_string(); + } +} + fn handle_command(opts: Opts, ctx: &Context) -> Result<(), String> { + // Resolve NO_DNA agent mode once for the whole command invocation. See + let agent_mode = AgentMode::from_env(); + match opts.command { Command::Simnet(mut cmd) => { - if cmd.runtime.ci { - cmd.observability.disable_instruction_profiling = true; - cmd.runtime.no_studio = true; - cmd.runtime.no_tui = true; - cmd.observability.log_level = "none".to_string(); - } + apply_agent_mode_to_simnet(&mut cmd, &agent_mode); + apply_ci_mode_to_simnet(&mut cmd); if cmd.runtime.daemon { // The only way to support daemon mode on macos is to either: @@ -915,7 +982,10 @@ fn handle_command(opts: Opts, ctx: &Context) -> Result<(), String> { // Known issue: https://github.com/firebase/firebase-tools/issues/6628 // Both of these options are confusing for users, so we just emit a warning and disable daemon mode if !cfg!(target_os = "linux") { - println!("Daemon mode is only supported on Linux"); + crate::cli_warn!( + "surfpool.cli.daemon", + "Daemon mode is only supported on Linux" + ); cmd.runtime.daemon = false; } else { cmd.runtime.no_tui = true; @@ -929,6 +999,7 @@ fn handle_command(opts: Opts, ctx: &Context) -> Result<(), String> { "simnet", &cmd.observability.log_level, cmd.runtime.no_tui, + &agent_mode, )?; } @@ -953,12 +1024,41 @@ fn handle_command(opts: Opts, ctx: &Context) -> Result<(), String> { Command::Completions(cmd) => { hiro_system_kit::nestable_block_on(generate_completion_helpers(cmd)) } - Command::Run(cmd) => { + Command::Run(mut cmd) => { + apply_agent_mode_to_run(&mut cmd, &agent_mode); hiro_system_kit::nestable_block_on(handle_execute_runbook_command(cmd)) } - Command::List(cmd) => hiro_system_kit::nestable_block_on(handle_list_command(cmd, ctx)), + Command::List(cmd) => { + // Under NO_DNA, ensure fern is initialized so any + // log!() calls in handle_list_command (or its txtx descendants) + // flow through the JSON format closure to stderr. + if agent_mode.is_active() { + setup_logger( + DEFAULT_LOG_DIR.as_str(), + None, + "list", + "info", + true, + &agent_mode, + )?; + } + hiro_system_kit::nestable_block_on(handle_list_command(cmd, ctx)) + } Command::Mcp => hiro_system_kit::nestable_block_on(handle_mcp_command(ctx)), - Command::Update(cmd) => hiro_system_kit::nestable_block_on(handle_update_command(cmd)), + Command::Update(mut cmd) => { + apply_agent_mode_to_update(&mut cmd, &agent_mode); + if agent_mode.is_active() { + setup_logger( + DEFAULT_LOG_DIR.as_str(), + None, + "update", + "info", + true, + &agent_mode, + )?; + } + hiro_system_kit::nestable_block_on(handle_update_command(cmd)) + } } } @@ -999,30 +1099,96 @@ async fn generate_completion_helpers(cmd: Completions) -> Result<(), String> { async fn handle_list_command(cmd: ListRunbooks, _ctx: &Context) -> Result<(), String> { let manifest_location = FileLocation::from_path_string(&cmd.manifest_path)?; let manifest = WorkspaceManifest::from_location(&manifest_location)?; + let agent_mode = AgentMode::from_env(); + if manifest.runbooks.is_empty() { - println!( - "{}: no runbooks referenced in the txtx.yml manifest.\nRun the command `txtx new` to create a new runbook.", - yellow!("warning") - ); + if agent_mode.is_active() { + crate::cli_warn!( + "surfpool.ls", + "no runbooks referenced in the txtx.yml manifest. Run `txtx new` to create one." + ); + } else { + println!( + "{}: no runbooks referenced in the txtx.yml manifest.\nRun the command `txtx new` to create a new runbook.", + yellow!("warning") + ); + } std::process::exit(1); } - println!("{:<35}\t{}", "Name", yellow!("Description")); - for runbook in manifest.runbooks { - println!( - "{:<35}\t{}", - runbook.name, - yellow!(format!("{}", runbook.description.unwrap_or("".into()))) - ); + + if agent_mode.is_active() { + // One JSON object per runbook to stderr (consistent JSONL contract). + // Tabular human output is replaced; the `data` field carries + // structured metadata so agents can parse without scraping. + for runbook in manifest.runbooks { + crate::agent_io::agent_emit_with_data( + "info", + "surfpool.ls.runbook", + &runbook.name, + runbook_to_agent_json_data( + &runbook.name, + runbook.description.as_deref(), + &runbook.location, + ), + ); + } + } else { + println!("{:<35}\t{}", "Name", yellow!("Description")); + for runbook in manifest.runbooks { + println!( + "{:<35}\t{}", + runbook.name, + yellow!(format!("{}", runbook.description.unwrap_or("".into()))) + ); + } } Ok(()) } +/// Build the `data` payload for a runbook listing under NO_DNA. +pub(crate) fn runbook_to_agent_json_data( + name: &str, + description: Option<&str>, + location: &str, +) -> serde_json::Value { + serde_json::json!({ + "name": name, + "description": description, + "location": location, + }) +} + +/// Where the logger's console branch writes records. The `Stderr` arm is +/// selected when [`AgentMode`] is active: agents must see log +/// output on stderr so stdout stays clean for any structured output added in future. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LogConsoleTarget { + Stdout, + Stderr, + None, +} + +/// Pure function exposing the routing decision so it can be unit-tested +/// without touching `fern::Dispatch` (which is not introspectable). Under +/// NO_DNA we always route to stderr regardless of `log_to_stdout` — agents +/// need console visibility, just on the correct stream. +pub(crate) fn log_console_target(log_to_stdout: bool, agent_mode: &AgentMode) -> LogConsoleTarget { + if agent_mode.is_active() { + LogConsoleTarget::Stderr + } else if log_to_stdout { + LogConsoleTarget::Stdout + } else { + LogConsoleTarget::None + } +} + pub fn setup_logger( log_dir: &str, environment_selector: Option<&str>, filename: &str, log_filter: &str, log_to_stdout: bool, + agent_mode: &AgentMode, ) -> Result<(), String> { let log_location = { let mut log_location = FileLocation::from_path_string(log_dir)?; @@ -1074,25 +1240,55 @@ pub fn setup_logger( .map_err(|e| format!("Failed to create log file: {}", e))?, ); - // Stdout branch: filtered to only txtx/surfopol target, minimal + colored format - let stdout_config = fern::Dispatch::new() - .filter(|metadata| { - metadata.target().starts_with("txtx") || metadata.target().starts_with("surfpool") - }) - .format(move |out, message, record| { - out.finish(format_args!( - "{} {} {}", - Local::now().format("%b %d %H:%M:%S%.3f"), - colors.color(record.level()), - message - )) + // Console branch routing: + let agent_mode_for_console = *agent_mode; + let console_dispatch_base = fern::Dispatch::new() + .filter(move |metadata| { + if agent_mode_for_console.is_active() { + true + } else { + metadata.target().starts_with("txtx") || metadata.target().starts_with("surfpool") + } }) - .chain(std::io::stdout()); + .format( + move |out, message, record| match agent_mode_for_console.output_format { + OutputFormat::Json => { + out.finish(format_args!( + "{}", + format_agent_json_at( + chrono::Utc::now(), + &record.level().to_string().to_lowercase(), + record.target(), + &message.to_string(), + ) + )); + } + OutputFormat::Human => match agent_mode_for_console.color { + ColorChoice::Never => out.finish(format_args!( + "{} {} {}", + Local::now().format("%b %d %H:%M:%S%.3f"), + record.level(), + message + )), + ColorChoice::Always | ColorChoice::Auto => out.finish(format_args!( + "{} {} {}", + Local::now().format("%b %d %H:%M:%S%.3f"), + colors.color(record.level()), + message + )), + }, + }, + ); - let mut builder = fern::Dispatch::new().level(log_filter).chain(file_config); + let console_chain = match log_console_target(log_to_stdout, agent_mode) { + LogConsoleTarget::Stdout => Some(console_dispatch_base.chain(std::io::stdout())), + LogConsoleTarget::Stderr => Some(console_dispatch_base.chain(std::io::stderr())), + LogConsoleTarget::None => None, + }; - if log_to_stdout { - builder = builder.chain(stdout_config) + let mut builder = fern::Dispatch::new().level(log_filter).chain(file_config); + if let Some(console) = console_chain { + builder = builder.chain(console); } builder @@ -1206,4 +1402,206 @@ mod tests { let cmd = parse_start(&["surfpool", "start", "--subgraph-db", "./legacy.sqlite"]); assert_eq!(cmd.subgraph_db.as_deref(), Some("./legacy.sqlite")); } + + fn parse_run(args: &[&str]) -> ExecuteRunbook { + match Opts::try_parse_from(args) + .expect("run args should parse") + .command + { + Command::Run(cmd) => cmd, + command => panic!("expected run command, got {command:?}"), + } + } + + fn parse_update(args: &[&str]) -> UpdateCommand { + match Opts::try_parse_from(args) + .expect("update args should parse") + .command + { + Command::Update(cmd) => cmd, + command => panic!("expected update command, got {command:?}"), + } + } + + const ACTIVE: AgentMode = AgentMode::const_active(); + const INACTIVE: AgentMode = AgentMode::const_inactive(); + + #[test] + fn no_dna_forces_simnet_no_tui_and_skip_runbook_gen() { + let mut cmd = parse_start(&["surfpool", "start"]); + assert!(!cmd.runtime.no_tui, "baseline: no_tui should default off"); + assert!( + !cmd.project.skip_runbook_generation_prompts, + "baseline: skip_runbook_generation_prompts should default off" + ); + apply_agent_mode_to_simnet(&mut cmd, &ACTIVE); + assert!(cmd.runtime.no_tui); + assert!(cmd.project.skip_runbook_generation_prompts); + } + + #[test] + fn inactive_agent_mode_leaves_simnet_flags_untouched() { + let mut cmd = parse_start(&["surfpool", "start"]); + apply_agent_mode_to_simnet(&mut cmd, &INACTIVE); + assert!(!cmd.runtime.no_tui); + assert!(!cmd.project.skip_runbook_generation_prompts); + } + + #[test] + fn no_dna_forces_run_unsupervised() { + let mut cmd = parse_run(&["surfpool", "run", "deployment"]); + assert!(!cmd.unsupervised, "baseline: unsupervised default off"); + apply_agent_mode_to_run(&mut cmd, &ACTIVE); + assert!(cmd.unsupervised); + } + + #[test] + fn inactive_agent_mode_leaves_run_unsupervised_untouched() { + let mut cmd = parse_run(&["surfpool", "run", "deployment"]); + apply_agent_mode_to_run(&mut cmd, &INACTIVE); + assert!(!cmd.unsupervised); + } + + #[test] + fn no_dna_forces_update_skip_confirm() { + let mut cmd = parse_update(&["surfpool", "update"]); + assert!(!cmd.skip_confirm, "baseline: skip_confirm default off"); + apply_agent_mode_to_update(&mut cmd, &ACTIVE); + assert!(cmd.skip_confirm); + } + + #[test] + fn inactive_agent_mode_leaves_update_skip_confirm_untouched() { + let mut cmd = parse_update(&["surfpool", "update"]); + apply_agent_mode_to_update(&mut cmd, &INACTIVE); + assert!(!cmd.skip_confirm); + } + + #[test] + fn top_level_help_documents_no_dna() { + let mut command = Opts::command(); + let mut buffer = Vec::new(); + command + .write_long_help(&mut buffer) + .expect("top-level long help should render"); + let help = String::from_utf8(buffer).expect("help should be utf8"); + assert!(help.contains("NO_DNA"), "long help should mention NO_DNA"); + assert!( + help.contains("https://no-dna.org"), + "long help should link to https://no-dna.org" + ); + } + + // NO_DNA and --ci are independent inputs. when both + // active, all flag rewrites from both apply (idempotent on shared keys + // like no_tui). this guards against future refactors collapsing the two + // helpers (`apply_agent_mode_to_simnet`, `apply_ci_mode_to_simnet`) into + // a single conditional that drops one set of rewrites. + #[test] + fn no_dna_and_ci_compose_for_simnet() { + let mut cmd = parse_start(&["surfpool", "start", "--ci"]); + apply_agent_mode_to_simnet(&mut cmd, &ACTIVE); + apply_ci_mode_to_simnet(&mut cmd); + + assert!(cmd.runtime.no_tui, "both NO_DNA and --ci force no_tui"); + assert!( + cmd.project.skip_runbook_generation_prompts, + "NO_DNA forces skip_runbook_generation_prompts; --ci does not" + ); + assert!( + cmd.observability.disable_instruction_profiling, + "--ci forces disable_instruction_profiling; NO_DNA does not" + ); + assert!(cmd.runtime.no_studio, "--ci forces no_studio"); + assert_eq!( + cmd.observability.log_level, "none", + "--ci sets log_level=none" + ); + } + + #[test] + fn ci_alone_does_not_set_skip_runbook_generation_prompts() { + let mut cmd = parse_start(&["surfpool", "start", "--ci"]); + apply_agent_mode_to_simnet(&mut cmd, &INACTIVE); + apply_ci_mode_to_simnet(&mut cmd); + + assert!( + !cmd.project.skip_runbook_generation_prompts, + "without NO_DNA, --ci alone must NOT set skip_runbook_generation_prompts" + ); + assert!(cmd.runtime.no_studio, "--ci still applies its own rewrites"); + } + + #[test] + fn agent_mode_alone_does_not_set_ci_rewrites() { + let mut cmd = parse_start(&["surfpool", "start"]); + apply_agent_mode_to_simnet(&mut cmd, &ACTIVE); + apply_ci_mode_to_simnet(&mut cmd); + + assert!(cmd.runtime.no_tui, "NO_DNA forces no_tui"); + assert!( + !cmd.observability.disable_instruction_profiling, + "without --ci, NO_DNA alone must NOT set disable_instruction_profiling" + ); + assert!( + !cmd.runtime.no_studio, + "without --ci, NO_DNA alone must NOT set no_studio" + ); + assert_ne!( + cmd.observability.log_level, "none", + "without --ci, NO_DNA alone must NOT silence the log_level" + ); + } + + // Under NO_DNA the fern dispatch must route the console + // branch to stderr (never stdout), regardless of log_to_stdout. fern's + // Dispatch is opaque, so V25 is encoded in the pure helper this matrix tests. + // `surfpool ls` under NO_DNA emits one JSON object per + // runbook with a populated `data` field. Tests the pure helper so the + // test doesn't have to construct a WorkspaceManifest or capture stderr. + #[test] + fn runbook_to_agent_json_data_contains_required_fields() { + let data = super::runbook_to_agent_json_data( + "deploy_hello", + Some("Deploy the hello-world program"), + "runbooks/deployment/main.tx", + ); + assert_eq!(data["name"], "deploy_hello"); + assert_eq!(data["description"], "Deploy the hello-world program"); + assert_eq!(data["location"], "runbooks/deployment/main.tx"); + } + + #[test] + fn runbook_to_agent_json_data_handles_missing_description() { + let data = super::runbook_to_agent_json_data("scratch", None, "runbooks/scratch.tx"); + assert!( + data["description"].is_null(), + "missing description → JSON null" + ); + assert_eq!(data["name"], "scratch"); + } + + #[test] + fn log_console_target_routes_to_stderr_under_agent_mode() { + assert_eq!( + log_console_target(true, &ACTIVE), + LogConsoleTarget::Stderr, + "NO_DNA + log_to_stdout=true must route to stderr" + ); + assert_eq!( + log_console_target(false, &ACTIVE), + LogConsoleTarget::Stderr, + "NO_DNA + log_to_stdout=false must STILL route to stderr (V25 forces visibility)" + ); + assert_eq!( + log_console_target(true, &INACTIVE), + LogConsoleTarget::Stdout, + "no NO_DNA + log_to_stdout=true must route to stdout (legacy path)" + ); + assert_eq!( + log_console_target(false, &INACTIVE), + LogConsoleTarget::None, + "no NO_DNA + log_to_stdout=false must route nowhere (file-only legacy path)" + ); + } } diff --git a/crates/cli/src/cli/simnet/mod.rs b/crates/cli/src/cli/simnet/mod.rs index ffa9c8f25..bab1337a3 100644 --- a/crates/cli/src/cli/simnet/mod.rs +++ b/crates/cli/src/cli/simnet/mod.rs @@ -235,7 +235,7 @@ pub async fn handle_start_local_surfnet_command( let initial_transactions = loop { match simnet_events_rx.recv() { Ok(SimnetEvent::Aborted(error)) => { - eprintln!("Error: {}", error); + crate::cli_error!("surfpool.simnet", "{}", error); return Err(error); } Ok(SimnetEvent::Shutdown) => return Ok(()), @@ -435,6 +435,10 @@ fn log_events( } else { LogLevel::Info }; + // Under NO_DNA, this path is reached (no_tui is forced) but `handle_log_event` + // short-circuits transient events under agent mode and never inserts a + // ProgressBar. The IndexMap + MultiProgress stay empty — neither renders + // without children — so no UI escapes. let mut active_spinners: IndexMap = IndexMap::new(); let mut multi_progress = MultiProgress::new(); diff --git a/crates/cli/src/cli/update/mod.rs b/crates/cli/src/cli/update/mod.rs index 7e3869f34..a50460f0a 100644 --- a/crates/cli/src/cli/update/mod.rs +++ b/crates/cli/src/cli/update/mod.rs @@ -10,7 +10,7 @@ use serde::Deserialize; use sha2::{Digest, Sha256}; use tar::Archive; -use crate::cli::UpdateCommand; +use crate::{cli::UpdateCommand, cli_info, cli_warn, no_dna::AgentMode}; #[derive(Deserialize, Debug)] struct LatestRelease { @@ -73,21 +73,29 @@ pub async fn handle_update_command(cmd: UpdateCommand) -> Result<(), String> { } if cmd.version.is_none() && current_semver > target_semver { - println!("Already on the latest version {}", current_semver); + cli_info!( + "surfpool.update", + "Already on the latest version {}", + current_semver + ); return Ok(()); } let expected_digest: Option<[u8; 32]> = match &asset.digest { None => { - eprintln!( - "Warning: release asset {users_asset} has no checksum, so the integrity of the release cannot be verified" + cli_warn!( + "surfpool.update.verify", + "release asset {users_asset} has no checksum, so the integrity of the release cannot be verified" ); None } Some(d) => match parse_sha256_digest(d) { Ok(bytes) => Some(bytes), Err(e) => { - eprintln!("Warning: {e}; the integrity of the release cannot be verified"); + cli_warn!( + "surfpool.update.verify", + "{e}; the integrity of the release cannot be verified" + ); None } }, @@ -109,34 +117,58 @@ pub async fn handle_update_command(cmd: UpdateCommand) -> Result<(), String> { .map_err(|e| format!("Failed to read confirmation: {e}"))?; if !confirm { - println!("Update cancelled"); + cli_info!("surfpool.update", "Update cancelled"); return Ok(()); } } - println!("Download URL: {}", browser_download_url); + cli_info!( + "surfpool.update.download", + "Download URL: {}", + browser_download_url + ); let response = client .get(browser_download_url) .send() .await .map_err(|e| e.to_string())?; let total_size = response.content_length().unwrap_or(0); - let progress_bar = ProgressBar::new(total_size); - progress_bar.set_style( - ProgressStyle::default_bar() - .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})") - .unwrap() - .progress_chars("#>-"), - ); + + // Under NO_DNA the progress bar is NOT constructed (skip, don't + // hide-after-construct). Agents still need download visibility, so + // emit start/end markers via cli_info!. + let agent_mode = AgentMode::from_env(); + let progress_bar = if agent_mode.is_active() { + cli_info!("surfpool.update.download", "Downloading {total_size} bytes"); + None + } else { + let pb = ProgressBar::new(total_size); + pb.set_style( + ProgressStyle::default_bar() + .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})") + .unwrap() + .progress_chars("#>-"), + ); + Some(pb) + }; let mut download: Vec = Vec::with_capacity(total_size as usize); let mut stream = response.bytes_stream(); while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|e| e.to_string())?; download.extend_from_slice(&chunk); - progress_bar.set_position(download.len() as u64); + if let Some(pb) = &progress_bar { + pb.set_position(download.len() as u64); + } + } + match &progress_bar { + Some(pb) => pb.finish_with_message("Download complete"), + None => cli_info!( + "surfpool.update.download", + "Download complete: {} bytes", + download.len() + ), } - progress_bar.finish_with_message("Download complete"); // Roots trust in api.github.com's TLS: the digest comes from the same // API response as browser_download_url. Stronger guarantees (signed diff --git a/crates/cli/src/macros.rs b/crates/cli/src/macros.rs index f9ec78c0d..3d9d8a0bb 100644 --- a/crates/cli/src/macros.rs +++ b/crates/cli/src/macros.rs @@ -1,3 +1,7 @@ +// Color macros gate on BOTH `atty::is(Stream::Stdout)` AND +// `AgentMode::color != ColorChoice::Never`. Without the AgentMode check, ANSI +// escapes leak into the JSONL `msg` field under NO_DNA when stdout is a TTY + #[allow(unused_macros)] #[macro_export] macro_rules! green { @@ -5,17 +9,13 @@ macro_rules! green { { use atty::Stream; use ansi_term::Colour; - if atty::is(Stream::Stdout) { + if atty::is(Stream::Stdout) + && $crate::no_dna::AgentMode::from_env().color != $crate::no_dna::ColorChoice::Never + { let colour = Colour::Green.bold(); - format!( - "{}", - colour.paint($($arg)*) - ) + format!("{}", colour.paint($($arg)*)) } else { - format!( - "{}", - $($arg)* - ) + format!("{}", $($arg)*) } } ) @@ -28,17 +28,13 @@ macro_rules! red { { use atty::Stream; use ansi_term::Colour; - if atty::is(Stream::Stdout) { + if atty::is(Stream::Stdout) + && $crate::no_dna::AgentMode::from_env().color != $crate::no_dna::ColorChoice::Never + { let colour = Colour::Red.bold(); - format!( - "{}", - colour.paint($($arg)*) - ) + format!("{}", colour.paint($($arg)*)) } else { - format!( - "{}", - $($arg)* - ) + format!("{}", $($arg)*) } } ) @@ -51,17 +47,13 @@ macro_rules! yellow { { use atty::Stream; use ansi_term::Colour; - if atty::is(Stream::Stdout) { + if atty::is(Stream::Stdout) + && $crate::no_dna::AgentMode::from_env().color != $crate::no_dna::ColorChoice::Never + { let colour = Colour::Yellow.bold(); - format!( - "{}", - colour.paint($($arg)*) - ) + format!("{}", colour.paint($($arg)*)) } else { - format!( - "{}", - $($arg)* - ) + format!("{}", $($arg)*) } } ) @@ -74,17 +66,13 @@ macro_rules! blue { { use atty::Stream; use ansi_term::Colour; - if atty::is(Stream::Stdout) { + if atty::is(Stream::Stdout) + && $crate::no_dna::AgentMode::from_env().color != $crate::no_dna::ColorChoice::Never + { let colour = Colour::Cyan.bold(); - format!( - "{}", - colour.paint($($arg)*) - ) + format!("{}", colour.paint($($arg)*)) } else { - format!( - "{}", - $($arg)* - ) + format!("{}", $($arg)*) } } ) @@ -97,17 +85,13 @@ macro_rules! purple { { use atty::Stream; use ansi_term::Colour; - if atty::is(Stream::Stdout) { + if atty::is(Stream::Stdout) + && $crate::no_dna::AgentMode::from_env().color != $crate::no_dna::ColorChoice::Never + { let colour = Colour::Purple.bold(); - format!( - "{}", - colour.paint($($arg)*) - ) + format!("{}", colour.paint($($arg)*)) } else { - format!( - "{}", - $($arg)* - ) + format!("{}", $($arg)*) } } ) @@ -120,17 +104,13 @@ macro_rules! black { { use atty::Stream; use ansi_term::Colour; - if atty::is(Stream::Stdout) { + if atty::is(Stream::Stdout) + && $crate::no_dna::AgentMode::from_env().color != $crate::no_dna::ColorChoice::Never + { let colour = Colour::Fixed(244); - format!( - "{}", - colour.paint($($arg)*) - ) + format!("{}", colour.paint($($arg)*)) } else { - format!( - "{}", - $($arg)* - ) + format!("{}", $($arg)*) } } ) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 27ce6ad03..0bf2e1426 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -3,9 +3,11 @@ mod macros; extern crate hiro_system_kit; +mod agent_io; mod cli; // mod manifest; mod http; +mod no_dna; mod runbook; mod scaffold; mod tui; diff --git a/crates/cli/src/no_dna.rs b/crates/cli/src/no_dna.rs new file mode 100644 index 000000000..d8c1ef2f9 --- /dev/null +++ b/crates/cli/src/no_dna.rs @@ -0,0 +1,261 @@ +//! NO_DNA agent-mode detection. +//! +//! Implements the [NO_DNA](https://no-dna.org) informal standard for detecting +//! non-human operators (AI agents, automation). When `NO_DNA` is set to any +//! non-empty value the caller signals it is an agent and the CLI drops +//! interactive UX, hides spinners/progress bars, routes log output to stderr, +//! emits JSONL on the console branch, and uses RFC3339 UTC timestamps. +//! +//! Detection follows the spec literally: **set and non-empty**. Truthy parsing +//! (`1|true|yes|on`) is explicitly rejected — `NO_DNA=0` activates agent mode, +//! mirroring [`NO_COLOR`](https://no-color.org) semantics. +//! +//! Color handling honors `NO_COLOR` independently: either env var disables +//! ANSI escapes in console output. + +use std::sync::OnceLock; + +const ENV_VAR_NO_DNA: &str = "NO_DNA"; +const ENV_VAR_NO_COLOR: &str = "NO_COLOR"; + +static AGENT_MODE: OnceLock = OnceLock::new(); + +/// Output format selected by the agent-mode boundary. +/// +/// `Json` emits JSONL records on the console branch: +/// `{"ts","level","target","msg"}`. `Human` keeps the legacy human-readable +/// format (used outside NO_DNA). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum OutputFormat { + #[default] + Human, + Json, +} + +/// Color choice for ANSI escapes in console output. +/// +/// `Never` is selected when NO_DNA OR NO_COLOR is set. `Always` is reserved +/// for the legacy unconditional-color behavior; `Auto` is the default +/// outside agent mode (currently the same as `Always` per fern's behavior). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ColorChoice { + Always, + Never, + #[default] + Auto, +} + +/// Timestamp format for log records. +/// +/// `Rfc3339Utc` emits e.g. `2026-06-04T16:57:00.123Z` and is selected under +/// NO_DNA. `Local` is the legacy human-readable format used outside agent +/// mode. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum TimestampFmt { + #[default] + Local, + Rfc3339Utc, +} + +/// Snapshot of the caller-disclosed execution environment. +/// +/// Populated once per process from `NO_DNA` + `NO_COLOR`. Consumers should +/// take `&AgentMode`, not bare bools, so future fields do not ripple +/// through call sites. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AgentMode { + /// `true` when the caller has signaled non-human operation via `NO_DNA`. + pub active: bool, + /// Output format for the console branch of the logger and direct emit sites. + pub output_format: OutputFormat, + /// ANSI color choice. `Never` if NO_DNA or NO_COLOR is set. + pub color: ColorChoice, + /// Timestamp format for log records. + pub timestamp_format: TimestampFmt, + /// Optional floor for log verbosity. `None`. + pub log_verbosity_floor: Option, +} + +impl Default for AgentMode { + fn default() -> Self { + Self::const_inactive() + } +} + +impl AgentMode { + /// Const constructor for the active-agent-mode preset. + /// + /// Useful in tests where the struct must appear in a `const` context; + /// production code should always call [`from_env`](Self::from_env). + pub const fn const_active() -> Self { + Self { + active: true, + output_format: OutputFormat::Json, + color: ColorChoice::Never, + timestamp_format: TimestampFmt::Rfc3339Utc, + log_verbosity_floor: None, + } + } + + /// Const constructor for the inactive preset. + pub const fn const_inactive() -> Self { + Self { + active: false, + output_format: OutputFormat::Human, + color: ColorChoice::Auto, + timestamp_format: TimestampFmt::Local, + log_verbosity_floor: None, + } + } + + /// Resolve agent mode from the process environment. + /// + /// Reads `NO_DNA` and `NO_COLOR` exactly once per process via + /// [`OnceLock`]; later calls return the cached value. Tests + /// should call [`from_raw`](Self::from_raw) directly to avoid env + /// mutation. + pub fn from_env() -> Self { + *AGENT_MODE.get_or_init(|| { + Self::from_raw( + std::env::var(ENV_VAR_NO_DNA).ok().as_deref(), + std::env::var(ENV_VAR_NO_COLOR).ok().as_deref(), + ) + }) + } + + /// Pure parser used by [`from_env`](Self::from_env) and tests. + /// + /// - `no_dna = None | Some("")` → inactive. Any other value → active. + /// - `no_color = Some(non-empty) OR no_dna active` → `ColorChoice::Never`. + /// - Active → JSON / RFC3339-UTC; inactive → Human / Local. + pub fn from_raw(no_dna: Option<&str>, no_color: Option<&str>) -> Self { + let active = no_dna.is_some_and(|v| !v.is_empty()); + let no_color_set = no_color.is_some_and(|v| !v.is_empty()); + Self { + active, + output_format: if active { + OutputFormat::Json + } else { + OutputFormat::Human + }, + color: if active || no_color_set { + ColorChoice::Never + } else { + ColorChoice::Auto + }, + timestamp_format: if active { + TimestampFmt::Rfc3339Utc + } else { + TimestampFmt::Local + }, + log_verbosity_floor: None, + } + } + + /// `true` when agent mode is engaged. + pub fn is_active(&self) -> bool { + self.active + } +} + +#[cfg(test)] +mod tests { + use super::{AgentMode, ColorChoice, OutputFormat, TimestampFmt}; + + #[test] + fn unset_is_inactive() { + assert!(!AgentMode::from_raw(None, None).is_active()); + } + + #[test] + fn empty_string_is_inactive() { + assert!(!AgentMode::from_raw(Some(""), None).is_active()); + } + + #[test] + fn one_is_active() { + assert!(AgentMode::from_raw(Some("1"), None).is_active()); + } + + #[test] + fn zero_is_active() { + // NO_COLOR semantics: any non-empty value activates, including "0". + assert!(AgentMode::from_raw(Some("0"), None).is_active()); + } + + #[test] + fn arbitrary_value_is_active() { + assert!(AgentMode::from_raw(Some("anything"), None).is_active()); + assert!(AgentMode::from_raw(Some("false"), None).is_active()); + assert!(AgentMode::from_raw(Some("off"), None).is_active()); + } + + #[test] + fn whitespace_is_active() { + // Whitespace counts as non-empty per the spec; we do not trim. + assert!(AgentMode::from_raw(Some(" "), None).is_active()); + assert!(AgentMode::from_raw(Some("\t"), None).is_active()); + } + + #[test] + fn default_is_inactive() { + assert!(!AgentMode::default().is_active()); + } + + #[test] + fn no_dna_active_populates_all_fields() { + let m = AgentMode::from_raw(Some("1"), None); + assert!(m.active); + assert_eq!(m.output_format, OutputFormat::Json); + assert_eq!(m.color, ColorChoice::Never); + assert_eq!(m.timestamp_format, TimestampFmt::Rfc3339Utc); + assert!(m.log_verbosity_floor.is_none()); + } + + #[test] + fn no_dna_inactive_populates_defaults() { + let m = AgentMode::from_raw(None, None); + assert!(!m.active); + assert_eq!(m.output_format, OutputFormat::Human); + assert_eq!(m.color, ColorChoice::Auto); + assert_eq!(m.timestamp_format, TimestampFmt::Local); + } + + #[test] + fn no_color_alone_forces_never() { + let m = AgentMode::from_raw(None, Some("1")); + assert!(!m.active, "NO_COLOR must not activate agent mode"); + assert_eq!(m.color, ColorChoice::Never); + assert_eq!( + m.output_format, + OutputFormat::Human, + "NO_COLOR alone keeps human output" + ); + } + + #[test] + fn no_color_empty_does_not_force_never() { + let m = AgentMode::from_raw(None, Some("")); + assert_eq!(m.color, ColorChoice::Auto); + } + + #[test] + fn no_dna_and_no_color_both_force_never() { + let m = AgentMode::from_raw(Some("1"), Some("1")); + assert!(m.active); + assert_eq!(m.color, ColorChoice::Never); + } + + #[test] + fn const_active_matches_from_raw_active() { + assert_eq!( + AgentMode::const_active(), + AgentMode::from_raw(Some("1"), None) + ); + } + + #[test] + fn const_inactive_matches_from_raw_unset() { + assert_eq!(AgentMode::const_inactive(), AgentMode::from_raw(None, None)); + } +} diff --git a/crates/cli/src/runbook/mod.rs b/crates/cli/src/runbook/mod.rs index 05ff9cf94..4c745c7a6 100644 --- a/crates/cli/src/runbook/mod.rs +++ b/crates/cli/src/runbook/mod.rs @@ -35,7 +35,11 @@ use txtx_gql::kit::{ uuid::Uuid, }; -use crate::cli::{ExecuteRunbook, setup_logger}; +use crate::{ + cli::{ExecuteRunbook, setup_logger}, + cli_debug, cli_error, cli_info, cli_trace, cli_warn, + no_dna::AgentMode, +}; lazy_static::lazy_static! { static ref CLI_SPINNER_STYLE: ProgressStyle = { @@ -98,6 +102,9 @@ pub async fn handle_execute_runbook_command(cmd: ExecuteRunbook) -> Result<(), S let log_filter: LogLevel = cmd.log_level.as_str().into(); let _ = hiro_system_kit::thread_named("Runbook Progress Event Loop").spawn(move || { + // handle_log_event is NO_DNA-aware; under agent mode it never + // inserts a ProgressBar so this IndexMap + MultiProgress stay + // empty for the run (no rendering ever). let mut active_spinners: IndexMap = IndexMap::new(); let mut multi_progress = MultiProgress::new(); while let Ok(msg) = progress_rx.recv() { @@ -209,12 +216,16 @@ pub async fn execute_runbook( .unwrap_or_else(AuthorizationContext::empty); if do_setup_logger { + // AgentMode::from_env() is OnceLock-cached; same value as resolved in + // handle_command. Under NO_DNA, setup_logger forces stderr output + // even when log_to_stdout=false so agents retain console visibility. setup_logger( &cmd.log_dir, Some(&top_level_inputs_map.current_top_level_input_name()), &runbook_id, &cmd.log_level, false, + &AgentMode::from_env(), )?; } @@ -309,10 +320,16 @@ pub async fn execute_runbook( ..ColorfulTheme::default() }; - let confirm = Confirm::with_theme(&theme) - .with_prompt("Do you want to continue?") - .interact() - .unwrap(); + // Under NO_DNA the caller has opted into non-interactive execution; + // treat the snapshot-diff confirmation as approved. + let confirm = if AgentMode::from_env().is_active() { + true + } else { + Confirm::with_theme(&theme) + .with_prompt("Do you want to continue?") + .interact() + .map_err(|e| format!("failed to read confirmation: {e}"))? + }; if !confirm { return Ok(()); @@ -472,6 +489,8 @@ pub async fn configure_supervised_execution( let log_filter = cmd.log_level.as_str().into(); let block_store_handle = tokio::spawn(async move { + // Gating happens inside handle_log_event under NO_DNA; + // these maps stay empty for the agent path. let mut active_spinners: IndexMap = IndexMap::new(); let mut multi_progress = MultiProgress::new(); loop { @@ -793,36 +812,60 @@ pub fn persist_log( do_log_to_cli: bool, ) { let msg = format!("{} - {}", summary, message); + // Under NO_DNA, fern's console branch already emits a JSONL line to stderr + // (target=namespace) via the format closure. Firing `cli_*` here would + // produce a SECOND stderr line (target="surfpool.runbook"), so we suppress + // the cli_* path in agent mode and let fern be the sole stderr source. + // The log-crate macros still fire so the file dispatch retains the audit trail. + let suppress_cli = AgentMode::from_env().is_active(); match log_level { LogLevel::Trace => { trace!(target: &namespace, "{}", msg); - if do_log_to_cli && log_filter.should_log(log_level) { - println!("→ {}", msg); + if do_log_to_cli && log_filter.should_log(log_level) && !suppress_cli { + cli_trace!("surfpool.runbook", "→ {}", msg); } } LogLevel::Debug => { debug!(target: &namespace, "{}", msg); - if do_log_to_cli && log_filter.should_log(log_level) { - println!("→ {}", msg); + if do_log_to_cli && log_filter.should_log(log_level) && !suppress_cli { + cli_debug!("surfpool.runbook", "→ {}", msg); } } LogLevel::Info => { info!(target: &namespace, "{}", msg); - if do_log_to_cli && log_filter.should_log(log_level) { - println!("{} {} - {}", purple!("→"), purple!(summary), message); + if do_log_to_cli && log_filter.should_log(log_level) && !suppress_cli { + cli_info!( + "surfpool.runbook", + "{} {} - {}", + purple!("→"), + purple!(summary), + message + ); } } LogLevel::Warn => { warn!(target: &namespace, "{}", msg); - if do_log_to_cli && log_filter.should_log(log_level) { - println!("{} {} - {}", yellow!("!"), yellow!(summary), message); + if do_log_to_cli && log_filter.should_log(log_level) && !suppress_cli { + cli_warn!( + "surfpool.runbook", + "{} {} - {}", + yellow!("!"), + yellow!(summary), + message + ); } } LogLevel::Error => { error!(target: &namespace, "{}", msg); - if do_log_to_cli && log_filter.should_log(log_level) { - println!("{} {} - {}", red!("x"), red!(summary), message); + if do_log_to_cli && log_filter.should_log(log_level) && !suppress_cli { + cli_error!( + "surfpool.runbook", + "{} {} - {}", + red!("x"), + red!(summary), + message + ); } } } @@ -835,6 +878,12 @@ pub fn handle_log_event( active_spinners: &mut IndexMap, do_log_to_cli: bool, ) { + // Under NO_DNA, transient events do NOT construct + // ProgressBars. Each lifecycle stage emits a plain stderr line so the + // agent retains visibility without any spinner UI. `active_spinners` + // stays empty for the run. + let agent_mode_active = AgentMode::from_env().is_active(); + match log { LogEvent::Static(static_log_event) => { let LogDetails { message, summary } = static_log_event.details; @@ -847,6 +896,30 @@ pub fn handle_log_event( do_log_to_cli, ); } + LogEvent::Transient(log) if agent_mode_active => { + let (tag, status_level, details) = match log.status { + TransientLogEventStatus::Pending(d) => ("start", LogLevel::Info, d), + TransientLogEventStatus::Success(d) => ("ok", LogLevel::Info, d), + TransientLogEventStatus::Failure(d) => ("fail", LogLevel::Error, d), + }; + let LogDetails { message, summary } = details; + // Fold the lifecycle tag into the summary and emit through the + // log-crate path inside persist_log. Fern's console branch under + // NO_DNA renders this as a single JSONL line on stderr (target= + // namespace) AND the file dispatch retains it. Pre-fix this branch + // also called cli_emit, producing a duplicate stderr record. The + // status-derived level overrides log.level so Failure surfaces as + // level="error" for agent filtering. + let tagged_summary = format!("[{tag}] {summary}"); + persist_log( + &message, + &tagged_summary, + &log.namespace, + &status_level, + log_filter, + false, + ); + } LogEvent::Transient(log) => match log.status { TransientLogEventStatus::Pending(LogDetails { message, summary }) => { if let Some(pb) = active_spinners.get(&log.uuid) { @@ -880,7 +953,7 @@ pub fn handle_log_event( pb.finish_with_message(msg); } } else if do_log_to_cli { - println!("{}", msg); + crate::agent_io::cli_emit("info", "surfpool.runbook.action", &msg); } persist_log( @@ -899,7 +972,7 @@ pub fn handle_log_event( pb.finish_with_message(msg); } } else if do_log_to_cli { - println!("{}", msg); + crate::agent_io::cli_emit("info", "surfpool.runbook.action", &msg); } persist_log( &message,