whisper-macos-cli 0.1.0

Transcribe audio files locally on Apple Silicon via whisper.cpp with Metal GPU acceleration, exposing a strict stdin/stdout JSON contract for AI agents and Unix pipelines.
Documentation
pub mod audio;
pub mod cli;
pub mod commands;
pub mod error;
pub mod language;
pub mod model;
pub mod output;
pub mod signal;
pub mod whisper;

use std::io::Write;
use std::process::ExitCode;
use std::time::Duration;

use clap::CommandFactory;
use clap::Parser;
use uuid::Uuid;

use crate::cli::{Cli, Commands, CommandsFormat};
use crate::error::Error;
use crate::language::detect::resolve_language;

const DOCTOR_TIMEOUT_SECS: u64 = 5;
const DEFAULT_INPUT_TIMEOUT_SECS: u64 = 30;

pub fn run() -> ExitCode {
    signal::reset_sigpipe();
    signal::install_handlers();

    let cli = Cli::parse();
    let correlation_id = generate_correlation_id();

    init_tracing(cli.quiet, cli.verbose);

    if cli.print_schema {
        print_schema_envelope(&correlation_id);
        return ExitCode::SUCCESS;
    }

    if cli.print_config {
        print_config_envelope(&cli, &correlation_id);
        return ExitCode::SUCCESS;
    }

    match cli.command {
        Some(Commands::Transcribe(args)) => run_transcribe(args, cli.no_input, &correlation_id),
        Some(Commands::Models { action }) => run_models(action, &correlation_id),
        Some(Commands::Doctor) => run_doctor(&correlation_id),
        Some(Commands::Schema) => {
            print_schema_envelope(&correlation_id);
            ExitCode::SUCCESS
        }
        Some(Commands::Config) => {
            print_config_envelope(&cli, &correlation_id);
            ExitCode::SUCCESS
        }
        Some(Commands::Completions { shell }) => {
            run_completions(shell);
            ExitCode::SUCCESS
        }
        Some(Commands::Commands { format }) => {
            run_commands_tree(format, &correlation_id);
            ExitCode::SUCCESS
        }
        Some(Commands::Init { target }) => run_init(&target, &correlation_id),
        Some(Commands::Licenses) => run_licenses(&correlation_id),
        Some(Commands::Resume { workflow_id }) => {
            tracing::info!(workflow_id, "resume not yet supported in v0.1");
            let value = serde_json::json!({
                "schema_version": env!("CARGO_PKG_VERSION"),
                "correlation_id": correlation_id,
                "resume_supported": false,
                "workflow_id": workflow_id,
                "hint": "v0.1 does not persist checkpoints; see --dry-run for input validation",
            });
            let _ = output::write_json_value(&value);
            ExitCode::SUCCESS
        }
        None => {
            let err = Error::NoInput;
            let _ = output::write_error(&err, &correlation_id);
            err.to_exit_code()
        }
    }
}

fn generate_correlation_id() -> String {
    Uuid::now_v7().to_string()
}

fn run_transcribe(args: cli::TranscribeArgs, no_input: bool, correlation_id: &str) -> ExitCode {
    let (language, language_source) = resolve_language(args.language.as_deref());
    tracing::info!(language, language_source, model = %args.model, "starting transcription");

    let effective_no_input = no_input || is_ci();

    if args.files.is_empty() && (effective_no_input || is_terminal::is_terminal(std::io::stdin())) {
        let err = Error::NoInput;
        let _ = output::write_error(&err, correlation_id);
        return err.to_exit_code();
    }

    match commands::transcribe::run(&args, language, language_source, correlation_id) {
        Ok(()) => {
            if signal::is_shutdown_requested() {
                ExitCode::from(signal::shutdown_signal_exit_code())
            } else {
                ExitCode::SUCCESS
            }
        }
        Err(e) => exit_with_error(e, correlation_id),
    }
}

fn run_models(action: cli::ModelsAction, correlation_id: &str) -> ExitCode {
    match commands::models::run(&action, correlation_id) {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => exit_with_error(e, correlation_id),
    }
}

fn run_completions(shell: clap_complete::Shell) {
    let mut cmd = Cli::command();
    clap_complete::generate(shell, &mut cmd, "whisper-macos-cli", &mut std::io::stdout());
}

fn run_doctor(correlation_id: &str) -> ExitCode {
    match commands::doctor::run(correlation_id) {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => exit_with_error(e, correlation_id),
    }
}

fn run_commands_tree(format: CommandsFormat, correlation_id: &str) -> ExitCode {
    let cmd = Cli::command();
    let name = cmd.get_name().to_string();
    let about = cmd.get_about().map(|s| s.to_string()).unwrap_or_default();
    let version = cmd.get_version().unwrap_or("unknown").to_string();

    let subcommands: Vec<serde_json::Value> = cmd
        .get_subcommands()
        .map(|sc| {
            serde_json::json!({
                "name": sc.get_name(),
                "about": sc.get_about().map(|a| a.to_string()).unwrap_or_default(),
                "subcommands": collect_subs(sc),
            })
        })
        .collect();

    let tree = serde_json::json!({
        "schema_version": env!("CARGO_PKG_VERSION"),
        "correlation_id": correlation_id,
        "name": name,
        "about": about,
        "version": version,
        "subcommands": subcommands,
    });

    let result = match format {
        CommandsFormat::Json => {
            let _ = output::write_json_value(&tree);
            Ok(())
        }
        CommandsFormat::Yaml => {
            tracing::warn!("YAML output not yet supported; emitting JSON");
            let _ = output::write_json_value(&tree);
            Ok(())
        }
    };
    match result {
        Ok(()) => ExitCode::SUCCESS,
        Err(()) => ExitCode::from(74),
    }
}

fn collect_subs(cmd: &clap::Command) -> Vec<serde_json::Value> {
    cmd.get_subcommands()
        .map(|sc| {
            serde_json::json!({
                "name": sc.get_name(),
                "about": sc.get_about().map(|a| a.to_string()).unwrap_or_default(),
                "subcommands": collect_subs(sc),
            })
        })
        .collect()
}

fn run_init(target: &std::path::Path, correlation_id: &str) -> ExitCode {
    let skill_path = target.join("SKILL.md");
    let agents_path = target.join("AGENTS.md");

    let skill_content = format!(
        "---\nname: whisper-macos-cli\nversion: {ver}\ndescription: Transcribe audio via whisper.cpp on macOS Apple Silicon\n---\n\n# whisper-macos-cli\n\nSee https://github.com/daniloaguiarbr/whisper-macos-cli for full documentation.\n",
        ver = env!("CARGO_PKG_VERSION")
    );

    let agents_content = format!(
        "# Agent Integration Guide\n\nGenerated by whisper-macos-cli v{ver}.\n\n## Quickstart\n\n```bash\nwhisper-macos-cli transcribe audio.ogg\n```\n\nSee `whisper-macos-cli schema` for the JSON Schema contract.\n",
        ver = env!("CARGO_PKG_VERSION")
    );

    let result: Result<(), std::io::Error> = (|| {
        std::fs::write(&skill_path, skill_content)?;
        std::fs::write(&agents_path, agents_content)?;
        Ok(())
    })();

    let value = match &result {
        Ok(()) => serde_json::json!({
            "schema_version": env!("CARGO_PKG_VERSION"),
            "correlation_id": correlation_id,
            "action": "initialized",
            "skill": skill_path.display().to_string(),
            "agents": agents_path.display().to_string(),
        }),
        Err(e) => serde_json::json!({
            "schema_version": env!("CARGO_PKG_VERSION"),
            "correlation_id": correlation_id,
            "error": true,
            "message": e.to_string(),
        }),
    };
    let _ = output::write_json_value(&value);

    match result {
        Ok(()) => ExitCode::SUCCESS,
        Err(_) => ExitCode::from(74),
    }
}

fn run_licenses(correlation_id: &str) -> ExitCode {
    let value = serde_json::json!({
        "schema_version": env!("CARGO_PKG_VERSION"),
        "correlation_id": correlation_id,
        "license": "MIT",
        "third_party_notice": "Run `cargo about generate about.hbs > THIRD-PARTY-LICENSES.md` to regenerate the full attribution report.",
        "key_dependencies": [
            {"name": "whisper.cpp", "license": "MIT", "url": "https://github.com/ggml-org/whisper.cpp"},
            {"name": "symphonia", "license": "MPL-2.0", "url": "https://github.com/pdeljanov/symphonia"},
            {"name": "clap", "license": "MIT OR Apache-2.0", "url": "https://github.com/clap-rs/clap"},
            {"name": "serde", "license": "MIT OR Apache-2.0", "url": "https://github.com/serde-rs/serde"},
        ]
    });
    let _ = output::write_json_value(&value);
    ExitCode::SUCCESS
}

fn exit_with_error(e: Error, correlation_id: &str) -> ExitCode {
    if let Error::Io(ref io_err) = e {
        if io_err.kind() == std::io::ErrorKind::BrokenPipe {
            let _ = std::io::stdout().flush();
            return ExitCode::from(141);
        }
    }
    let _ = output::write_error(&e, correlation_id);
    e.to_exit_code()
}

fn is_ci() -> bool {
    matches!(
        std::env::var("CI").ok().as_deref(),
        Some("1") | Some("true") | Some("TRUE") | Some("yes")
    )
}

fn print_schema_envelope(correlation_id: &str) {
    let result_schema = serde_json::json!({
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "title": "TranscriptionResult",
        "type": "object",
        "properties": {
            "schema_version": { "type": "string" },
            "correlation_id": { "type": "string" },
            "file": { "type": "string" },
            "language": { "type": "string" },
            "language_source": { "type": "string", "enum": ["cli", "whisper_auto", "os_locale"] },
            "model": { "type": "string" },
            "duration_seconds": { "type": "number" },
            "text": { "type": "string" },
            "segments": { "type": "array" },
            "vad_chunks": { "type": "integer" },
            "processing_time_ms": { "type": "integer" }
        },
        "required": ["schema_version", "correlation_id", "file", "language", "model", "duration_seconds", "text", "vad_chunks", "processing_time_ms"]
    });

    let envelope = serde_json::json!({
        "schema_version": env!("CARGO_PKG_VERSION"),
        "correlation_id": correlation_id,
        "agentNotes": "whisper-macos-cli emits a single JSON object per invocation. Use schema_version to gate downstream consumers. correlation_id is a UUID v7 generated per process invocation. text is NFC-normalized.",
        "invariants": [
            "stdout is always valid JSON or NDJSON",
            "stderr is always human-readable logs (suppressed with --quiet)",
            "exit codes follow sysexits.h convention",
            "large-v3 is the default model",
            "OGG/Opus (WhatsApp voice messages) is supported natively",
            "output is reproducible given same input and same model"
        ],
        "sideEffects": [
            "may download a model file on first use (~75MB to ~3GB)",
            "may write to ~/Library/Application Support/whisper-macos-cli/models/",
            "may load ~3GB into unified memory on Apple Silicon"
        ],
        "idempotent": true,
        "checkpointable": false,
        "tokenBudget": {
            "invocation_overhead": 200,
            "per_file_transcription": "50 + transcribed text length"
        },
        "result_schema": result_schema,
        "error_schema": {
            "type": "object",
            "required": ["schema_version", "error", "code", "message", "category", "retryable", "docs_url", "correlation_id"],
            "properties": {
                "schema_version": { "type": "string" },
                "error": { "type": "boolean" },
                "code": { "type": "integer" },
                "message": { "type": "string" },
                "category": { "type": "string", "enum": ["usage", "input", "data", "config", "service", "internal", "io"] },
                "retryable": { "type": "boolean" },
                "retry_after_ms": { "type": ["integer", "null"] },
                "hint": { "type": ["string", "null"] },
                "docs_url": { "type": "string" },
                "correlation_id": { "type": "string" }
            }
        }
    });
    let _ = output::write_schema(&envelope);
}

fn print_config_envelope(cli: &Cli, correlation_id: &str) {
    let value = serde_json::json!({
        "schema_version": env!("CARGO_PKG_VERSION"),
        "correlation_id": correlation_id,
        "config": {
            "quiet": cli.quiet,
            "verbose": cli.verbose,
            "no_input": cli.no_input,
            "ci_mode": is_ci(),
            "color": format!("{:?}", cli.color),
        }
    });
    let _ = output::write_json_value(&value);
}

fn init_tracing(quiet: bool, verbose: u8) {
    use tracing_subscriber::EnvFilter;

    if quiet {
        return;
    }

    let level = match verbose {
        0 => "warn",
        1 => "info",
        2 => "debug",
        _ => "trace",
    };

    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level));

    if is_terminal::is_terminal(std::io::stderr()) {
        let _ = tracing_subscriber::fmt()
            .with_env_filter(filter)
            .with_writer(std::io::stderr)
            .with_target(false)
            .try_init();
    } else {
        let _ = tracing_subscriber::fmt()
            .with_env_filter(filter)
            .with_writer(std::io::stderr)
            .with_target(false)
            .json()
            .try_init();
    }
}

#[doc(hidden)]
pub const _DOCTOR_TIMEOUT: Duration = Duration::from_secs(DOCTOR_TIMEOUT_SECS);
#[doc(hidden)]
pub const _DEFAULT_INPUT_TIMEOUT: Duration = Duration::from_secs(DEFAULT_INPUT_TIMEOUT_SECS);