use std::io::{BufRead, IsTerminal, Write};
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::Args as ClapArgs;
use scrybe_core::config::{Config, RECORD_SOURCE_MIC_SYSTEM, RECORD_SYSTEM_BACKEND_TAP};
use scrybe_core::record_defaults;
use url::{Host, Url};
use crate::runtime::{expand_root, load_or_default_config};
#[derive(ClapArgs, Debug)]
pub struct Args {
#[arg(long)]
pub root: Option<PathBuf>,
#[arg(long, default_value_t = false)]
pub check_tap: bool,
#[arg(long, default_value_t = false)]
pub check_sck: bool,
#[arg(long, default_value_t = false, requires = "sign_self")]
pub fix: bool,
#[arg(long, requires = "fix")]
pub sign_self: Option<String>,
}
#[allow(clippy::unused_async)]
pub async fn run(args: Args) -> Result<()> {
let mut report = Report::default();
let config_path = Config::discover_path().context("resolving config path")?;
report.lines.push(format!(
"config: {} (exists={})",
config_path.display(),
config_path.exists()
));
let cfg = load_or_default_config()?;
let root = match &args.root {
Some(path) => expand_root(path),
None => expand_root(&cfg.storage.root),
};
report.lines.push(format!(
"storage root: {} (exists={})",
root.display(),
root.exists()
));
if root.exists() {
scan_root(&root, &mut report)?;
}
report_egress_posture(&cfg, &mut report);
run_capture_onboarding(&cfg, &args, &mut report).await?;
for line in &report.lines {
println!("{line}");
}
if report.warnings == 0 {
println!("scrybe doctor: ok ({} checks)", report.lines.len());
} else {
println!(
"scrybe doctor: completed with {} warnings (see lines above)",
report.warnings
);
}
Ok(())
}
#[derive(Default, Debug)]
struct Report {
lines: Vec<String>,
warnings: u32,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum OnboardingTarget {
MicrophoneOnly,
ScreenCaptureKit,
CoreAudioTap,
}
fn effective_onboarding_target(cfg: &Config) -> OnboardingTarget {
if record_defaults::ergonomic_source(&cfg.record) != RECORD_SOURCE_MIC_SYSTEM {
return OnboardingTarget::MicrophoneOnly;
}
if cfg.record.validated_system_backend() == Some(RECORD_SYSTEM_BACKEND_TAP) {
OnboardingTarget::CoreAudioTap
} else {
OnboardingTarget::ScreenCaptureKit
}
}
async fn run_capture_onboarding(cfg: &Config, args: &Args, report: &mut Report) -> Result<()> {
let target = effective_onboarding_target(cfg);
if args.check_sck {
check_sck(report).await;
}
if args.check_tap {
return run_tap_onboarding(args, report, true).await;
}
if args.fix {
if target == OnboardingTarget::CoreAudioTap {
return run_tap_onboarding(args, report, false).await;
}
report
.lines
.push("macOS onboarding: no Core Audio Tap bundle repair is applicable".to_string());
return Ok(());
}
if args.check_sck {
return Ok(());
}
match target {
OnboardingTarget::MicrophoneOnly => {
report.lines.push(
"capture onboarding: microphone-only; no system-audio probe required".to_string(),
);
}
OnboardingTarget::ScreenCaptureKit => {
report
.lines
.push("system audio backend: ScreenCaptureKit".to_string());
if terminal_is_interactive() {
if confirm_optional("Run the live system-audio permission check now? [y/N] ")
.await?
{
check_sck(report).await;
} else {
report.lines.push(
"sck probe: declined; run `scrybe doctor --check-sck` later".to_string(),
);
}
} else {
report.lines.push(
"sck probe: skipped (non-interactive); run `scrybe doctor --check-sck`"
.to_string(),
);
}
}
OnboardingTarget::CoreAudioTap => {
run_tap_onboarding(args, report, false).await?;
}
}
Ok(())
}
fn terminal_is_interactive() -> bool {
std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
}
async fn confirm_optional(prompt: &str) -> Result<bool> {
let prompt = prompt.to_string();
tokio::task::spawn_blocking(move || -> Result<bool> {
let stderr = std::io::stderr();
let mut writer = stderr.lock();
writer
.write_all(prompt.as_bytes())
.context("writing doctor prompt")?;
writer.flush().context("flushing doctor prompt")?;
drop(writer);
let stdin = std::io::stdin();
let mut answer = String::new();
stdin
.lock()
.read_line(&mut answer)
.context("reading doctor response")?;
Ok(crate::prompter::is_affirmative_response(&answer))
})
.await
.context("joining doctor prompt task")?
}
#[cfg(all(target_os = "macos", feature = "system-capture-mac"))]
async fn run_tap_onboarding(args: &Args, report: &mut Report, explicit_probe: bool) -> Result<()> {
use crate::macos_bundle::BundleState;
report
.lines
.push("system audio backend: Core Audio Tap".to_string());
if crate::macos_bundle::already_inside_bundle() {
check_tap(report).await;
return Ok(());
}
let destination = crate::macos_bundle::repair_destination()?;
let state = crate::macos_bundle::inspect_bundle(&destination);
report.lines.push(bundle_state_line(&destination, &state));
let ready = matches!(state, BundleState::Ready);
if !ready {
if args.fix {
let identity =
crate::macos_bundle::resolve_signing_identity(args.sign_self.as_deref())?;
install_current_bundle(&destination, &identity)?;
report.lines.push(format!(
"tap bundle repaired: {} (identity={identity})",
destination.display()
));
} else if terminal_is_interactive() {
let identity = match crate::macos_bundle::resolve_signing_identity(None) {
Ok(identity) => identity,
Err(error) => {
report
.lines
.push(format!("tap bundle repair unavailable: {error}"));
report.warnings += 1;
return Ok(());
}
};
eprintln!(
"Core Audio Tap bundle repair:\n destination: {}\n identity: {identity}",
destination.display()
);
if !confirm_optional("Repair the Core Audio Tap bundle now? [y/N] ").await? {
report.lines.push(format!(
"tap bundle repair: declined; run `scrybe doctor --check-tap --fix --sign-self {identity}`"
));
report.warnings += 1;
return Ok(());
}
install_current_bundle(&destination, &identity)?;
report.lines.push(format!(
"tap bundle repaired: {} (identity={identity})",
destination.display()
));
} else {
report.lines.push(
"tap bundle repair: skipped (non-interactive); run `scrybe doctor --check-tap --fix --sign-self <identity>`"
.to_string(),
);
report.warnings += 1;
return Ok(());
}
}
if explicit_probe {
run_bundled_tap_probe(&destination, report).await;
} else if args.fix {
return Ok(());
} else if terminal_is_interactive() {
if confirm_optional("Run the live Core Audio Tap permission check now? [y/N] ").await? {
run_bundled_tap_probe(&destination, report).await;
} else {
report
.lines
.push("tap probe: declined; run `scrybe doctor --check-tap` later".to_string());
}
} else {
report.lines.push(
"tap probe: skipped (non-interactive); run `scrybe doctor --check-tap`".to_string(),
);
}
Ok(())
}
#[cfg(all(target_os = "macos", feature = "system-capture-mac"))]
fn bundle_state_line(path: &std::path::Path, state: &crate::macos_bundle::BundleState) -> String {
use crate::macos_bundle::BundleState;
match state {
BundleState::Missing => format!("tap bundle: missing ({})", path.display()),
BundleState::Invalid { reason } => {
format!("tap bundle: invalid ({reason}; {})", path.display())
}
BundleState::Stale { found_version } => format!(
"tap bundle: stale (found {found_version}, need {}; {})",
env!("CARGO_PKG_VERSION"),
path.display()
),
BundleState::Ready => format!("tap bundle: ready ({})", path.display()),
}
}
#[cfg(all(target_os = "macos", feature = "system-capture-mac"))]
fn install_current_bundle(destination: &std::path::Path, identity: &str) -> Result<()> {
let binary = std::env::current_exe().context("resolving installed scrybe executable")?;
crate::macos_bundle::install_bundle(&binary, destination, identity)?;
match crate::macos_bundle::inspect_bundle(destination) {
crate::macos_bundle::BundleState::Ready => Ok(()),
state => anyhow::bail!("repaired Tap bundle failed final validation: {state:?}"),
}
}
#[cfg(all(target_os = "macos", feature = "system-capture-mac"))]
async fn run_bundled_tap_probe(destination: &std::path::Path, report: &mut Report) {
eprintln!(
"scrybe: launching Core Audio Tap diagnostic via {}",
destination.display()
);
match crate::bundle_launcher::launch_doctor_probe_via_bundle(destination).await {
Ok(output) => {
report.lines.extend(
output
.stdout
.lines()
.map(|line| format!("tap bundle stdout: {line}")),
);
report.lines.extend(
output
.stderr
.lines()
.map(|line| format!("tap bundle stderr: {line}")),
);
if !output.success {
report.warnings += 1;
report.lines.push(
"tap bundle probe: bundled diagnostic did not report success".to_string(),
);
}
}
Err(error) => {
report.warnings += 1;
report
.lines
.push(format!("tap bundle probe: launch failed: {error:#}"));
}
}
}
#[cfg(not(all(target_os = "macos", feature = "system-capture-mac")))]
async fn run_tap_onboarding(args: &Args, report: &mut Report, _explicit_probe: bool) -> Result<()> {
report
.lines
.push("system audio backend: Core Audio Tap".to_string());
if args.fix {
anyhow::bail!("Tap bundle repair requires macOS and the `system-capture-mac` feature");
}
check_tap(report).await;
Ok(())
}
fn scan_root(root: &std::path::Path, report: &mut Report) -> Result<()> {
let mut session_count = 0_u32;
let mut orphaned_locks = 0_u32;
let mut orphaned_partials = 0_u32;
let entries = std::fs::read_dir(root).with_context(|| format!("reading {}", root.display()))?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
session_count += 1;
let lock = path.join(scrybe_core::storage::PID_LOCK_NAME);
if lock.exists() {
if pid_alive_from_lock(&lock).unwrap_or(false) {
report
.lines
.push(format!("session in progress: {}", path.display()));
} else {
orphaned_locks += 1;
report
.lines
.push(format!("orphaned pid.lock: {}", lock.display()));
}
}
} else {
let is_partial = path
.file_name()
.and_then(|s| s.to_str())
.is_some_and(|name| name.ends_with(".partial"));
if is_partial {
orphaned_partials += 1;
report
.lines
.push(format!("orphaned partial download: {}", path.display()));
}
}
}
report
.lines
.push(format!("sessions found: {session_count}"));
if orphaned_locks > 0 {
report.warnings += orphaned_locks;
}
if orphaned_partials > 0 {
report.warnings += orphaned_partials;
}
Ok(())
}
pub(super) fn pid_alive_from_lock(lock_path: &std::path::Path) -> Result<bool> {
let body = std::fs::read_to_string(lock_path).context("reading pid.lock")?;
let pid: u32 = body
.trim()
.parse()
.with_context(|| format!("parsing pid in {}", lock_path.display()))?;
Ok(is_pid_alive(pid))
}
#[cfg(unix)]
#[allow(clippy::cast_possible_wrap)]
fn is_pid_alive(pid: u32) -> bool {
#[allow(unsafe_code)]
let rc = unsafe { libc::kill(pid as libc::pid_t, 0) };
rc == 0
}
#[cfg(windows)]
fn is_pid_alive(pid: u32) -> bool {
use windows_sys::Win32::Foundation::{
CloseHandle, GetLastError, ERROR_ACCESS_DENIED, WAIT_OBJECT_0,
};
use windows_sys::Win32::System::Threading::{
OpenProcess, WaitForSingleObject, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE,
};
#[allow(unsafe_code)]
unsafe {
let handle = OpenProcess(
PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE,
0,
pid,
);
if handle.is_null() {
return GetLastError() == ERROR_ACCESS_DENIED;
}
let wait = WaitForSingleObject(handle, 0);
let _ = CloseHandle(handle);
wait != WAIT_OBJECT_0
}
}
#[cfg(not(any(unix, windows)))]
const fn is_pid_alive(_pid: u32) -> bool {
true
}
#[cfg(all(target_os = "macos", feature = "system-capture-mac"))]
const TAP_PROBE_WINDOW: std::time::Duration = std::time::Duration::from_millis(1_500);
#[cfg(all(target_os = "macos", feature = "system-capture-mac"))]
async fn check_sck(report: &mut Report) {
use futures::StreamExt;
use scrybe_capture_mac::probe_chime::{play_probe_chime, PROBE_CHIME_PASS_THRESHOLD};
use scrybe_capture_mac::SckCapture;
use scrybe_core::capture::AudioCapture;
let mut capture = SckCapture::new();
if let Err(e) = capture.start() {
report.lines.push(format!("sck probe: start failed: {e}"));
report.warnings += 1;
return;
}
let chime_handle = tokio::task::spawn_blocking(move || play_probe_chime(TAP_PROBE_WINDOW));
let mut frames = capture.frames();
let deadline = tokio::time::Instant::now() + TAP_PROBE_WINDOW;
let mut frame_count: u64 = 0;
let mut peak: f32 = 0.0;
loop {
match tokio::time::timeout_at(deadline, frames.next()).await {
Ok(Some(Ok(frame))) => {
frame_count += 1;
for sample in frame.samples.iter() {
peak = peak.max(sample.abs());
}
}
Ok(Some(Err(e))) => {
report
.lines
.push(format!("sck probe: capture error mid-stream: {e}"));
report.warnings += 1;
break;
}
Ok(None) | Err(_) => break,
}
}
let _ = capture.stop();
match chime_handle.await {
Ok(Ok(())) => {}
Ok(Err(e)) => {
report
.lines
.push(format!("sck probe: chime playback failed: {e}"));
report.warnings += 1;
}
Err(e) => {
report
.lines
.push(format!("sck probe: chime task failed: {e}"));
report.warnings += 1;
}
}
let verdict = if frame_count == 0 {
report.warnings += 1;
"FAIL: no frames received"
} else if peak < PROBE_CHIME_PASS_THRESHOLD {
report.warnings += 1;
"FAIL: silent frames (Screen & System Audio Recording not granted)"
} else {
"OK"
};
report.lines.push(format!(
"sck probe: frames={frame_count} peak={peak:.5} → {verdict}"
));
}
#[cfg(all(target_os = "macos", feature = "system-capture-mac"))]
async fn check_tap(report: &mut Report) {
use futures::StreamExt;
use scrybe_capture_mac::probe_chime::{play_probe_chime, PROBE_CHIME_PASS_THRESHOLD};
use scrybe_capture_mac::MacCapture;
use scrybe_core::capture::AudioCapture;
let mut capture = MacCapture::new();
if let Err(e) = capture.start() {
report.lines.push(format!("tap probe: start failed: {e}"));
report.warnings += 1;
return;
}
let chime_handle = tokio::task::spawn_blocking(move || play_probe_chime(TAP_PROBE_WINDOW));
let mut frames = capture.frames();
let deadline = tokio::time::Instant::now() + TAP_PROBE_WINDOW;
let mut frame_count: u64 = 0;
let mut peak: f32 = 0.0;
loop {
match tokio::time::timeout_at(deadline, frames.next()).await {
Ok(Some(Ok(frame))) => {
frame_count += 1;
for s in frame.samples.iter() {
let abs = s.abs();
if abs > peak {
peak = abs;
}
}
}
Ok(Some(Err(e))) => {
report
.lines
.push(format!("tap probe: capture error mid-stream: {e}"));
report.warnings += 1;
break;
}
Ok(None) | Err(_) => break,
}
}
let _ = capture.stop();
match chime_handle.await {
Ok(Ok(())) => {}
Ok(Err(e)) => {
report
.lines
.push(format!("tap probe: chime playback failed: {e}"));
report.warnings += 1;
}
Err(e) => {
report
.lines
.push(format!("tap probe: chime playback task panicked: {e}"));
report.warnings += 1;
}
}
let verdict = if frame_count == 0 {
report.warnings += 1;
"FAIL: IOProc never fired (entitlement, sandbox, or aggregate-device construction failure)"
} else if peak < PROBE_CHIME_PASS_THRESHOLD {
report.warnings += 1;
"FAIL: tap delivered silent frames (Audio Capture permission denied, stale, or routed away)"
} else {
"OK"
};
report.lines.push(format!(
"tap probe: frames={frame_count} peak={peak:.5} → {verdict}"
));
if frame_count > 0 && peak < PROBE_CHIME_PASS_THRESHOLD {
emit_silent_tap_remediation(report);
}
}
#[cfg(all(target_os = "macos", feature = "system-capture-mac"))]
fn emit_silent_tap_remediation(report: &mut Report) {
report.lines.push(" remediation:".to_string());
report.lines.push(
" 1. Remove stale TCC entry: System Settings → Privacy & Security \
→ Audio Recording → click `-` next to scrybe"
.to_string(),
);
report.lines.push(
" 2. Re-run `scrybe doctor --check-tap` and click Allow on the \
Audio Capture prompt"
.to_string(),
);
report.lines.push(
" 3. If Doctor reports a bundle problem, repair it with \
`scrybe doctor --check-tap --fix --sign-self scrybe-local-signing`"
.to_string(),
);
if let Some(service) = discover_tcc_audio_service() {
report.lines.push(format!(
" 4. (alternative reset) sudo tccutil reset {service} dev.scrybe.scrybe"
));
}
}
#[cfg(all(target_os = "macos", feature = "system-capture-mac"))]
fn discover_tcc_audio_service() -> Option<String> {
let framework = "/System/Library/PrivateFrameworks/TCC.framework/Versions/A/TCC";
let output = std::process::Command::new("dyld_info")
.args(["-exports", framework])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = std::str::from_utf8(&output.stdout).ok()?;
let candidates: Vec<&str> = text
.lines()
.filter_map(|line| line.split_whitespace().last())
.filter(|tok| tok.starts_with("_kTCCService"))
.map(|tok| tok.trim_start_matches("_kTCCService"))
.filter(|name| name.to_ascii_lowercase().contains("audio"))
.collect();
candidates
.iter()
.find(|n| n.eq_ignore_ascii_case("AudioCapture"))
.or_else(|| candidates.first())
.map(|s| (*s).to_string())
}
#[cfg(not(all(target_os = "macos", feature = "system-capture-mac")))]
#[allow(clippy::unused_async)]
async fn check_tap(report: &mut Report) {
report.lines.push(
"tap probe: skipped (binary not built with --features system-capture-mac on macOS)"
.to_string(),
);
}
#[cfg(not(all(target_os = "macos", feature = "system-capture-mac")))]
#[allow(clippy::unused_async)]
async fn check_sck(report: &mut Report) {
report.lines.push(
"sck probe: skipped (binary not built with --features system-capture-mac on macOS)"
.to_string(),
);
report.warnings += 1;
}
fn report_egress_posture(cfg: &Config, report: &mut Report) {
let stt = match cfg.stt.provider.as_str() {
"whisper-local" => "no egress (local Whisper)".to_string(),
other => cfg.stt.base_url.as_deref().map_or_else(
|| format!("STT provider {other} configured without base_url"),
|url| format!("egress to STT provider {other} at {url}"),
),
};
let llm = if is_loopback_url(&cfg.llm.base_url) {
format!("no egress (local LLM at {})", cfg.llm.base_url)
} else {
format!(
"egress to LLM provider {} at {}",
cfg.llm.provider, cfg.llm.base_url
)
};
report.lines.push(format!("stt egress: {stt}"));
report.lines.push(format!("llm egress: {llm}"));
}
fn is_loopback_url(value: &str) -> bool {
Url::parse(value)
.ok()
.and_then(|url| {
url.host().map(|host| match host {
Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
Host::Ipv4(address) => address.is_loopback(),
Host::Ipv6(address) => address.is_loopback(),
})
})
.unwrap_or(false)
}
#[cfg(unix)]
extern crate libc;
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_report_egress_posture_local_only_emits_no_egress_lines() {
let cfg = Config::default();
let mut report = Report::default();
report_egress_posture(&cfg, &mut report);
assert_eq!(report.lines.len(), 2);
assert!(report.lines[0].contains("no egress"));
assert!(report.lines[1].contains("no egress"));
}
#[test]
fn test_report_egress_posture_openai_compat_loopback_is_local() {
let mut cfg = Config::default();
cfg.llm.provider = "openai-compat".into();
cfg.llm.base_url = "http://127.0.0.1:11434/v1".into();
let mut report = Report::default();
report_egress_posture(&cfg, &mut report);
assert!(report.lines[1].contains("no egress"));
}
#[test]
fn test_report_egress_posture_hosted_llm_remains_egress() {
let mut cfg = Config::default();
cfg.llm.provider = "openai-compat".into();
cfg.llm.base_url = "https://openrouter.ai/api/v1".into();
let mut report = Report::default();
report_egress_posture(&cfg, &mut report);
assert!(report.lines[1].contains("egress"));
}
#[test]
fn test_report_egress_posture_openai_compat_stt_reports_base_url() {
let mut cfg = Config::default();
cfg.stt.provider = "openai-compat".into();
cfg.stt.base_url = Some("https://api.groq.com/openai/v1".into());
let mut report = Report::default();
report_egress_posture(&cfg, &mut report);
assert!(report.lines[0].contains("https://api.groq.com/openai/v1"));
}
#[test]
fn test_scan_root_for_empty_root_reports_zero_sessions() {
let dir = tempfile::tempdir().unwrap();
let mut report = Report::default();
scan_root(dir.path(), &mut report).unwrap();
assert_eq!(report.warnings, 0);
assert!(report.lines.iter().any(|l| l.contains("sessions found: 0")));
}
#[test]
fn test_scan_root_flags_orphaned_partial_downloads() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("model.gguf.partial"), b"abc").unwrap();
let mut report = Report::default();
scan_root(dir.path(), &mut report).unwrap();
assert_eq!(report.warnings, 1);
assert!(report.lines.iter().any(|l| l.contains("orphaned partial")));
}
#[test]
fn test_scan_root_flags_orphaned_pid_lock_for_dead_process() {
let dir = tempfile::tempdir().unwrap();
let folder = dir.path().join("session-x");
std::fs::create_dir(&folder).unwrap();
std::fs::write(folder.join(scrybe_core::storage::PID_LOCK_NAME), b"1\n").unwrap();
let mut report = Report::default();
scan_root(dir.path(), &mut report).unwrap();
assert!(report.lines.iter().any(|l| l.contains("session-x")));
}
#[test]
fn onboarding_target_is_microphone_only_for_mic_source() {
let mut cfg = Config::default();
cfg.record.source = "mic".to_string();
assert_eq!(
effective_onboarding_target(&cfg),
OnboardingTarget::MicrophoneOnly
);
}
#[test]
fn onboarding_target_uses_configured_system_backend() {
let mut cfg = Config::default();
cfg.record.source = RECORD_SOURCE_MIC_SYSTEM.to_string();
cfg.record.system_backend = RECORD_SYSTEM_BACKEND_TAP.to_string();
assert_eq!(
effective_onboarding_target(&cfg),
OnboardingTarget::CoreAudioTap
);
cfg.record.system_backend = "sck".to_string();
assert_eq!(
effective_onboarding_target(&cfg),
OnboardingTarget::ScreenCaptureKit
);
}
}