bird 0.2.0

X API CLI with entity caching, search, threads, and watchlists
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
//! Layered entrypoints for in-process invocation.
//!
//! - [`run_argv`]: the binary entrypoint. Reads `args_os`, locks std stdout/stderr.
//! - [`run`]: convenience wrapper for library consumers — loads paths/env from
//!   the process and delegates to [`run_with_paths`].
//! - [`run_with_paths`]: the real worker. Tests call this directly with
//!   `TempDir`-backed [`ResolvedPaths`] and explicit [`EnvOverrides`].
//!
//! The library never calls `process::exit`; it returns [`ExitCode`] to the caller.
//!
//! All stdout writes in this module route through the runner-injected
//! `stdout` writer: the `Completions` short-circuit, the `--examples`
//! short-circuit (`print_examples`), and the JSON-wrapped clap help/version
//! envelope branch. Diagnostic writes route through the injected `stderr`
//! writer or — for `BirdClient`/`BirdDb` internal sites — through the
//! `Arc<Mutex<dyn Write + Send>>` handle constructed at runner-entry.

#![doc(hidden)]

use crate::cli::argv::{explicit_output_from_argv, output_from_argv};
use crate::cli::clap_errors::clap_error_to_bird;
use crate::cli::dispatch::{
    GuardOutcome, ListFlags, clamp_limit, command_needs_xurl, require_confirmation,
};
use crate::cli::{Cli, Command, OutputFlags, SkillAction, WatchlistCommand};
use crate::config::{ArgOverrides, EnvOverrides, ResolvedConfig, ResolvedPaths};
use crate::error::BirdError;
use crate::output::{OutputConfig, OutputFormat};
use crate::{db, doctor, output, schema, schema_print, skill_install, transport, watchlist};
use clap::Parser;
use std::ffi::OsString;
use std::io::{IsTerminal, Write};
use std::process::ExitCode;

/// Curated top-level examples block — embedded so `--examples` works on every host.
const TOP_LEVEL_EXAMPLES: &str = include_str!("../../examples/top-level.txt");

/// Emit the curated top-level examples block and exit zero. JSON mode wraps the
/// parsed example invocations in `{"data": [...], "meta": {...}}`.
fn print_examples(out: &OutputConfig, stdout: &mut dyn Write) -> ExitCode {
    if out.format.is_json() {
        let qualified: Vec<String> = TOP_LEVEL_EXAMPLES
            .lines()
            .filter_map(|line| {
                let trimmed = line.trim_start();
                trimmed.strip_prefix("bird ").map(|rest| {
                    // Strip trailing `# comment` so machine consumers see the bare command.
                    let cmd = rest.split('#').next().unwrap_or(rest).trim_end();
                    format!("bird {}", cmd)
                })
            })
            .filter(|s| !s.is_empty())
            .collect();
        let data = serde_json::json!(qualified);
        let meta = serde_json::json!({"count": qualified.len()});
        match output::success_envelope_string(&data, &meta) {
            Ok(line) => {
                let _ = writeln!(stdout, "{}", line);
            }
            Err(_) => {
                let _ = writeln!(stdout, "{}", TOP_LEVEL_EXAMPLES);
            }
        }
    } else {
        let _ = write!(stdout, "{}", TOP_LEVEL_EXAMPLES);
    }
    ExitCode::SUCCESS
}

/// Binary entrypoint. Reads `std::env::args_os`, locks std stdout/stderr, and
/// delegates to [`run`]. Returns [`ExitCode`]; the binary converts to a process
/// exit.
pub fn run_argv() -> ExitCode {
    let args: Vec<OsString> = std::env::args_os().collect();
    let stdout = std::io::stdout();
    let stderr = std::io::stderr();
    let mut stdout_lock = stdout.lock();
    let mut stderr_lock = stderr.lock();
    run(args, &mut stdout_lock, &mut stderr_lock)
}

/// Library-consumer entrypoint. Loads [`ResolvedPaths`] and [`EnvOverrides`]
/// from the process environment, then delegates to [`run_with_paths`]. Consumers
/// that need to inject paths (tests, embeddors) should call [`run_with_paths`]
/// directly.
pub fn run<I, S>(args: I, stdout: &mut dyn Write, stderr: &mut dyn Write) -> ExitCode
where
    I: IntoIterator<Item = S>,
    S: Into<OsString> + Clone,
{
    let paths = match ResolvedPaths::from_env() {
        Ok(p) => p,
        Err(e) => {
            // Fatal pre-config path: no OutputConfig has been constructed
            // yet, so route through the BirdError::print chokepoint per
            // Plan 2 R18 instead of bypassing the format selection.
            let err = BirdError::config(e);
            err.print();
            return ExitCode::from(err.exit_code());
        }
    };
    let env = EnvOverrides::from_env();
    run_with_paths(args, stdout, stderr, paths, env)
}

/// Worker entrypoint. Owns the full dispatch pipeline against caller-supplied
/// paths and env. Tests call this directly with `TempDir`-backed paths.
///
/// stdout flows through the injected `stdout` writer for every short-circuit
/// branch (`Completions`, `--examples`, the JSON-wrapped help/version
/// envelope) and through dispatch into the per-command handlers. Diagnostic
/// output flows through the injected `stderr` writer (handler params) or
/// through the `Arc<Mutex<dyn Write + Send>>` handle on `BirdClient` /
/// `BirdDb` for internal sites — both bind to the process stderr in the
/// binary entrypoint, both can be redirected for library callers.
pub fn run_with_paths<I, S>(
    args: I,
    stdout: &mut dyn Write,
    stderr: &mut dyn Write,
    paths: ResolvedPaths,
    env: EnvOverrides,
) -> ExitCode
where
    I: IntoIterator<Item = S>,
    S: Into<OsString> + Clone,
{
    // Materialize argv once for the pre-parse scans (clap also needs to consume
    // it from a clone via `try_parse_from`).
    let args_os: Vec<OsString> = args.into_iter().map(|s| s.into()).collect();
    let argv: Vec<String> = args_os
        .iter()
        .map(|s| s.to_string_lossy().into_owned())
        .collect();
    let argv_output = output_from_argv(&argv);
    let explicit_output = explicit_output_from_argv(&argv);

    // `--examples` is a global help-style flag: print the curated examples block
    // and exit zero, even when no subcommand is supplied. Must short-circuit
    // before `Cli::try_parse_from` so a missing subcommand does not turn it
    // into a usage error.
    if argv.iter().any(|a| a == "--examples") {
        let fmt = argv_output;
        let cfg = OutputConfig {
            format: fmt,
            use_color: output::use_color_auto() && !fmt.is_json(),
            quiet: false,
            raw: false,
        };
        return print_examples(&cfg, stdout);
    }

    // try_parse_from routes clap errors through the JSON-aware envelope
    // formatter. Reading from the caller-supplied iterator (not
    // `std::env::args`) keeps the library pure.
    let cli = match Cli::try_parse_from(args_os.iter()) {
        Ok(c) => c,
        Err(e) => match clap_error_to_bird(&e) {
            None => {
                // Help/version display: only when the user EXPLICITLY requested JSON
                // (via `--json`, `--jsonl`, or `--output {json,jsonl}`) do we wrap the
                // help/version text in a success envelope. Auto-detected pipe mode
                // keeps the plain clap output so naive `bird --help | grep` still works.
                let wrap_in_envelope = explicit_output.is_some_and(|f| f.is_json());
                if wrap_in_envelope {
                    let body = e.to_string();
                    let kind = match e.kind() {
                        clap::error::ErrorKind::DisplayVersion => "version",
                        _ => "help",
                    };
                    let data = serde_json::json!({
                        kind: body.trim(),
                    });
                    let meta = serde_json::json!({"format": "text"});
                    match output::success_envelope_string(&data, &meta) {
                        Ok(line) => {
                            let _ = writeln!(stdout, "{}", line);
                        }
                        Err(_) => {
                            let _ = e.print();
                        }
                    }
                } else {
                    let _ = e.print();
                }
                return ExitCode::SUCCESS;
            }
            Some(bird_err) => {
                let fmt = argv_output;
                let cfg = OutputConfig {
                    format: fmt,
                    use_color: output::use_color_auto() && !fmt.is_json(),
                    quiet: false,
                    raw: false,
                };
                let _ = cfg.print_error(stderr, &bird_err);
                return ExitCode::from(bird_err.exit_code());
            }
        },
    };

    let color_mode = cli.effective_color();
    let use_color = output::resolve_color(color_mode);
    let raw = cli.raw;

    // Resolve output format: explicit flag > env var > auto-detect from stderr TTY.
    let output_format = cli.effective_output().unwrap_or_else(|| {
        if std::io::stderr().is_terminal() {
            OutputFormat::Text
        } else {
            OutputFormat::Json
        }
    });
    let out = OutputConfig {
        format: output_format,
        use_color,
        quiet: cli.quiet,
        raw,
    };

    // The xurl binary path and `--timeout` value are per-transport state:
    // resolve once below and pass into `XurlTransport::new`. Resolution
    // errors are stored on the transport and only surface when a command
    // actually spawns xurl.
    let xurl_timeout = std::time::Duration::from_secs(cli.timeout);

    // --- Meta-commands: need nothing beyond parsed args ---
    if let Command::Completions { shell } = &cli.command {
        use clap::CommandFactory;
        // R7: route completions through the runner's stdout so library
        // consumers capture all output (AE1).
        clap_complete::generate(*shell, &mut Cli::command(), "bird", stdout);
        return ExitCode::SUCCESS;
    }

    if let Command::Skill { action } = &cli.command {
        let code = match *action {
            SkillAction::Install { host, all, dry_run } => {
                skill_install::run_install_multi(host, all, dry_run, &out, stdout)
            }
            SkillAction::Update { host, all, dry_run } => {
                skill_install::run_update_multi(host, all, dry_run, &out, stdout)
            }
        };
        return ExitCode::from((code.clamp(0, 255)) as u8);
    }

    if let Command::Schema { name, list } = &cli.command {
        return match schema_print::run(name.as_deref(), *list, &out, stdout) {
            Ok(()) => ExitCode::SUCCESS,
            Err(err) => {
                let _ = out.print_error(stderr, &err);
                ExitCode::from(err.exit_code())
            }
        };
    }

    // --- Username validation + config + DB init (no xurl needed) ---

    let cli_username = match cli.username {
        Some(ref raw) => match schema::validate_username(raw) {
            Ok(clean) => Some(clean.to_string()),
            Err(e) => {
                let err = BirdError::config(format!("--username: {}", e));
                let _ = out.print_error(stderr, &err);
                return ExitCode::from(err.exit_code());
            }
        },
        None => None,
    };
    // env.username is the X_API_USERNAME snapshot; validate at runner time to
    // preserve the same warn-and-drop behavior the inline read used to have.
    let env_username = env
        .username
        .clone()
        .and_then(|u| match schema::validate_username(&u) {
            Ok(s) => Some(s.to_string()),
            Err(e) => {
                if !out.suppress_diag() {
                    writeln!(
                        stderr,
                        "[config] warning: X_API_USERNAME invalid, ignoring: {}",
                        e
                    )
                    .ok();
                }
                None
            }
        });
    let overrides = ArgOverrides {
        username: cli_username,
        env_username,
    };

    let config = match ResolvedConfig::load_with_paths(overrides, paths.clone(), env.clone()) {
        Ok(c) => c,
        Err(e) => {
            let err = BirdError::config(e);
            let _ = out.print_error(stderr, &err);
            return ExitCode::from(err.exit_code());
        }
    };

    // Resolve the xurl binary path once at startup. Commands that need xurl
    // surface the error on first transport call (or via the xurl gate below);
    // commands that never spawn xurl (local watchlist, cache, doctor's
    // xurl-status report) tolerate the error transport silently.
    let xurl_resolution = transport::resolve_xurl_path(&env);
    let transport: Box<dyn transport::Transport> = match &xurl_resolution {
        Ok(path) => Box::new(transport::XurlTransport::new(path.clone(), xurl_timeout)),
        Err(e) => Box::new(transport::XurlTransport::from_error(
            e.to_string(),
            xurl_timeout,
        )),
    };
    let cache_opts = db::CacheOpts {
        no_store: cli.no_cache || !config.cache_enabled,
        refresh: cli.refresh,
        cache_only: cli.cache_only,
    };
    // BirdClient takes an Arc-shared writer (KTD-2). The runner's local
    // `&mut dyn Write` stderr param cannot be cloned/shared into an Arc, so we
    // construct a separate handle bound to the process stderr. Diagnostic
    // sites that fire inside `BirdClient` / `BirdDb` route through this
    // handle, not the runner's `stderr` borrow — capturing them in tests
    // requires the Plan 2 U11 signature change.
    let client_stderr: std::sync::Arc<std::sync::Mutex<dyn std::io::Write + Send>> =
        std::sync::Arc::new(std::sync::Mutex::new(std::io::stderr()));
    let mut client = db::BirdClient::new(
        transport,
        &config.cache_path,
        cache_opts,
        config.cache_max_size_mb,
        config.username.clone(),
        out.suppress_diag(),
        client_stderr,
    );

    // --- Diagnostic commands: need config/DB but not xurl ---
    if let Command::Doctor {
        command,
        common: OutputFlags { pretty },
    } = &cli.command
    {
        let scope = command.as_deref();
        let pretty = *pretty;
        let use_emoji = use_color && pretty;
        match doctor::run_doctor(&client, &out, stdout, stderr, pretty, scope, use_emoji) {
            Ok(()) => return ExitCode::SUCCESS,
            Err(e) => {
                let err = BirdError::general("doctor", e);
                let _ = out.print_error(stderr, &err);
                return ExitCode::from(err.exit_code());
            }
        }
    }

    // --- Local watchlist commands: need config/DB but not xurl ---
    if let Command::Watchlist {
        ref action,
        common: OutputFlags { pretty },
    } = cli.command
        && !matches!(action, WatchlistCommand::Fetch)
    {
        let result = match action {
            WatchlistCommand::Add { username } => {
                watchlist::run_watchlist_add(&config, &out, stderr, username)
                    .map_err(BirdError::config)
            }
            WatchlistCommand::Remove { username, guard } => {
                let target = format!("watchlist:@{}", username);
                match require_confirmation(
                    "remove",
                    "LOCAL",
                    &target,
                    None,
                    *guard,
                    &out,
                    cli.no_interactive,
                    stdout,
                    stderr,
                    None,
                ) {
                    Ok(GuardOutcome::DryRun) => Ok(()),
                    Ok(GuardOutcome::Proceed) => {
                        watchlist::run_watchlist_remove(&config, &out, stderr, username)
                            .map_err(BirdError::config)
                    }
                    Err(e) => Err(e),
                }
            }
            WatchlistCommand::List => {
                let (limit, _) = clamp_limit(cli.limit, 1000, 10_000);
                watchlist::run_watchlist_list(
                    &config,
                    &out,
                    stdout,
                    stderr,
                    pretty,
                    Some(limit),
                    cli.cursor.as_deref(),
                )
                .map_err(|e| BirdError::from_source("watchlist", e))
            }
            WatchlistCommand::Fetch => unreachable!(),
        };
        return match result {
            Ok(()) => ExitCode::SUCCESS,
            Err(e) => {
                let _ = out.print_error(stderr, &e);
                ExitCode::from(e.exit_code())
            }
        };
    }

    // --- xurl gate: only for commands that actually spawn xurl ---
    // Skip when:
    //   * The command is local-only (Cache, Watchlist Add/Remove/List)
    //   * --cache-only is set (no network)
    //   * The command's guard is --dry-run (we print the would-be call and exit)
    let stdin_is_tty = std::io::stdin().is_terminal();
    if command_needs_xurl(&cli.command, stdin_is_tty, cli.no_interactive)
        && !cli.cache_only
        && let Err(e) = &xurl_resolution
    {
        let err = BirdError::config(e.to_string());
        let _ = out.print_error(stderr, &err);
        return ExitCode::from(err.exit_code());
    }

    let list_flags = ListFlags {
        limit: cli.limit,
        cursor: cli.cursor.clone(),
    };
    match crate::cli::dispatch::run(
        cli.command,
        config,
        &mut client,
        &out,
        stdout,
        stderr,
        cli.cache_only,
        cli.no_interactive,
        list_flags,
    ) {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => {
            let _ = out.print_error(stderr, &e);
            ExitCode::from(e.exit_code())
        }
    }
}

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

    fn test_paths() -> ResolvedPaths {
        use std::time::{SystemTime, UNIX_EPOCH};
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let tmp: PathBuf =
            std::env::temp_dir().join(format!("bird-runner-test-{}-{}", std::process::id(), nanos));
        ResolvedPaths {
            config_dir: tmp.clone(),
            store_path: tmp,
        }
    }

    // ExitCode does not impl PartialEq on stable; compare via Debug format,
    // which renders `ExitCode(unix_exit_status(N))` deterministically.
    fn exit_eq(actual: ExitCode, expected: ExitCode) -> bool {
        format!("{:?}", actual) == format!("{:?}", expected)
    }

    #[test]
    fn run_with_paths_help_returns_zero() {
        let paths = test_paths();
        let env = EnvOverrides::default();
        let mut stdout: Vec<u8> = Vec::new();
        let mut stderr: Vec<u8> = Vec::new();
        let exit = run_with_paths(["bird", "--help"], &mut stdout, &mut stderr, paths, env);
        assert!(
            exit_eq(exit, ExitCode::SUCCESS),
            "--help should exit 0, got {:?}",
            exit
        );
        // --help routes through clap's `e.print()`, which writes to the
        // process stdout directly rather than the runner's injected stdout
        // writer; the captured-content surface is therefore empty for
        // --help/--version in in-process tests.
    }

    #[test]
    fn run_with_paths_bogus_flag_returns_two() {
        let paths = test_paths();
        let env = EnvOverrides::default();
        let mut stdout: Vec<u8> = Vec::new();
        let mut stderr: Vec<u8> = Vec::new();
        let exit = run_with_paths(["bird", "--bogus"], &mut stdout, &mut stderr, paths, env);
        assert!(
            exit_eq(exit, ExitCode::from(2)),
            "bogus flag should exit 2, got {:?}",
            exit
        );
    }

    #[test]
    fn run_with_paths_version_returns_zero() {
        let paths = test_paths();
        let env = EnvOverrides::default();
        let mut stdout: Vec<u8> = Vec::new();
        let mut stderr: Vec<u8> = Vec::new();
        let exit = run_with_paths(["bird", "--version"], &mut stdout, &mut stderr, paths, env);
        assert!(
            exit_eq(exit, ExitCode::SUCCESS),
            "--version should exit 0, got {:?}",
            exit
        );
    }
}