Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions rust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<path>` to cache the verified archive between builds (CI keys this on `<os>-<version>` for ~zero-cost rebuilds on cache hits). With `bundled-cli` disabled there is no separate archive cache — the extracted binary itself is the cache.
Expand Down
40 changes: 40 additions & 0 deletions rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,46 @@ impl From<PathBuf> 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<PathBuf> {
#[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`]
Expand Down
62 changes: 61 additions & 1 deletion rust/tests/cli_resolution_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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"
);
}
Loading