ssh-cli 0.5.5

Native Rust CLI that gives LLMs (Claude Code, Cursor, Windsurf) the ability to operate remote servers via SSH over stdin/stdout
Documentation
// SPDX-License-Identifier: MIT OR Apache-2.0
// G-COMP: compact JSON emit primitives + envelopes (extracted from json_wire monolith).
#![forbid(unsafe_code)]
//! Compact JSON + LF writers and agent error/success envelopes.

use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io::{self, Write};

/// UTF-8 BOM character; stripped before parsing external JSON.
pub const UTF8_BOM: char = '\u{feff}';

/// Strips a leading UTF-8 BOM if present (Rules: remove BOM before parse).
///
/// # Examples
///
/// ```
/// use ssh_cli::json_wire::{strip_utf8_bom, UTF8_BOM};
///
/// assert_eq!(strip_utf8_bom("{\"ok\":true}"), "{\"ok\":true}");
/// let with_bom = format!("{UTF8_BOM}{{\"ok\":true}}");
/// assert_eq!(strip_utf8_bom(&with_bom), "{\"ok\":true}");
/// ```
#[must_use]
pub fn strip_utf8_bom(s: &str) -> &str {
    s.strip_prefix(UTF8_BOM).unwrap_or(s)
}

/// Serializes `value` as **compact** JSON + trailing LF on the given writer.
///
/// DI primitive (G-IO-11): pass a `Cursor`/`Vec` in tests; production uses
/// process stdout/stderr via [`print_json_line`] / [`print_json_line_stderr`].
///
/// # Examples
///
/// ```
/// use ssh_cli::json_wire::write_json_line;
/// use serde_json::json;
/// use std::io::Cursor;
///
/// let mut buf = Cursor::new(Vec::new());
/// write_json_line(&mut buf, &json!({"ok": true})).unwrap();
/// let s = String::from_utf8(buf.into_inner()).unwrap();
/// assert_eq!(s, "{\"ok\":true}\n");
/// assert!(!s.contains('\r'));
/// ```
///
/// # Errors
/// Serialization failure or I/O (including `BrokenPipe`).
pub fn write_json_line<W: Write, T: Serialize>(mut w: W, value: &T) -> io::Result<()> {
    let s = serde_json::to_string(value).map_err(io::Error::other)?;
    w.write_all(s.as_bytes())?;
    w.write_all(b"\n")?;
    w.flush()?;
    Ok(())
}

/// Compact JSON + LF on stdout (agent success / data path).
///
/// This is the **single funnel** every structured payload passes through, which is why
/// agent-native shaping ([`crate::agent_shape`]) is applied here rather than at each of
/// the fifteen call sites. Reducing before serialization is the whole point: shaping
/// downstream with `jaq` would mean the oversized envelope was already built and
/// written, so the tokens were already spent.
///
/// When no shaping flag was passed, [`crate::agent_shape::is_active`] short-circuits and
/// the value is serialized directly — the default path pays no `to_value` round-trip.
///
/// # Errors
/// Serialization or stdout I/O (including `BrokenPipe` → exit 141).
pub fn print_json_line<T: Serialize>(value: &T) -> io::Result<()> {
    let stdout = io::stdout();
    let mut handle = stdout.lock();

    let Some(cfg) = crate::agent_shape::current() else {
        return write_json_line(&mut handle, value);
    };
    let mut shaped = serde_json::to_value(value).map_err(io::Error::other)?;
    crate::agent_shape::apply(&mut shaped, &cfg);
    write_json_line(&mut handle, &shaped)
}

/// Compact JSON + LF on stderr; BrokenPipe is ignored (downstream closed).
///
/// # Errors
/// Non-pipe stderr write failures.
pub fn print_json_line_stderr<T: Serialize>(value: &T) -> io::Result<()> {
    let stderr = io::stderr();
    let mut handle = stderr.lock();
    match write_json_line(&mut handle, value) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()),
        Err(e) => Err(e),
    }
}

// ---------------------------------------------------------------------------
// Error envelope (stderr)
// ---------------------------------------------------------------------------

/// Stderr failure envelope when JSON errors mode is active.
///
/// Agents must read `retryable` / `error_class` before re-invoking (Rules Rust —
/// retry/backoff). Historical clients that only inspect `exit_code` remain valid
/// (`additionalProperties` / unknown-field ignore on the consumer side).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ErrorEnvelope {
    /// Process exit code (sysexits-inspired).
    pub exit_code: i32,
    /// Stable machine code (`vps_not_found`, `tls`, …) — G-ERR-08.
    #[serde(default)]
    pub error_code: String,
    /// Human-readable message (may be localized).
    pub message: String,
    /// Optional remote shell exit when process exit is general failure.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub remote_exit_code: Option<i32>,
    /// High-level class (`transient` | `permanent` | `cancelled`).
    pub error_class: crate::errors::ErrorClass,
    /// Whether an agent may re-invoke with the same argv after backoff.
    pub retryable: bool,
    /// Optional short remediation hint for agents.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub suggestion: Option<String>,
    /// Host this process had resolved when the failure occurred (canonical name).
    ///
    /// GAP-SSH-EXEC-ENVELOPE-002 asks for the audit fields on the error path because
    /// the failing step is where the target matters most: a step-zero failure on the
    /// wrong machine and one on the right machine were byte-identical envelopes.
    ///
    /// Absent — not empty — when the failure predates target resolution, which is
    /// most usage errors. `None` says "no host was chosen"; `""` would assert that a
    /// host was chosen and is nameless. Only one of those is true.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub target_resolved: Option<String>,
    /// How [`Self::target_resolved`] was obtained (canonical name).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub target_source: Option<crate::json_wire::TargetSource>,
    /// Compatibility alias of [`Self::target_resolved`] (0.5.5 spelling).
    ///
    /// Written from the same value, never independently — see
    /// [`crate::json_wire::TargetEcho`] for why both spellings ship.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub host_resolved: Option<String>,
    /// Compatibility alias of [`Self::target_source`] (0.5.5 spelling).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub host_source: Option<crate::json_wire::TargetSource>,
    /// Whether that host came from the active marker instead of argv.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub active_fallback: Option<bool>,
}

// ---------------------------------------------------------------------------
// Success envelope (stdout)
// ---------------------------------------------------------------------------

/// Agent-first success envelope: `{ "ok": true, "event": …, …fields }`.
///
/// Extra fields are merged from a map so callers can attach event-specific keys
/// without proliferating one struct per CRUD event.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuccessEnvelope {
    /// Always `true` for success envelopes.
    pub ok: bool,
    /// Event discriminator (`vps-added`, `scp-transfer`, …).
    pub event: String,
    /// Additional event fields (flattened at serialize time via map merge).
    #[serde(flatten)]
    pub fields: BTreeMap<String, serde_json::Value>,
}

impl SuccessEnvelope {
    /// Builds a success envelope from an event name and extra fields.
    #[must_use]
    pub fn new(event: impl Into<String>, fields: BTreeMap<String, serde_json::Value>) -> Self {
        Self {
            ok: true,
            event: event.into(),
            fields,
        }
    }

    /// Builds from a `serde_json::Value` object (or wraps non-objects under `data`).
    #[must_use]
    pub fn from_value(event: &str, fields: serde_json::Value) -> Self {
        let mut map = BTreeMap::new();
        match fields {
            serde_json::Value::Object(obj) => {
                for (k, v) in obj {
                    map.insert(k, v);
                }
            }
            other => {
                map.insert("data".into(), other);
            }
        }
        Self::new(event, map)
    }
}

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

    /// The agent wire is one line per event, failures included.
    ///
    /// Lives here rather than in `vps_export.rs`, where it used to sit: the subject is
    /// [`ErrorEnvelope`], and a test two modules away from its subject is a test nobody
    /// updates when the subject changes.
    #[test]
    fn compact_json_is_single_line() {
        let env = ErrorEnvelope {
            exit_code: 65,
            error_code: "invalid_argument".into(),
            message: "bad".into(),
            remote_exit_code: None,
            error_class: crate::errors::ErrorClass::Permanent,
            retryable: false,
            suggestion: None,
            target_resolved: None,
            target_source: None,
            host_resolved: None,
            host_source: None,
            active_fallback: None,
        };
        let s = serde_json::to_string(&env).expect("envelope serializes");
        assert!(!s.contains('\n'), "agent wire must be compact: {s}");
        assert!(s.starts_with('{'));
        assert!(s.contains("\"exit_code\":65"));
    }

    /// A failure that resolved no target must not claim one.
    ///
    /// Absent is the honest answer; an empty string would assert that a host was
    /// chosen and has no name, and only one of those is true.
    #[test]
    fn an_unresolved_target_is_absent_rather_than_empty() {
        let env = ErrorEnvelope {
            exit_code: 66,
            error_code: "vps_not_found".into(),
            message: "no such host".into(),
            remote_exit_code: None,
            error_class: crate::errors::ErrorClass::Permanent,
            retryable: false,
            suggestion: None,
            target_resolved: None,
            target_source: None,
            host_resolved: None,
            host_source: None,
            active_fallback: None,
        };
        let s = serde_json::to_string(&env).expect("envelope serializes");
        assert!(!s.contains("target_resolved"), "{s}");
        assert!(!s.contains("host_resolved"), "{s}");
    }
}