Skip to content

Commit 4463df4

Browse files
tclemCopilot
andauthored
Expose install_bundled_cli and HAS_BUNDLED_CLI (#1489)
Lift embeddedcli::path() to a stable public surface so health checks, diagnostics, and version probes can reach the bundled CLI without spinning up a Client. Returns the same path Client::start resolves to for CliProgram::Resolve with no COPILOT_CLI_PATH or extract_dir override. Returns None when bundled-cli is off or the target is unsupported (no fallback to the dev-cache path). HAS_BUNDLED_CLI is a const so callers can branch on bundling presence without forcing extraction. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ffadd9d commit 4463df4

3 files changed

Lines changed: 126 additions & 1 deletion

File tree

rust/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,6 +844,31 @@ Set `COPILOT_SKIP_CLI_DOWNLOAD=1` at build time to disable the entire download /
844844

845845
There is no PATH scanning. If none of the above resolves, `Client::start` returns `Error::BinaryNotFound`.
846846

847+
### Reaching the bundled binary without a `Client`
848+
849+
Health checks, diagnostics, and version probes often need the bundled
850+
CLI's path *before* any session starts — and for callers that always
851+
override `program` with `CliProgram::Path(...)`, `Client::start`'s
852+
resolver may never run. Use [`install_bundled_cli`] for those cases:
853+
854+
```rust,no_run
855+
use github_copilot_sdk::{HAS_BUNDLED_CLI, install_bundled_cli};
856+
857+
if HAS_BUNDLED_CLI {
858+
if let Some(path) = install_bundled_cli() {
859+
// lazily extracts on first call; idempotent thereafter
860+
println!("bundled CLI at {}", path.display());
861+
}
862+
}
863+
```
864+
865+
This returns the same path `Client::start` would resolve to for
866+
`CliProgram::Resolve` with no `COPILOT_CLI_PATH` override and no
867+
`ClientOptions::bundled_cli_extract_dir` configured. It returns `None`
868+
when `bundled-cli` is off or the target is unsupported, and (unlike the
869+
full resolver) does not fall back to the build-time-extracted dev-cache
870+
path.
871+
847872
### Download cache (build-time, embed mode)
848873

849874
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.

rust/src/lib.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,46 @@ impl From<PathBuf> for CliProgram {
127127
}
128128
}
129129

130+
/// `true` when this build of the SDK has the Copilot CLI embedded in
131+
/// its binary — i.e. the `bundled-cli` cargo feature is on **and** the
132+
/// target platform is one for which `build.rs` shipped an archive.
133+
///
134+
/// Useful for branching on bundling presence without forcing the lazy
135+
/// extraction triggered by [`install_bundled_cli`].
136+
pub const HAS_BUNDLED_CLI: bool = cfg!(has_bundled_cli);
137+
138+
/// Returns the path to the bundled Copilot CLI, extracting it from the
139+
/// embedded archive on first call.
140+
///
141+
/// This is the same path [`Client::start`] resolves to when
142+
/// [`ClientOptions::program`] is [`CliProgram::Resolve`], no
143+
/// `COPILOT_CLI_PATH` override is set, and no
144+
/// [`ClientOptions::bundled_cli_extract_dir`] is configured — exposing
145+
/// it directly so callers (health checks, diagnostics, version probes)
146+
/// can reach the bundled binary without spinning up a full [`Client`].
147+
///
148+
/// Subsequent calls return the cached result. Extraction is skipped
149+
/// when the target file already exists.
150+
///
151+
/// Returns `None` when the `bundled-cli` feature is off, the target
152+
/// platform isn't supported by `build.rs`, or extraction failed (the
153+
/// failure is logged via `tracing::warn!`). When `None` is returned for
154+
/// the "feature off" reason, [`HAS_BUNDLED_CLI`] is also `false`.
155+
///
156+
/// This deliberately does not fall back to the build-time-extracted
157+
/// dev-cache path used when `bundled-cli` is off — callers that want
158+
/// that resolution should continue to use [`CliProgram::Resolve`].
159+
pub fn install_bundled_cli() -> Option<PathBuf> {
160+
#[cfg(feature = "bundled-cli")]
161+
{
162+
embeddedcli::path()
163+
}
164+
#[cfg(not(feature = "bundled-cli"))]
165+
{
166+
None
167+
}
168+
}
169+
130170
/// Options for starting a [`Client`].
131171
///
132172
/// When `program` is [`CliProgram::Resolve`] (the default), [`Client::start`]

rust/tests/cli_resolution_test.rs

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
99
use std::path::PathBuf;
1010

11-
use github_copilot_sdk::{CliProgram, Client, ClientOptions, ErrorKind};
11+
use github_copilot_sdk::{
12+
CliProgram, Client, ClientOptions, ErrorKind, HAS_BUNDLED_CLI, install_bundled_cli,
13+
};
1214
use serial_test::serial;
1315

1416
fn unset_env(key: &str) {
@@ -224,3 +226,61 @@ fn pin_file_when_present_is_well_formed() {
224226
}
225227
assert!(saw_version, "cli-version.txt missing `version=` line");
226228
}
229+
230+
/// With `bundled-cli` on AND a supported target, `install_bundled_cli`
231+
/// returns a real on-disk path and is idempotent across calls.
232+
#[cfg(all(feature = "bundled-cli", has_bundled_cli))]
233+
#[test]
234+
fn install_bundled_cli_returns_extracted_path() {
235+
const { assert!(HAS_BUNDLED_CLI) };
236+
237+
let first = install_bundled_cli().expect("bundled CLI should install");
238+
assert!(
239+
first.is_file(),
240+
"install_bundled_cli returned a path that is not a file: {}",
241+
first.display()
242+
);
243+
244+
let second = install_bundled_cli().expect("second call should also succeed");
245+
assert_eq!(
246+
first, second,
247+
"install_bundled_cli must be idempotent across calls"
248+
);
249+
}
250+
251+
/// `install_bundled_cli` returns the same path the runtime resolver
252+
/// hands to `Client::start` for `CliProgram::Resolve` with no
253+
/// `COPILOT_CLI_PATH` override. Observed indirectly: the binary the
254+
/// public API points at must exist, and `Client::start` must not
255+
/// report `BinaryNotFound` under the same env conditions.
256+
#[cfg(all(feature = "bundled-cli", has_bundled_cli))]
257+
#[tokio::test(flavor = "current_thread")]
258+
#[serial(copilot_cli_path)]
259+
async fn install_bundled_cli_matches_resolver() {
260+
unset_env("COPILOT_CLI_PATH");
261+
unset_env("COPILOT_CLI_EXTRACT_DIR");
262+
263+
let direct = install_bundled_cli().expect("bundled CLI should install");
264+
assert!(direct.is_file());
265+
266+
let opts = ClientOptions::default().with_program(CliProgram::Resolve);
267+
if let Err(e) = Client::start(opts).await {
268+
assert!(
269+
!matches!(e.kind(), ErrorKind::BinaryNotFound { .. }),
270+
"resolver returned BinaryNotFound while install_bundled_cli succeeded: {e}"
271+
);
272+
}
273+
}
274+
275+
/// With `bundled-cli` off (or the target unsupported), the public API
276+
/// reports no bundled CLI and does not fall back to the
277+
/// build-time-extracted dev-cache path that `CliProgram::Resolve` uses.
278+
#[cfg(not(all(feature = "bundled-cli", has_bundled_cli)))]
279+
#[test]
280+
fn install_bundled_cli_is_none_without_embed() {
281+
const { assert!(!HAS_BUNDLED_CLI) };
282+
assert!(
283+
install_bundled_cli().is_none(),
284+
"install_bundled_cli must not fall back to the dev-cache path"
285+
);
286+
}

0 commit comments

Comments
 (0)