diff --git a/rust/README.md b/rust/README.md index 215448e05..651c5c771 100644 --- a/rust/README.md +++ b/rust/README.md @@ -844,6 +844,31 @@ Set `COPILOT_SKIP_CLI_DOWNLOAD=1` at build time to disable the entire download / There is no PATH scanning. If none of the above resolves, `Client::start` returns `Error::BinaryNotFound`. +### Reaching the bundled binary without a `Client` + +Health checks, diagnostics, and version probes often need the bundled +CLI's path *before* any session starts — and for callers that always +override `program` with `CliProgram::Path(...)`, `Client::start`'s +resolver may never run. Use [`install_bundled_cli`] for those cases: + +```rust,no_run +use github_copilot_sdk::{HAS_BUNDLED_CLI, install_bundled_cli}; + +if HAS_BUNDLED_CLI { + if let Some(path) = install_bundled_cli() { + // lazily extracts on first call; idempotent thereafter + println!("bundled CLI at {}", path.display()); + } +} +``` + +This returns the same path `Client::start` would resolve to for +`CliProgram::Resolve` with no `COPILOT_CLI_PATH` override and no +`ClientOptions::bundled_cli_extract_dir` configured. It returns `None` +when `bundled-cli` is off or the target is unsupported, and (unlike the +full resolver) does not fall back to the build-time-extracted dev-cache +path. + ### Download cache (build-time, embed mode) In embed mode `build.rs` re-downloads on every clean build by default. Set `BUNDLED_CLI_CACHE_DIR=` to cache the verified archive between builds (CI keys this on `-` for ~zero-cost rebuilds on cache hits). With `bundled-cli` disabled there is no separate archive cache — the extracted binary itself is the cache. diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 4528b5deb..515ab4a55 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -127,6 +127,46 @@ impl From for CliProgram { } } +/// `true` when this build of the SDK has the Copilot CLI embedded in +/// its binary — i.e. the `bundled-cli` cargo feature is on **and** the +/// target platform is one for which `build.rs` shipped an archive. +/// +/// Useful for branching on bundling presence without forcing the lazy +/// extraction triggered by [`install_bundled_cli`]. +pub const HAS_BUNDLED_CLI: bool = cfg!(has_bundled_cli); + +/// Returns the path to the bundled Copilot CLI, extracting it from the +/// embedded archive on first call. +/// +/// This is the same path [`Client::start`] resolves to when +/// [`ClientOptions::program`] is [`CliProgram::Resolve`], no +/// `COPILOT_CLI_PATH` override is set, and no +/// [`ClientOptions::bundled_cli_extract_dir`] is configured — exposing +/// it directly so callers (health checks, diagnostics, version probes) +/// can reach the bundled binary without spinning up a full [`Client`]. +/// +/// Subsequent calls return the cached result. Extraction is skipped +/// when the target file already exists. +/// +/// Returns `None` when the `bundled-cli` feature is off, the target +/// platform isn't supported by `build.rs`, or extraction failed (the +/// failure is logged via `tracing::warn!`). When `None` is returned for +/// the "feature off" reason, [`HAS_BUNDLED_CLI`] is also `false`. +/// +/// This deliberately does not fall back to the build-time-extracted +/// dev-cache path used when `bundled-cli` is off — callers that want +/// that resolution should continue to use [`CliProgram::Resolve`]. +pub fn install_bundled_cli() -> Option { + #[cfg(feature = "bundled-cli")] + { + embeddedcli::path() + } + #[cfg(not(feature = "bundled-cli"))] + { + None + } +} + /// Options for starting a [`Client`]. /// /// When `program` is [`CliProgram::Resolve`] (the default), [`Client::start`] diff --git a/rust/tests/cli_resolution_test.rs b/rust/tests/cli_resolution_test.rs index 0abd0f94e..c3044a9e7 100644 --- a/rust/tests/cli_resolution_test.rs +++ b/rust/tests/cli_resolution_test.rs @@ -8,7 +8,9 @@ use std::path::PathBuf; -use github_copilot_sdk::{CliProgram, Client, ClientOptions, ErrorKind}; +use github_copilot_sdk::{ + CliProgram, Client, ClientOptions, ErrorKind, HAS_BUNDLED_CLI, install_bundled_cli, +}; use serial_test::serial; fn unset_env(key: &str) { @@ -224,3 +226,61 @@ fn pin_file_when_present_is_well_formed() { } assert!(saw_version, "cli-version.txt missing `version=` line"); } + +/// With `bundled-cli` on AND a supported target, `install_bundled_cli` +/// returns a real on-disk path and is idempotent across calls. +#[cfg(all(feature = "bundled-cli", has_bundled_cli))] +#[test] +fn install_bundled_cli_returns_extracted_path() { + const { assert!(HAS_BUNDLED_CLI) }; + + let first = install_bundled_cli().expect("bundled CLI should install"); + assert!( + first.is_file(), + "install_bundled_cli returned a path that is not a file: {}", + first.display() + ); + + let second = install_bundled_cli().expect("second call should also succeed"); + assert_eq!( + first, second, + "install_bundled_cli must be idempotent across calls" + ); +} + +/// `install_bundled_cli` returns the same path the runtime resolver +/// hands to `Client::start` for `CliProgram::Resolve` with no +/// `COPILOT_CLI_PATH` override. Observed indirectly: the binary the +/// public API points at must exist, and `Client::start` must not +/// report `BinaryNotFound` under the same env conditions. +#[cfg(all(feature = "bundled-cli", has_bundled_cli))] +#[tokio::test(flavor = "current_thread")] +#[serial(copilot_cli_path)] +async fn install_bundled_cli_matches_resolver() { + unset_env("COPILOT_CLI_PATH"); + unset_env("COPILOT_CLI_EXTRACT_DIR"); + + let direct = install_bundled_cli().expect("bundled CLI should install"); + assert!(direct.is_file()); + + let opts = ClientOptions::default().with_program(CliProgram::Resolve); + if let Err(e) = Client::start(opts).await { + assert!( + !matches!(e.kind(), ErrorKind::BinaryNotFound { .. }), + "resolver returned BinaryNotFound while install_bundled_cli succeeded: {e}" + ); + } +} + +/// With `bundled-cli` off (or the target unsupported), the public API +/// reports no bundled CLI and does not fall back to the +/// build-time-extracted dev-cache path that `CliProgram::Resolve` uses. +#[cfg(not(all(feature = "bundled-cli", has_bundled_cli)))] +#[test] +fn install_bundled_cli_is_none_without_embed() { + const { assert!(!HAS_BUNDLED_CLI) }; + assert!( + install_bundled_cli().is_none(), + "install_bundled_cli must not fall back to the dev-cache path" + ); +}