Synced from monorepo

Synced from monorepo

Changes:
- Shell: accept target response id on rewind execute
- Shell: stamp response id on chat user message chunks
- Worktree: optional rebuild and stale git registration cleanup in auto-GC
- Worktree: kind-aware auto-GC TTLs and config knobs
- Worktree: macOS process CWD scan and Unix PID liveness for GC guards
- Worktree: automatic throttled GC on startup (Linux age-based; non-Linux dead-only)
- Pager: add `[ui].combine_queued_prompts` to batch queued follow-ups
- Shell: stop overwriting user skills
- Tools: read markdown in `skills/` directories untruncated
- `/usage` shows per-session token and dollar usage in the TUI
- Security: prompt on environment-dumping `ps` variants
- Security: always-safe `kubectl` no longer runs arbitrary kubeconfig credential plugins without permission
- Tools: make scheduler deletion durable
- Shell: add relocation storage primitives
- Shell: give side model calls their own conversation ids
- Fix five workflow-runtime bugs (budget, pause, cancel, reconnect)
- Security: peel `env -S` / `--split-string` operands in the Bash permission gate (managed deny/ask)
- Pager: expose doctor in the TUI
- Security: block unauthorized RCE via abused safe commands
- Pager idle watcher cue: "1 subagent still running" instead of "watching · 1 subagent"
- Security: block `rg --pre` arbitrary code execution in auto-mode
- Voice: diagnose silent-mic failures (macOS permission) and add doctor/terminal-setup Voice section
- App builder deployer: `allow_forking` and `show_built_with_grok`
- Pager: stop stacking duplicate "Worked for" markers on parked turns
- Shell: support `max` as a distinct reasoning effort tier
- Tools: serialize background `/loop` fires on the whole work unit
- Shell: add working-directory relocation state primitives
- Proto: `ClientToolResult` and `ChatConfig` client-side tools
- Shell: model providers
- Chat: select App Builder product on the Build path
- Shell: attach author identity to feedback when the deployment opts in
- Doctor: fix for SSH wrap setup
- Workflow authoring skills: create-workflow and import-claude-workflow docs
- Add read-only grok doctor
- Sandbox: apply Landlock without a controlling TTY
- Pager: recover image paste over grok wrap on headless remotes
- Pager: make actions screen-mode aware
- Shell: resume sessions when the working directory moves
- Pager: centralize terminal diagnostics
- Workspace: gate inline shell file access
- Pager: centralize terminal probes
- Pager: edit minimal prompts in an external editor
- Pager: standardize backgrounding on Ctrl+B
- Shell: recap rides the parent turn's prompt cache
- Tools: add scheduler lifecycle version clock

Source-Revision: 0f4d7c91b8b2b408333f6de1e8a76cb8eaa71899
This commit is contained in:
grokkybara[bot] 2026-07-21 18:10:23 +00:00
commit 3af4d5d398
556 changed files with 56609 additions and 21892 deletions

View file

@ -0,0 +1,260 @@
use crate::clipboard::{ClipboardDelivery, NativeClipboardPreflight};
use crate::diagnostics::{
DataControlFact, DiagnosticFinding, DiagnosticReport, FindingDisposition, NewlineFact,
ProbeStatus, RuntimeFact, VoiceFacts,
};
use crate::host::{DisplayServer, HostOs};
const LIVE_TUI_PROBE_CTA: &str = "Run /doctor inside Grok.";
pub(super) fn format(report: &DiagnosticReport) -> String {
let facts = &report.facts;
let mut out = String::from("Grok Doctor\n\nTerminal\n");
fact(&mut out, "terminal", &facts.terminal.to_string());
match &facts.xtversion {
RuntimeFact::Available(value) => fact(&mut out, "xtversion", value),
RuntimeFact::NoReply => unavailable(&mut out, "xtversion", "no reply"),
RuntimeFact::Unavailable => unavailable(&mut out, "xtversion", "unavailable"),
}
fact(&mut out, "multiplexer", &facts.multiplexer.to_string());
if let Some(byobu) = facts.byobu {
fact(&mut out, "byobu", &byobu.to_string());
}
fact(&mut out, "ssh", if facts.ssh { "yes" } else { "no" });
match &facts.color.level {
RuntimeFact::Available(level) => {
fact(&mut out, "color", level.as_str());
let themes = if facts.color.available_themes.len() == facts.color.total_themes {
"all".to_owned()
} else {
format!(
"{}/{}: {}",
facts.color.available_themes.len(),
facts.color.total_themes,
facts
.color
.available_themes
.iter()
.map(|theme| theme.display_name())
.collect::<Vec<_>>()
.join(", ")
)
};
fact(&mut out, "themes", &themes);
}
RuntimeFact::NoReply | RuntimeFact::Unavailable => {
unavailable(&mut out, "color", "unavailable");
unavailable(&mut out, "themes", "unavailable");
}
}
if let Some(keyboard) = &facts.keyboard {
let rescue = if keyboard.os == HostOs::Macos {
"OS rescue active"
} else {
"OS rescue unavailable on this platform"
};
fact(
&mut out,
"keyboard",
&format!("{} ({rescue})", keyboard.modifier_delivery.label()),
);
}
if let Some(newline) = &facts.newline {
fact(&mut out, "newline", &format_newline(newline));
}
let clipboard = &facts.clipboard;
let native = match clipboard.native_preflight {
NativeClipboardPreflight::LocalAvailable => {
format!("local ({})", clipboard.native_tool)
}
NativeClipboardPreflight::RemoteOnly if clipboard.container_no_display => {
format!("container ({})", clipboard.native_tool)
}
NativeClipboardPreflight::RemoteOnly => format!("remote ({})", clipboard.native_tool),
NativeClipboardPreflight::Unavailable => "unavailable".to_owned(),
NativeClipboardPreflight::Disabled => "off".to_owned(),
};
out.push_str("\nClipboard\n");
fact(&mut out, "native", &native);
fact(
&mut out,
"tmux",
if clipboard.tmux_route { "on" } else { "off" },
);
fact(
&mut out,
"osc 52",
if clipboard.osc52_route {
clipboard.osc52_capability.label()
} else {
"off"
},
);
fact(
&mut out,
"wrap",
if clipboard.wrap_sink { "on" } else { "off" },
);
if clipboard.display_server == DisplayServer::Wayland {
match clipboard.data_control {
DataControlFact::Available => fact(&mut out, "data-control", "on"),
DataControlFact::Missing => fact(&mut out, "data-control", "off"),
DataControlFact::Unavailable => unavailable(&mut out, "data-control", "unavailable"),
DataControlFact::Error => {
let detail = report
.probe_notes
.iter()
.find(|note| note.probe == "wayland.data-control")
.and_then(|note| note.message.as_deref());
match detail {
Some(message) => {
unavailable(&mut out, "data-control", &format!("error: {message}"))
}
None => unavailable(&mut out, "data-control", "error"),
}
}
DataControlFact::NotApplicable => {}
}
}
let status = match clipboard.delivery {
ClipboardDelivery::Confirmed => "confirmed",
ClipboardDelivery::Unverified => "unverified",
ClipboardDelivery::Failed => "unavailable",
};
fact(&mut out, "status", status);
if let Some(fix) = &clipboard.fix {
fact(&mut out, "fix", fix);
}
if let Some(voice) = &facts.voice {
out.push_str("\nVoice\n");
match voice {
VoiceFacts::Device { name, detail } => {
fact(&mut out, "microphone", &format!("{name} ({detail})"));
}
VoiceFacts::Missing { error } => {
fact(&mut out, "microphone", &format!("none detected ({error})"));
}
}
}
if !report.findings.is_empty() {
out.push_str("\nFindings\n");
for finding in &report.findings {
format_finding(&mut out, finding);
}
}
let visible_notes = report
.probe_notes
.iter()
.filter(|note| !fact_already_shows_probe(note.probe));
let mut notes = visible_notes.peekable();
if notes.peek().is_some() {
out.push_str("\nProbe notes\n");
for note in notes {
let message = match &note.message {
Some(message) => format!("{}: {message}", probe_status(note.status)),
None => probe_status(note.status).to_owned(),
};
row(&mut out, "?", note.probe, &message);
}
}
if report
.probe_notes
.iter()
.any(crate::diagnostics::probe_requires_live_tui)
{
out.push_str("\nLive TUI evidence\n");
out.push_str(&format!(" {LIVE_TUI_PROBE_CTA}\n"));
}
let issues = report.issue_count();
let recommendations = report.recommendation_count();
out.push('\n');
out.push_str(&format!(
"{} {}, {} {}\n",
issues,
plural(issues, "issue", "issues"),
recommendations,
plural(recommendations, "recommendation", "recommendations")
));
out
}
fn fact_already_shows_probe(probe: &str) -> bool {
matches!(
probe,
"runtime.xtversion" | "terminal.color" | "wayland.data-control"
)
}
fn fact(out: &mut String, label: &str, value: &str) {
row(out, "·", label, value);
}
fn unavailable(out: &mut String, label: &str, value: &str) {
row(out, "?", label, value);
}
fn row(out: &mut String, marker: &str, label: &str, value: &str) {
out.push_str(&format!(" {marker} {label:<28} {value}\n"));
}
fn format_finding(out: &mut String, finding: &DiagnosticFinding) {
let marker = match finding.disposition {
FindingDisposition::Issue => "!",
FindingDisposition::Recommendation => "i",
};
row(out, marker, &finding.id.to_string(), &finding.message);
if let Some(automatic) = finding.automatic_remediation {
let command = crate::diagnostics::human_fix_command(automatic.fix_id)
.unwrap_or_else(|| automatic.command.to_owned());
out.push_str(&format!(" → Automatic setup: `{command}`\n"));
}
if let Some(remediation) = &finding.remediation {
let instruction = match (&remediation.config_path, &finding.automatic_remediation) {
(Some(path), _) => format!("Add `{}` to {path}", remediation.fix),
(None, Some(_)) => format!("One-off: `{}`", remediation.fix),
(None, None) => format!("Run `{}`", remediation.fix),
};
out.push_str(&format!("{instruction}\n"));
}
if let Some(note) = &finding.note {
out.push_str(&format!(" {note}\n"));
}
}
fn format_newline(newline: &NewlineFact) -> String {
let detail = match newline {
NewlineFact::Vte {
version: Some(version),
} => format!("VTE {version}; need >= 8200 for Shift+Enter"),
NewlineFact::Vte { version: None } => {
"legacy VTE; need VTE >= 0.82 for Shift+Enter".to_owned()
}
NewlineFact::XtermJs { terminal } => {
format!("{terminal}: xterm.js cannot distinguish Shift+Enter")
}
NewlineFact::NoKittyKeyboardProtocol => {
"no Kitty keyboard protocol; Shift+Enter equals Enter".to_owned()
}
};
format!("Alt+Enter ({detail})")
}
fn plural<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str {
if count == 1 { singular } else { plural }
}
fn probe_status(status: ProbeStatus) -> &'static str {
match status {
ProbeStatus::Unsupported => "unsupported",
ProbeStatus::Unavailable => "unavailable",
ProbeStatus::Error => "error",
}
}

View file

@ -0,0 +1,466 @@
use serde::Serialize;
use crate::clipboard::{ClipboardDelivery, NativeClipboardPreflight, Osc52Capability};
use crate::diagnostics::{
DataControlFact, DiagnosticFinding, DiagnosticReport, FindingDisposition, NewlineFact,
ProbeNote, ProbeStatus, RuntimeFact, VoiceFacts,
};
use crate::host::HostOs;
use crate::terminal::{ByobuBackend, ModifierFate, MultiplexerKind, TerminalName};
use crate::theme::color_support::ColorLevel;
use super::SCHEMA_VERSION;
pub(super) fn write(
report: &DiagnosticReport,
writer: &mut impl std::io::Write,
) -> anyhow::Result<()> {
serde_json::to_writer_pretty(&mut *writer, &JsonReport::from(report))?;
writeln!(writer)?;
Ok(())
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonReport<'a> {
schema_version: &'static str,
facts: JsonFacts<'a>,
findings: Vec<JsonFinding<'a>>,
probe_notes: Vec<JsonProbeNote<'a>>,
counts: JsonCounts,
}
impl<'a> From<&'a DiagnosticReport> for JsonReport<'a> {
fn from(report: &'a DiagnosticReport) -> Self {
Self {
schema_version: SCHEMA_VERSION,
facts: JsonFacts::from(report),
findings: report.findings.iter().map(JsonFinding::from).collect(),
probe_notes: report.probe_notes.iter().map(JsonProbeNote::from).collect(),
counts: JsonCounts {
issues: report.issue_count(),
recommendations: report.recommendation_count(),
probe_notes: report.probe_notes.len(),
},
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonFacts<'a> {
terminal: JsonTerminalFact<'a>,
multiplexer: JsonMultiplexerFact,
ssh: bool,
color: JsonColorFacts,
keyboard: Option<JsonKeyboardFact>,
newline: Option<JsonNewlineFact<'a>>,
clipboard: JsonClipboardFacts<'a>,
#[serde(skip_serializing_if = "Option::is_none")]
voice: Option<JsonVoiceFacts<'a>>,
}
impl<'a> From<&'a DiagnosticReport> for JsonFacts<'a> {
fn from(report: &'a DiagnosticReport) -> Self {
let facts = &report.facts;
Self {
terminal: JsonTerminalFact {
name: terminal_name(facts.terminal),
xtversion: JsonRuntimeFact::from(&facts.xtversion),
},
multiplexer: JsonMultiplexerFact {
kind: multiplexer(facts.multiplexer),
byobu: facts.byobu.map(byobu_backend),
},
ssh: facts.ssh,
color: JsonColorFacts {
level: JsonColorLevel::from(&facts.color.level),
available_themes: facts
.color
.available_themes
.iter()
.map(|theme| theme.display_name())
.collect(),
total_themes: facts.color.total_themes,
},
keyboard: facts.keyboard.as_ref().map(|keyboard| JsonKeyboardFact {
cmd: modifier_fate(keyboard.modifier_delivery.cmd),
opt: modifier_fate(keyboard.modifier_delivery.opt),
os: host_os(keyboard.os),
}),
newline: facts.newline.as_ref().map(JsonNewlineFact::from),
clipboard: JsonClipboardFacts::from(&facts.clipboard),
voice: facts.voice.as_ref().map(JsonVoiceFacts::from),
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonVoiceFacts<'a> {
status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<&'a str>,
}
impl<'a> From<&'a VoiceFacts> for JsonVoiceFacts<'a> {
fn from(facts: &'a VoiceFacts) -> Self {
match facts {
VoiceFacts::Device { name, detail } => Self {
status: "available",
name: Some(name),
detail: Some(detail),
error: None,
},
VoiceFacts::Missing { error } => Self {
status: "missing",
name: None,
detail: None,
error: Some(error),
},
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonTerminalFact<'a> {
name: &'static str,
xtversion: JsonRuntimeFact<'a>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonMultiplexerFact {
kind: &'static str,
byobu: Option<&'static str>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonRuntimeFact<'a> {
status: &'static str,
value: Option<&'a str>,
}
impl<'a> From<&'a RuntimeFact<String>> for JsonRuntimeFact<'a> {
fn from(fact: &'a RuntimeFact<String>) -> Self {
match fact {
RuntimeFact::Available(value) => Self {
status: "available",
value: Some(value),
},
RuntimeFact::NoReply => Self {
status: "no_reply",
value: None,
},
RuntimeFact::Unavailable => Self {
status: "unavailable",
value: None,
},
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonColorFacts {
level: JsonColorLevel,
available_themes: Vec<&'static str>,
total_themes: usize,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonColorLevel {
status: &'static str,
value: Option<&'static str>,
}
impl From<&RuntimeFact<ColorLevel>> for JsonColorLevel {
fn from(fact: &RuntimeFact<ColorLevel>) -> Self {
match fact {
RuntimeFact::Available(level) => Self {
status: "available",
value: Some(level.as_str()),
},
RuntimeFact::NoReply => Self {
status: "no_reply",
value: None,
},
RuntimeFact::Unavailable => Self {
status: "unavailable",
value: None,
},
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonKeyboardFact {
cmd: &'static str,
opt: &'static str,
os: &'static str,
}
#[derive(Serialize)]
#[serde(
tag = "kind",
rename_all = "snake_case",
rename_all_fields = "camelCase"
)]
enum JsonNewlineFact<'a> {
Vte { version: Option<&'a str> },
XtermJs { terminal_name: &'static str },
NoKittyKeyboardProtocol,
}
impl<'a> From<&'a NewlineFact> for JsonNewlineFact<'a> {
fn from(newline: &'a NewlineFact) -> Self {
match newline {
NewlineFact::Vte { version } => Self::Vte {
version: version.as_deref(),
},
NewlineFact::XtermJs { terminal } => Self::XtermJs {
terminal_name: terminal_name(*terminal),
},
NewlineFact::NoKittyKeyboardProtocol => Self::NoKittyKeyboardProtocol,
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonClipboardFacts<'a> {
native_route: bool,
native_tool: &'a str,
native_preflight: &'static str,
tmux_route: bool,
osc52_route: bool,
osc52_capability: &'static str,
wrap_sink: bool,
display_server: &'static str,
container_no_display: bool,
data_control: &'static str,
delivery: &'static str,
fix: Option<&'a str>,
}
impl<'a> From<&'a crate::diagnostics::ClipboardFacts> for JsonClipboardFacts<'a> {
fn from(facts: &'a crate::diagnostics::ClipboardFacts) -> Self {
Self {
native_route: facts.native_route,
native_tool: &facts.native_tool,
native_preflight: native_preflight(facts.native_preflight),
tmux_route: facts.tmux_route,
osc52_route: facts.osc52_route,
osc52_capability: osc52_capability(facts.osc52_capability),
wrap_sink: facts.wrap_sink,
display_server: display_server(facts.display_server),
container_no_display: facts.container_no_display,
data_control: data_control(facts.data_control),
delivery: clipboard_delivery(facts.delivery),
fix: facts.fix.as_deref(),
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonFinding<'a> {
id: String,
disposition: &'static str,
message: &'a str,
remediation: Option<JsonRemediation<'a>>,
automatic_remediation: Option<JsonAutomaticRemediation>,
note: Option<&'a str>,
}
impl<'a> From<&'a DiagnosticFinding> for JsonFinding<'a> {
fn from(finding: &'a DiagnosticFinding) -> Self {
Self {
id: finding.id.to_string(),
disposition: match finding.disposition {
FindingDisposition::Issue => "issue",
FindingDisposition::Recommendation => "recommendation",
},
message: &finding.message,
remediation: finding
.remediation
.as_ref()
.map(|remediation| JsonRemediation {
fix: &remediation.fix,
config_path: remediation.config_path.as_deref(),
}),
automatic_remediation: finding.automatic_remediation.map(|automatic| {
JsonAutomaticRemediation {
fix_id: automatic.fix_id.to_string(),
command: automatic.command,
}
}),
note: finding.note.as_deref(),
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonRemediation<'a> {
fix: &'a str,
config_path: Option<&'a str>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonAutomaticRemediation {
fix_id: String,
command: &'static str,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonProbeNote<'a> {
probe: &'static str,
status: &'static str,
message: Option<&'a str>,
}
impl<'a> From<&'a ProbeNote> for JsonProbeNote<'a> {
fn from(note: &'a ProbeNote) -> Self {
Self {
probe: note.probe,
status: probe_status(note.status),
message: note.message.as_deref(),
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonCounts {
issues: usize,
recommendations: usize,
probe_notes: usize,
}
pub(super) fn terminal_name(name: TerminalName) -> &'static str {
match name {
TerminalName::AppleTerminal => "apple_terminal",
TerminalName::Ghostty => "ghostty",
TerminalName::Iterm2 => "iterm2",
TerminalName::WarpTerminal => "warp",
TerminalName::VsCode => "vs_code",
TerminalName::Cursor => "cursor",
TerminalName::Windsurf => "windsurf",
TerminalName::Zed => "zed",
TerminalName::WezTerm => "wezterm",
TerminalName::Kitty => "kitty",
TerminalName::Alacritty => "alacritty",
TerminalName::Rio => "rio",
TerminalName::Foot => "foot",
TerminalName::JetBrains => "jetbrains",
TerminalName::GrokDesktop => "grok_desktop",
TerminalName::Vte => "vte",
TerminalName::Terminator => "terminator",
TerminalName::WindowsTerminal => "windows_terminal",
TerminalName::Otty => "otty",
TerminalName::Unknown => "unknown",
}
}
pub(super) fn multiplexer(kind: MultiplexerKind) -> &'static str {
match kind {
MultiplexerKind::Tmux => "tmux",
MultiplexerKind::Screen => "screen",
MultiplexerKind::Zellij => "zellij",
MultiplexerKind::Cmux => "cmux",
MultiplexerKind::Undetected => "undetected",
}
}
pub(super) fn byobu_backend(backend: ByobuBackend) -> &'static str {
match backend {
ByobuBackend::Unknown => "unknown",
ByobuBackend::Tmux => "tmux",
ByobuBackend::Screen => "screen",
}
}
pub(super) fn modifier_fate(fate: ModifierFate) -> &'static str {
match fate {
ModifierFate::Native => "native",
ModifierFate::Dropped => "dropped",
ModifierFate::Unrecoverable => "unrecoverable",
ModifierFate::Unknown => "unknown",
_ => "unknown",
}
}
pub(super) fn host_os(os: HostOs) -> &'static str {
match os {
HostOs::Macos => "macos",
HostOs::Linux => "linux",
HostOs::Windows => "windows",
HostOs::Other => "other",
_ => "other",
}
}
pub(super) fn native_preflight(fact: NativeClipboardPreflight) -> &'static str {
match fact {
NativeClipboardPreflight::Disabled => "disabled",
NativeClipboardPreflight::LocalAvailable => "local_available",
NativeClipboardPreflight::RemoteOnly => "remote_only",
NativeClipboardPreflight::Unavailable => "unavailable",
}
}
pub(super) fn osc52_capability(capability: Osc52Capability) -> &'static str {
match capability {
Osc52Capability::Supported => "supported",
Osc52Capability::Unsupported => "unsupported",
Osc52Capability::Unknown => "unknown",
}
}
pub(super) fn display_server(server: crate::host::DisplayServer) -> &'static str {
match server {
crate::host::DisplayServer::Quartz => "quartz",
crate::host::DisplayServer::Wayland => "wayland",
crate::host::DisplayServer::X11 => "x11",
crate::host::DisplayServer::Win32 => "win32",
crate::host::DisplayServer::Unknown => "unknown",
_ => "unknown",
}
}
pub(super) fn clipboard_delivery(delivery: ClipboardDelivery) -> &'static str {
match delivery {
ClipboardDelivery::Confirmed => "confirmed",
ClipboardDelivery::Unverified => "unverified",
ClipboardDelivery::Failed => "failed",
}
}
pub(super) fn data_control(fact: DataControlFact) -> &'static str {
match fact {
DataControlFact::Available => "available",
DataControlFact::Missing => "missing",
DataControlFact::Unavailable => "unavailable",
DataControlFact::Error => "error",
DataControlFact::NotApplicable => "not_applicable",
}
}
pub(super) fn probe_status(status: ProbeStatus) -> &'static str {
match status {
ProbeStatus::Unsupported => "unsupported",
ProbeStatus::Unavailable => "unavailable",
ProbeStatus::Error => "error",
}
}

View file

@ -0,0 +1,225 @@
use std::io::{IsTerminal as _, Write};
use std::path::Path;
use anyhow::Result;
use crate::diagnostics::{DiagnosticReport, FixPlan, FixStatus, ShellKind};
mod human;
mod json;
pub const SCHEMA_VERSION: &str = "1";
#[derive(Clone, Debug, Default, Eq, PartialEq, clap::Args)]
#[command(args_conflicts_with_subcommands = true)]
pub struct DoctorArgs {
/// Emit machine-readable JSON output.
#[arg(long)]
pub json: bool,
#[command(subcommand)]
pub command: Option<DoctorCommand>,
}
#[derive(Clone, Debug, Eq, PartialEq, clap::Subcommand)]
pub enum DoctorCommand {
/// Apply a named automatic remediation.
Fix(FixArgs),
}
#[derive(Clone, Debug, Eq, PartialEq, clap::Args)]
pub struct FixArgs {
/// Short fix handle (`ssh-wrap`); canonical `terminal.ssh-wrap` is also accepted.
pub id: String,
/// Apply without prompting after printing the exact plan.
#[arg(long)]
pub yes: bool,
}
pub fn run(args: DoctorArgs) -> Result<()> {
match args.command {
None => run_report(args.json, &mut std::io::stdout().lock()),
Some(DoctorCommand::Fix(fix)) => run_fix(
fix,
std::io::stdin().is_terminal(),
&mut std::io::stdin().lock(),
&mut std::io::stdout().lock(),
),
}
}
pub fn run_with_writer(args: DoctorArgs, writer: &mut impl Write) -> Result<()> {
match args.command {
None => run_report(args.json, writer),
Some(_) => anyhow::bail!("doctor fixes require interactive input/output"),
}
}
fn run_report(json_output: bool, writer: &mut impl Write) -> Result<()> {
let report = collect_report();
write_report(&report, json_output, writer)
}
pub fn collect_report() -> DiagnosticReport {
let terminal = crate::terminal::standalone_terminal_context();
let report = collect_report_with(crate::diagnostics::probes::collect_standalone(&terminal));
configured_report_for_terminal(report, &terminal)
}
fn configured_report_for_terminal(
report: DiagnosticReport,
terminal: &crate::terminal::TerminalContext,
) -> DiagnosticReport {
let configured = shell_home_and_kind()
.map(|(home, shell)| {
crate::diagnostics::managed_alias_configured(&shell.config_path(&home), shell)
})
.unwrap_or(false);
if terminal.is_ssh || terminal.is_official_vscode_remote {
report
} else {
crate::diagnostics::configured_report(report, configured)
}
}
fn collect_report_with(
snapshot: crate::diagnostics::probes::StandaloneDiagnosticSnapshot<'_>,
) -> DiagnosticReport {
let mut report = crate::diagnostics::view(snapshot.into());
// Passive mic fact when audio is compiled in. No issue finding — headless
// hosts often have no input device; the Voice fact row is enough.
crate::diagnostics::apply_voice_probe(&mut report, false);
report
}
fn write_report(
report: &DiagnosticReport,
json_output: bool,
writer: &mut impl Write,
) -> Result<()> {
if json_output {
json::write(report, writer)
} else {
write!(writer, "{}", human::format(report))?;
Ok(())
}
}
fn run_fix(
args: FixArgs,
stdin_is_terminal: bool,
input: &mut impl std::io::BufRead,
writer: &mut impl Write,
) -> Result<()> {
let id = crate::diagnostics::resolve_fix_id(&args.id)?;
let terminal = crate::terminal::standalone_terminal_context();
let report = configured_report_for_terminal(
collect_report_with(crate::diagnostics::probes::collect_standalone(&terminal)),
&terminal,
);
let request = crate::diagnostics::FixRequest::from_environment(id)?;
let plan = crate::diagnostics::plan_fix(request, &report, &terminal)?;
apply_fix_plan(args, stdin_is_terminal, input, writer, &terminal, plan)
}
fn apply_fix_plan(
args: FixArgs,
stdin_is_terminal: bool,
input: &mut impl std::io::BufRead,
writer: &mut impl Write,
terminal: &crate::terminal::TerminalContext,
plan: FixPlan,
) -> Result<()> {
let id = plan.id;
write_fix_preview(&plan, writer)?;
if !args.yes {
if !stdin_is_terminal {
anyhow::bail!(
"refusing to apply a doctor fix from non-interactive stdin without --yes"
);
}
write!(writer, "\nApply this change? [y/N] ")?;
writer.flush()?;
let mut answer = String::new();
input.read_line(&mut answer)?;
if !matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
writeln!(writer, "Cancelled.")?;
return Ok(());
}
}
let shell = plan.shell;
let outcome = crate::diagnostics::apply_fix(plan)?;
let post_report = crate::diagnostics::configured_report(
collect_report_with(crate::diagnostics::probes::collect_standalone(terminal)),
crate::diagnostics::managed_alias_configured(&outcome.changed_path, shell),
);
if post_report.findings.iter().any(|finding| finding.id == id) {
anyhow::bail!("fix applied, but `{id}` is still reported");
}
match outcome.status {
FixStatus::Applied => writeln!(
writer,
"\nConfigured {id} in {}.",
outcome.changed_path.display()
)?,
FixStatus::AlreadyConfigured => writeln!(
writer,
"\n{id} is already configured in {}.",
outcome.changed_path.display()
)?,
}
if let Some(backup) = outcome.backup_path {
writeln!(writer, "Backup: {}", backup.display())?;
}
writeln!(writer, "Open a new interactive shell to use the alias.")?;
Ok(())
}
fn write_fix_preview(plan: &FixPlan, writer: &mut impl Write) -> std::io::Result<()> {
writeln!(writer, "Doctor fix: {}", plan.id)?;
writeln!(writer, "Shell: {}", plan.shell.name())?;
for change in &plan.changes {
writeln!(writer, "File: {}", change.requested_path.display())?;
if change.target_path != change.requested_path {
writeln!(writer, "Physical target: {}", change.target_path.display())?;
}
writeln!(writer, "\nManaged block:")?;
writeln!(writer, "{}", change.block)?;
match &change.backup_path_hint {
Some(path) => writeln!(
writer,
"\nProposed backup: {} (apply retries a nearby unique name on collision)",
path.display()
)?,
None => writeln!(writer, "\nBackup: none (new file or exact no-op)")?,
}
}
writeln!(writer, "\nBehavior:")?;
writeln!(
writer,
" New interactive shells run typed `ssh ...` as `grok wrap ssh ...`."
)?;
writeln!(
writer,
" One-off alternative without changing config: `{}`.",
crate::diagnostics::SSH_WRAP_ONE_OFF
)?;
writeln!(writer, "Caveats:")?;
for caveat in &plan.caveats {
writeln!(writer, " - {caveat}")?;
}
Ok(())
}
fn shell_home_and_kind() -> Option<(std::path::PathBuf, ShellKind)> {
#[allow(deprecated)]
let home = std::env::home_dir()?;
let shell = std::env::var_os("SHELL")?;
let kind = ShellKind::from_shell_path(Path::new(&shell))?;
Some((home, kind))
}
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,969 @@
use super::*;
use crate::clipboard::{
ClipboardDelivery, ClipboardRoute, NativeClipboardPreflight, Osc52Capability,
};
use crate::diagnostics::probes::{
RuntimeEvidence, TmuxProbeFacts, TmuxProbeResult, WaylandProbeFacts,
};
use crate::diagnostics::{
ClipboardFacts, ColorFacts, DataControlFact, DiagnosticFacts, DiagnosticFinding, DiagnosticId,
DiagnosticReport, FindingDisposition, KeyboardFact, ManualRemediation, NewlineFact, ProbeNote,
ProbeStatus, RuntimeFact,
};
use crate::host::{DisplayServer, HostOs};
use crate::terminal::{
ByobuBackend, ModifierDelivery, ModifierFate, MultiplexerKind, TerminalContext, TerminalName,
};
use crate::theme::{ThemeKind, color_support::ColorLevel};
fn ssh_wrap_report() -> DiagnosticReport {
let mut report = healthy_report();
report.findings.push(DiagnosticFinding {
id: crate::diagnostics::SSH_WRAP_ID,
disposition: FindingDisposition::Recommendation,
message: "Use local SSH wrapping".to_owned(),
remediation: Some(ManualRemediation {
fix: crate::diagnostics::SSH_WRAP_ONE_OFF.to_owned(),
config_path: None,
}),
automatic_remediation: Some(crate::diagnostics::ssh_wrap_automatic_remediation()),
note: None,
});
report
}
fn local_terminal() -> TerminalContext {
TerminalContext {
brand: TerminalName::Ghostty,
env_brand: TerminalName::Ghostty,
..Default::default()
}
}
fn ssh_wrap_fix_request(home: &std::path::Path) -> crate::diagnostics::FixRequest {
crate::diagnostics::FixRequest {
id: crate::diagnostics::SSH_WRAP_ID,
home: home.to_path_buf(),
shell: Some(std::path::PathBuf::from("/bin/bash")),
validator: None,
}
}
static TMUX_ROUTE: ClipboardRoute = ClipboardRoute {
native: true,
tmux_buffer: true,
osc52: true,
osc52_tmux_passthrough: true,
};
static LOCAL_ROUTE: ClipboardRoute = ClipboardRoute {
native: true,
tmux_buffer: false,
osc52: false,
osc52_tmux_passthrough: false,
};
fn tmux_facts(
set_clipboard: TmuxProbeResult<String>,
control_mode: TmuxProbeResult<bool>,
) -> TmuxProbeFacts {
TmuxProbeFacts {
version: TmuxProbeResult::Unavailable,
extended_keys: TmuxProbeResult::Unavailable,
set_clipboard,
allow_passthrough_support: TmuxProbeResult::Available(()),
allow_passthrough: TmuxProbeResult::Available("on".to_owned()),
control_mode,
}
}
fn unavailable_tmux_facts() -> TmuxProbeFacts {
TmuxProbeFacts {
version: TmuxProbeResult::Unavailable,
extended_keys: TmuxProbeResult::Unavailable,
set_clipboard: TmuxProbeResult::Unavailable,
allow_passthrough_support: TmuxProbeResult::Unavailable,
allow_passthrough: TmuxProbeResult::Unavailable,
control_mode: TmuxProbeResult::Unavailable,
}
}
fn healthy_report() -> DiagnosticReport {
DiagnosticReport {
facts: DiagnosticFacts {
terminal: TerminalName::Ghostty,
xtversion: RuntimeFact::NoReply,
multiplexer: MultiplexerKind::Undetected,
byobu: None,
ssh: false,
color: ColorFacts {
level: RuntimeFact::Available(ColorLevel::TrueColor),
available_themes: ThemeKind::ALL.to_vec(),
total_themes: ThemeKind::ALL.len(),
},
keyboard: None,
newline: None,
clipboard: ClipboardFacts {
native_route: true,
native_tool: "pbcopy".to_owned(),
native_preflight: NativeClipboardPreflight::LocalAvailable,
tmux_route: false,
osc52_route: false,
osc52_capability: Osc52Capability::Supported,
wrap_sink: false,
display_server: DisplayServer::Unknown,
container_no_display: false,
data_control: DataControlFact::NotApplicable,
delivery: ClipboardDelivery::Confirmed,
fix: None,
},
voice: None,
},
findings: Vec::new(),
probe_notes: Vec::new(),
}
}
fn mixed_report() -> DiagnosticReport {
let mut report = healthy_report();
report.facts.xtversion = RuntimeFact::Available("Ghostty 1.2.3".to_owned());
report.facts.multiplexer = MultiplexerKind::Tmux;
report.facts.byobu = Some(ByobuBackend::Tmux);
report.facts.ssh = true;
report.facts.color = ColorFacts {
level: RuntimeFact::Available(ColorLevel::Ansi256),
available_themes: vec![ThemeKind::GrokNight, ThemeKind::GrokDay],
total_themes: ThemeKind::ALL.len(),
};
report.facts.keyboard = Some(KeyboardFact {
modifier_delivery: ModifierDelivery::new_for_test(
ModifierFate::Dropped,
ModifierFate::Native,
),
os: HostOs::Macos,
});
report.facts.newline = Some(NewlineFact::XtermJs {
terminal: TerminalName::Cursor,
});
report.facts.clipboard.tmux_route = true;
report.facts.clipboard.osc52_route = true;
report.findings = vec![
DiagnosticFinding {
id: DiagnosticId::new("terminal", "tmux-clipboard"),
disposition: FindingDisposition::Issue,
message: "OSC 52 clipboard passthrough is disabled".to_owned(),
remediation: Some(ManualRemediation {
fix: "set -g set-clipboard on".to_owned(),
config_path: Some("~/.tmux.conf".to_owned()),
}),
automatic_remediation: None,
note: Some("Reload tmux after editing.".to_owned()),
},
DiagnosticFinding {
id: DiagnosticId::new("terminal", "ssh-wrap"),
disposition: FindingDisposition::Recommendation,
message: "Use local SSH wrapping".to_owned(),
remediation: Some(ManualRemediation {
fix: "grok wrap ssh <host>".to_owned(),
config_path: None,
}),
automatic_remediation: Some(crate::diagnostics::ssh_wrap_automatic_remediation()),
note: None,
},
];
report.probe_notes = vec![
ProbeNote {
probe: "tmux.version",
status: ProbeStatus::Unavailable,
message: None,
},
ProbeNote {
probe: "tmux.extended-keys",
status: ProbeStatus::Unavailable,
message: None,
},
ProbeNote {
probe: "tmux.allow-passthrough-support",
status: ProbeStatus::Unsupported,
message: None,
},
ProbeNote {
probe: "runtime.fullscreen-active",
status: ProbeStatus::Unavailable,
message: None,
},
ProbeNote {
probe: "tmux.control-mode",
status: ProbeStatus::Error,
message: Some("server unavailable".to_owned()),
},
];
report
}
#[test]
fn fake_standalone_facts_compose_through_shared_view() {
let terminal = TerminalContext {
brand: TerminalName::Iterm2,
env_brand: TerminalName::Iterm2,
multiplexer: MultiplexerKind::Tmux,
..Default::default()
};
let snapshot = crate::diagnostics::probes::collect_standalone_from(
&terminal,
tmux_facts(
TmuxProbeResult::Available("off".to_owned()),
TmuxProbeResult::Available(false),
),
WaylandProbeFacts {
is_wayland: false,
data_control: TmuxProbeResult::Unavailable,
wl_copy_available: false,
},
"pbcopy",
TMUX_ROUTE.clone(),
true,
HostOs::Macos,
DisplayServer::Unknown,
false,
RuntimeEvidence::Available(ColorLevel::TrueColor),
);
let report = collect_report_with(snapshot);
assert_eq!(report.issue_count(), 1);
assert!(
report
.findings
.iter()
.all(|finding| { finding.id != DiagnosticId::new("terminal", "control-mode") })
);
assert_eq!(
report.findings[0].id,
DiagnosticId::new("terminal", "tmux-clipboard")
);
}
#[test]
fn standalone_wayland_missing_is_issue_but_no_seats_or_errors_are_not() {
let terminal = TerminalContext::default();
for data_control in [
TmuxProbeResult::Available(false),
TmuxProbeResult::Unavailable,
TmuxProbeResult::Error("probe worker died".to_owned()),
] {
let snapshot = crate::diagnostics::probes::collect_standalone_from(
&terminal,
unavailable_tmux_facts(),
WaylandProbeFacts {
is_wayland: true,
data_control,
wl_copy_available: false,
},
"arboard",
LOCAL_ROUTE.clone(),
false,
HostOs::Macos,
DisplayServer::Wayland,
false,
RuntimeEvidence::Available(ColorLevel::TrueColor),
);
let report = collect_report_with(snapshot);
let has_issue = report
.findings
.iter()
.any(|finding| finding.id == DiagnosticId::new("terminal", "wayland-data-control"));
match report.facts.clipboard.data_control {
DataControlFact::Missing => assert!(has_issue),
DataControlFact::Unavailable => {
assert!(!has_issue);
assert_eq!(
report
.probe_notes
.iter()
.find(|note| note.probe == "wayland.data-control")
.and_then(|note| note.message.as_deref()),
None
);
}
DataControlFact::Error => {
assert!(!has_issue);
assert_eq!(
report
.probe_notes
.iter()
.find(|note| note.probe == "wayland.data-control")
.and_then(|note| note.message.as_deref()),
Some("probe worker died")
);
}
other => panic!("unexpected data-control fact: {other:?}"),
}
}
}
#[test]
fn human_wayland_error_includes_detail_once() {
let mut report = healthy_report();
report.facts.clipboard.native_preflight = NativeClipboardPreflight::Unavailable;
report.facts.clipboard.display_server = DisplayServer::Wayland;
report.facts.clipboard.data_control = DataControlFact::Error;
report.facts.clipboard.delivery = ClipboardDelivery::Failed;
report.facts.clipboard.fix = Some("/minimal".to_owned());
report.probe_notes = vec![ProbeNote {
probe: "wayland.data-control",
status: ProbeStatus::Error,
message: Some("probe worker died".to_owned()),
}];
assert_eq!(
human::format(&report),
concat!(
"Grok Doctor\n",
"\n",
"Terminal\n",
" · terminal Ghostty\n",
" ? xtversion no reply\n",
" · multiplexer None detected\n",
" · ssh no\n",
" · color truecolor\n",
" · themes all\n",
"\n",
"Clipboard\n",
" · native unavailable\n",
" · tmux off\n",
" · osc 52 off\n",
" · wrap off\n",
" ? data-control error: probe worker died\n",
" · status unavailable\n",
" · fix /minimal\n",
"\n",
"1 issue, 0 recommendations\n",
)
);
}
#[test]
fn standalone_runtime_and_tmux_are_unavailable_without_false_wezterm_finding() {
let terminal = TerminalContext {
brand: TerminalName::WezTerm,
env_brand: TerminalName::WezTerm,
multiplexer: MultiplexerKind::Tmux,
..Default::default()
};
let snapshot = crate::diagnostics::probes::collect_standalone_from(
&terminal,
unavailable_tmux_facts(),
WaylandProbeFacts {
is_wayland: false,
data_control: TmuxProbeResult::Unavailable,
wl_copy_available: false,
},
"pbcopy",
LOCAL_ROUTE.clone(),
true,
HostOs::Macos,
DisplayServer::Unknown,
false,
RuntimeEvidence::Available(ColorLevel::TrueColor),
);
let report = collect_report_with(snapshot);
assert!(report.findings.iter().all(|finding| {
finding.id != DiagnosticId::new("terminal", "wezterm-kitty")
&& finding.id != DiagnosticId::new("terminal", "control-mode")
}));
assert_eq!(report.facts.xtversion, RuntimeFact::Unavailable);
assert_eq!(
report
.probe_notes
.iter()
.filter(|note| note.probe.starts_with("tmux."))
.map(|note| note.probe)
.collect::<Vec<_>>(),
[
"tmux.version",
"tmux.extended-keys",
"tmux.set-clipboard",
"tmux.allow-passthrough-support",
"tmux.control-mode",
]
);
let runtime_notes = report
.probe_notes
.iter()
.filter(|note| note.probe.starts_with("runtime."))
.collect::<Vec<_>>();
assert_eq!(
runtime_notes
.iter()
.map(|note| (note.probe, note.status))
.collect::<Vec<_>>(),
[
("runtime.fullscreen-active", ProbeStatus::Unavailable),
("runtime.kitty-flags-pushed", ProbeStatus::Unavailable),
("runtime.xtversion", ProbeStatus::Unavailable),
]
);
assert!(
runtime_notes
.iter()
.all(|note| crate::diagnostics::probe_requires_live_tui(note))
);
assert!(
report
.probe_notes
.iter()
.filter(|note| note.probe.starts_with("tmux."))
.all(|note| !crate::diagnostics::probe_requires_live_tui(note))
);
}
#[test]
fn human_healthy_fixture_is_exact() {
assert_eq!(
human::format(&healthy_report()),
concat!(
"Grok Doctor\n",
"\n",
"Terminal\n",
" · terminal Ghostty\n",
" ? xtversion no reply\n",
" · multiplexer None detected\n",
" · ssh no\n",
" · color truecolor\n",
" · themes all\n",
"\n",
"Clipboard\n",
" · native local (pbcopy)\n",
" · tmux off\n",
" · osc 52 off\n",
" · wrap off\n",
" · status confirmed\n",
"\n",
"0 issues, 0 recommendations\n",
)
);
}
#[test]
fn human_mixed_fixture_is_exact() {
assert_eq!(
human::format(&mixed_report()),
concat!(
"Grok Doctor\n",
"\n",
"Terminal\n",
" · terminal Ghostty\n",
" · xtversion Ghostty 1.2.3\n",
" · multiplexer tmux\n",
" · byobu tmux\n",
" · ssh yes\n",
" · color 256\n",
" · themes 2/5: groknight, grokday\n",
" · keyboard cmd=dropped, opt=native (OS rescue active)\n",
" · newline Alt+Enter (Cursor: xterm.js cannot distinguish Shift+Enter)\n",
"\n",
"Clipboard\n",
" · native local (pbcopy)\n",
" · tmux on\n",
" · osc 52 supported\n",
" · wrap off\n",
" · status confirmed\n",
"\n",
"Findings\n",
" ! terminal.tmux-clipboard OSC 52 clipboard passthrough is disabled\n",
" → Add `set -g set-clipboard on` to ~/.tmux.conf\n",
" Reload tmux after editing.\n",
" i terminal.ssh-wrap Use local SSH wrapping\n",
" → Automatic setup: `grok doctor fix ssh-wrap`\n",
" → One-off: `grok wrap ssh <host>`\n",
"\n",
"Probe notes\n",
" ? tmux.version unavailable\n",
" ? tmux.extended-keys unavailable\n",
" ? tmux.allow-passthrough-support unsupported\n",
" ? runtime.fullscreen-active unavailable\n",
" ? tmux.control-mode error: server unavailable\n",
"\n",
"Live TUI evidence\n",
" Run /doctor inside Grok.\n",
"\n",
"1 issue, 1 recommendation\n",
)
);
}
#[test]
fn fix_preview_contains_exact_change_and_caveats() {
let temp = tempfile::tempdir().unwrap();
let terminal = local_terminal();
let plan = crate::diagnostics::plan_fix(
ssh_wrap_fix_request(temp.path()),
&ssh_wrap_report(),
&terminal,
)
.unwrap();
let mut preview = Vec::new();
write_fix_preview(&plan, &mut preview).unwrap();
let preview = String::from_utf8(preview).unwrap();
assert!(preview.contains("File: "));
assert!(
preview.contains(
"# >>> grok doctor >>>\n# >>> terminal.ssh-wrap >>>\nalias ssh='grok wrap ssh'"
)
);
assert!(
preview.contains("One-off alternative without changing config: `grok wrap ssh <host>`")
);
assert!(preview.contains("Use `command ssh ...` to bypass the alias."));
assert!(preview.contains("ssh -f"));
assert!(preview.contains("ControlPersist"));
assert!(preview.contains("~^Z"));
}
#[test]
fn decline_is_success_and_does_not_write() {
let temp = tempfile::tempdir().unwrap();
let terminal = local_terminal();
let plan = crate::diagnostics::plan_fix(
ssh_wrap_fix_request(temp.path()),
&ssh_wrap_report(),
&terminal,
)
.unwrap();
let mut input = std::io::Cursor::new(b"n\n");
let mut output = Vec::new();
apply_fix_plan(
FixArgs {
id: "ssh-wrap".to_owned(),
yes: false,
},
true,
&mut input,
&mut output,
&terminal,
plan,
)
.unwrap();
assert!(String::from_utf8(output).unwrap().ends_with("Cancelled.\n"));
assert!(!temp.path().join(".bashrc").exists());
}
#[test]
fn non_tty_without_yes_fails_safely_before_write() {
let temp = tempfile::tempdir().unwrap();
let terminal = local_terminal();
let plan = crate::diagnostics::plan_fix(
ssh_wrap_fix_request(temp.path()),
&ssh_wrap_report(),
&terminal,
)
.unwrap();
let error = apply_fix_plan(
FixArgs {
id: "terminal.ssh-wrap".to_owned(),
yes: false,
},
false,
&mut std::io::Cursor::new(Vec::<u8>::new()),
&mut Vec::new(),
&terminal,
plan,
)
.unwrap_err();
assert!(
error
.to_string()
.contains("non-interactive stdin without --yes")
);
assert!(!temp.path().join(".bashrc").exists());
}
#[test]
fn human_incomplete_fixture_is_exact_without_duplicate_probe_rows() {
let mut report = healthy_report();
report.facts.xtversion = RuntimeFact::Unavailable;
report.facts.color.level = RuntimeFact::Unavailable;
report.facts.color.available_themes.clear();
report.facts.clipboard.data_control = DataControlFact::Unavailable;
report.probe_notes = vec![
ProbeNote {
probe: "runtime.xtversion",
status: ProbeStatus::Unavailable,
message: None,
},
ProbeNote {
probe: "terminal.color",
status: ProbeStatus::Unavailable,
message: None,
},
ProbeNote {
probe: "wayland.data-control",
status: ProbeStatus::Unavailable,
message: None,
},
];
assert_eq!(
human::format(&report),
concat!(
"Grok Doctor\n",
"\n",
"Terminal\n",
" · terminal Ghostty\n",
" ? xtversion unavailable\n",
" · multiplexer None detected\n",
" · ssh no\n",
" ? color unavailable\n",
" ? themes unavailable\n",
"\n",
"Clipboard\n",
" · native local (pbcopy)\n",
" · tmux off\n",
" · osc 52 off\n",
" · wrap off\n",
" · status confirmed\n",
"\n",
"Live TUI evidence\n",
" Run /doctor inside Grok.\n",
"\n",
"0 issues, 0 recommendations\n",
)
);
}
#[test]
fn json_empty_fixture_pins_null_policy() {
let mut report = healthy_report();
report.facts.xtversion = RuntimeFact::Unavailable;
report.facts.color.level = RuntimeFact::Unavailable;
report.facts.color.available_themes.clear();
report.facts.clipboard.data_control = DataControlFact::Unavailable;
let mut output = Vec::new();
write_report(&report, true, &mut output).unwrap();
let json: serde_json::Value = serde_json::from_slice(&output).unwrap();
assert_eq!(
json,
serde_json::json!({
"schemaVersion": "1",
"facts": {
"terminal": {
"name": "ghostty",
"xtversion": {"status": "unavailable", "value": null}
},
"multiplexer": {"kind": "undetected", "byobu": null},
"ssh": false,
"color": {
"level": {"status": "unavailable", "value": null},
"availableThemes": [],
"totalThemes": 5
},
"keyboard": null,
"newline": null,
"clipboard": {
"nativeRoute": true,
"nativeTool": "pbcopy",
"nativePreflight": "local_available",
"tmuxRoute": false,
"osc52Route": false,
"osc52Capability": "supported",
"wrapSink": false,
"displayServer": "unknown",
"containerNoDisplay": false,
"dataControl": "unavailable",
"delivery": "confirmed",
"fix": null
}
},
"findings": [],
"probeNotes": [],
"counts": {"issues": 0, "recommendations": 0, "probeNotes": 0}
})
);
}
#[test]
fn json_contract_is_structural_stable_ordered_and_ansi_free() {
let report = mixed_report();
let mut output = Vec::new();
write_report(&report, true, &mut output).expect("serialize doctor report");
let text = String::from_utf8(output).expect("JSON is UTF-8");
let json: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
assert_eq!(
json,
serde_json::json!({
"schemaVersion": "1",
"facts": {
"terminal": {
"name": "ghostty",
"xtversion": {"status": "available", "value": "Ghostty 1.2.3"}
},
"multiplexer": {"kind": "tmux", "byobu": "tmux"},
"ssh": true,
"color": {
"level": {"status": "available", "value": "256"},
"availableThemes": ["groknight", "grokday"],
"totalThemes": 5
},
"keyboard": {"cmd": "dropped", "opt": "native", "os": "macos"},
"newline": {"kind": "xterm_js", "terminalName": "cursor"},
"clipboard": {
"nativeRoute": true,
"nativeTool": "pbcopy",
"nativePreflight": "local_available",
"tmuxRoute": true,
"osc52Route": true,
"osc52Capability": "supported",
"wrapSink": false,
"displayServer": "unknown",
"containerNoDisplay": false,
"dataControl": "not_applicable",
"delivery": "confirmed",
"fix": null
}
},
"findings": [
{
"id": "terminal.tmux-clipboard",
"disposition": "issue",
"message": "OSC 52 clipboard passthrough is disabled",
"remediation": {
"fix": "set -g set-clipboard on",
"configPath": "~/.tmux.conf"
},
"automaticRemediation": null,
"note": "Reload tmux after editing."
},
{
"id": "terminal.ssh-wrap",
"disposition": "recommendation",
"message": "Use local SSH wrapping",
"remediation": {"fix": "grok wrap ssh <host>", "configPath": null},
"automaticRemediation": {
"fixId": "terminal.ssh-wrap",
"command": "grok doctor fix terminal.ssh-wrap"
},
"note": null
}
],
"probeNotes": [
{"probe": "tmux.version", "status": "unavailable", "message": null},
{"probe": "tmux.extended-keys", "status": "unavailable", "message": null},
{"probe": "tmux.allow-passthrough-support", "status": "unsupported", "message": null},
{"probe": "runtime.fullscreen-active", "status": "unavailable", "message": null},
{"probe": "tmux.control-mode", "status": "error", "message": "server unavailable"}
],
"counts": {"issues": 1, "recommendations": 1, "probeNotes": 5}
})
);
let issue = text.find("terminal.tmux-clipboard").expect("issue ID");
let recommendation = text.find("terminal.ssh-wrap").expect("recommendation ID");
let version = text.find("tmux.version").expect("version probe");
let extended = text.find("tmux.extended-keys").expect("extended-key probe");
let unsupported = text
.find("tmux.allow-passthrough-support")
.expect("unsupported probe");
let unavailable = text
.find("runtime.fullscreen-active")
.expect("unavailable probe");
assert!(issue < recommendation);
assert!(version < extended && extended < unsupported && unsupported < unavailable);
assert!(!text.contains("\u{1b}"));
assert!(!text.contains("Grok Doctor"));
}
#[test]
fn stable_mapping_tables_are_complete() {
use super::json::{
byobu_backend, clipboard_delivery, data_control, display_server, host_os, modifier_fate,
multiplexer, native_preflight, osc52_capability, terminal_name,
};
assert_eq!(
[
TerminalName::AppleTerminal,
TerminalName::Ghostty,
TerminalName::Iterm2,
TerminalName::WarpTerminal,
TerminalName::VsCode,
TerminalName::Cursor,
TerminalName::Windsurf,
TerminalName::Zed,
TerminalName::WezTerm,
TerminalName::Kitty,
TerminalName::Alacritty,
TerminalName::Rio,
TerminalName::Foot,
TerminalName::JetBrains,
TerminalName::GrokDesktop,
TerminalName::Vte,
TerminalName::Terminator,
TerminalName::WindowsTerminal,
TerminalName::Otty,
TerminalName::Unknown,
]
.map(terminal_name),
[
"apple_terminal",
"ghostty",
"iterm2",
"warp",
"vs_code",
"cursor",
"windsurf",
"zed",
"wezterm",
"kitty",
"alacritty",
"rio",
"foot",
"jetbrains",
"grok_desktop",
"vte",
"terminator",
"windows_terminal",
"otty",
"unknown",
]
);
assert_eq!(
[
MultiplexerKind::Tmux,
MultiplexerKind::Screen,
MultiplexerKind::Zellij,
MultiplexerKind::Cmux,
MultiplexerKind::Undetected,
]
.map(multiplexer),
["tmux", "screen", "zellij", "cmux", "undetected"]
);
assert_eq!(
[
ByobuBackend::Unknown,
ByobuBackend::Tmux,
ByobuBackend::Screen
]
.map(byobu_backend),
["unknown", "tmux", "screen"]
);
assert_eq!(
[
DataControlFact::Available,
DataControlFact::Missing,
DataControlFact::Unavailable,
DataControlFact::Error,
DataControlFact::NotApplicable,
]
.map(data_control),
[
"available",
"missing",
"unavailable",
"error",
"not_applicable"
]
);
assert_eq!(modifier_fate(ModifierFate::Native), "native");
assert_eq!(modifier_fate(ModifierFate::Dropped), "dropped");
assert_eq!(modifier_fate(ModifierFate::Unrecoverable), "unrecoverable");
assert_eq!(modifier_fate(ModifierFate::Unknown), "unknown");
assert_eq!(host_os(HostOs::Macos), "macos");
assert_eq!(host_os(HostOs::Linux), "linux");
assert_eq!(host_os(HostOs::Windows), "windows");
assert_eq!(host_os(HostOs::Other), "other");
assert_eq!(
[
NativeClipboardPreflight::Disabled,
NativeClipboardPreflight::LocalAvailable,
NativeClipboardPreflight::RemoteOnly,
NativeClipboardPreflight::Unavailable,
]
.map(native_preflight),
["disabled", "local_available", "remote_only", "unavailable"]
);
assert_eq!(
[
Osc52Capability::Supported,
Osc52Capability::Unsupported,
Osc52Capability::Unknown,
]
.map(osc52_capability),
["supported", "unsupported", "unknown"]
);
assert_eq!(
[
ClipboardDelivery::Confirmed,
ClipboardDelivery::Unverified,
ClipboardDelivery::Failed,
]
.map(clipboard_delivery),
["confirmed", "unverified", "failed"]
);
assert_eq!(
[
DisplayServer::Quartz,
DisplayServer::Wayland,
DisplayServer::X11,
DisplayServer::Win32,
DisplayServer::Unknown,
]
.map(display_server),
["quartz", "wayland", "x11", "win32", "unknown"]
);
}
#[test]
fn newline_variant_and_field_mappings_are_stable() {
for (fact, kind, field, value) in [
(
NewlineFact::Vte {
version: Some("8200".to_owned()),
},
"vte",
"version",
"8200",
),
(
NewlineFact::XtermJs {
terminal: TerminalName::Cursor,
},
"xterm_js",
"terminalName",
"cursor",
),
] {
let mut report = healthy_report();
report.facts.newline = Some(fact);
let mut output = Vec::new();
write_report(&report, true, &mut output).unwrap();
let json: serde_json::Value = serde_json::from_slice(&output).unwrap();
assert_eq!(json["facts"]["newline"]["kind"], kind);
assert_eq!(json["facts"]["newline"][field], value);
}
let mut report = healthy_report();
report.facts.newline = Some(NewlineFact::NoKittyKeyboardProtocol);
let mut output = Vec::new();
write_report(&report, true, &mut output).unwrap();
let json: serde_json::Value = serde_json::from_slice(&output).unwrap();
assert_eq!(
json["facts"]["newline"],
serde_json::json!({"kind": "no_kitty_keyboard_protocol"})
);
}
#[test]
fn output_writer_errors_propagate() {
struct BrokenWriter;
impl std::io::Write for BrokenWriter {
fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("closed"))
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
assert!(write_report(&healthy_report(), false, &mut BrokenWriter).is_err());
assert!(write_report(&healthy_report(), true, &mut BrokenWriter).is_err());
}