pub mod capabilities;
pub mod commands;
pub mod config;
mod context;
pub mod dependency;
pub mod launchers;
pub mod models;
pub mod proxy;
pub mod registry;
pub mod session;
pub mod sources;
pub mod utils;
pub mod version {
include!(concat!(env!("OUT_DIR"), "/version.rs"));
}
pub mod providers;
use alog::{MessageLevel, alog};
use clap::{CommandFactory, Parser, Subcommand};
use commands::shared::remediation::DependentsPolicy;
use commands::{
CapabilityCommands, HardwareCommands, LauncherCommands, ModelCommands, ProviderCommands,
SetupCommands,
};
pub use context::AppContext;
use utils::ui::{UI_REGISTRY, Ui, run_interactive_tui};
extern crate paste;
#[derive(Parser, Debug)]
#[command(name = "granite-cli")]
#[command(about = "Universal Model Adapter with Capabilities", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
#[arg(
short,
long,
global = true,
default_value = "warning",
env = "LOG_LEVEL"
)]
log_level: String,
#[arg(long, global = true, default_value = "", env = "LOG_FILTERS")]
log_filters: String,
#[arg(long, global = true, env = "LOG_JSON")]
log_json: bool,
#[arg(long, global = true, env = "LOG_THREAD_ID")]
log_thread_id: bool,
}
#[derive(clap::Args, Debug)]
struct ModelWithOutput {
#[arg(short, long, global = true, default_value = "terminal", value_parser = output_formats())]
output: String,
#[command(subcommand)]
subcommand: ModelSubcommands,
}
#[derive(clap::Args, Debug)]
struct CapabilityWithOutput {
#[arg(short, long, global = true, default_value = "terminal", value_parser = output_formats())]
output: String,
#[command(subcommand)]
subcommand: CapabilitySubcommands,
}
#[derive(clap::Args, Debug)]
struct ProviderWithOutput {
#[arg(short, long, global = true, default_value = "terminal", value_parser = output_formats())]
output: String,
#[command(subcommand)]
subcommand: ProviderSubcommands,
}
#[derive(clap::Args, Debug)]
struct LaunchWithOutput {
#[arg(short, long, global = true, default_value = "terminal", value_parser = output_formats())]
output: String,
launcher_id: String,
#[arg(long)]
dry_run: bool,
#[arg(trailing_var_arg = true)]
args: Vec<String>,
}
#[derive(clap::Args, Debug)]
struct LauncherWithOutput {
#[arg(short, long, global = true, default_value = "terminal", value_parser = output_formats())]
output: String,
#[command(subcommand)]
subcommand: LauncherSubcommands,
}
#[derive(clap::Args, Debug)]
struct InternalArgs {
#[command(subcommand)]
subcommand: InternalSubcommands,
}
#[derive(Subcommand, Debug)]
enum InternalSubcommands {
BobHookCapture {
output_path: std::path::PathBuf,
#[arg(long)]
ancestor_lock: Option<std::path::PathBuf>,
},
}
#[derive(Subcommand, Debug)]
enum Commands {
Model(ModelWithOutput),
Capability(CapabilityWithOutput),
Provider(ProviderWithOutput),
Launcher(LauncherWithOutput),
Setup {
#[arg(long)]
auto: bool,
#[arg(long, conflicts_with = "skip_pull")]
pull: bool,
#[arg(long, conflicts_with = "pull")]
skip_pull: bool,
},
Hardware,
Launch(LaunchWithOutput),
Version,
Completions {
shell: clap_complete::Shell,
},
#[command(hide = true)]
Internal(InternalArgs),
}
#[derive(Subcommand, Debug)]
enum ModelSubcommands {
Catalog {
#[arg(short, long)]
r#type: Option<models::ModelType>,
},
List {
#[arg(short, long)]
r#type: Option<models::ModelType>,
},
Search {
query: String,
},
Recommend {
#[arg(short, long)]
r#type: Option<models::ModelType>,
#[arg(short = 'p', long = "providers", value_delimiter = ',')]
providers: Vec<String>,
#[arg(long)]
wide: bool,
},
Info {
model_id: String,
},
Setup {
model_type: String,
#[arg(long = "id")]
instance_id: Option<String>,
},
Pull {
model_id: String,
},
Remove {
model_id: String,
#[arg(long, value_name = "POLICY")]
dependents: Option<DependentsPolicy>,
},
}
#[derive(Subcommand, Debug)]
enum CapabilitySubcommands {
Catalog,
List,
Info {
capability_id: String,
},
Setup {
capability_type: String,
#[arg(long = "id")]
instance_id: Option<String>,
},
Remove {
capability_id: String,
#[arg(long, value_name = "POLICY")]
dependents: Option<DependentsPolicy>,
},
}
#[derive(Subcommand, Debug)]
enum ProviderSubcommands {
Catalog {
#[arg(long)]
wide: bool,
},
List,
Info {
provider_id: String,
},
Setup {
provider_type: String,
#[arg(long = "id")]
instance_id: Option<String>,
},
Health {
provider_id: Option<String>,
},
Remove {
provider_id: String,
#[arg(long, value_name = "POLICY")]
dependents: Option<DependentsPolicy>,
},
}
#[derive(Subcommand, Debug)]
enum LauncherSubcommands {
Catalog,
List,
Info {
launcher_id: String,
},
Setup {
launcher_type: String,
#[arg(long = "id")]
instance_id: Option<String>,
},
Remove {
launcher_id: String,
#[arg(long, value_name = "POLICY")]
dependents: Option<DependentsPolicy>,
},
}
fn construct_ui(output: &str) -> Box<dyn Ui> {
UI_REGISTRY
.construct(output, output, &serde_json::json!({}))
.unwrap_or_else(|_| {
eprintln!(
"Unknown output format '{output}'. Valid: {}",
output_format_names().join(", ")
);
std::process::exit(1);
})
}
fn construct_context(
output: &str,
log_level: &str,
log_filters: &str,
log_json: bool,
log_thread_id: bool,
) -> AppContext {
let ui = construct_ui(output);
let ui: std::sync::Arc<dyn Ui> = std::sync::Arc::from(ui);
let formatter_kind = if log_json {
alog::FormatterKind::Json
} else {
alog::FormatterKind::Pretty
};
let ui_arc_clone = Arc::clone(&ui);
let ui_writer = UiWriter {
ui: Arc::clone(&ui),
};
alog::configure(alog::Config {
default_level: log_level.parse().unwrap(),
filters: alog::Filters::Spec(log_filters.to_string()),
formatter: alog::FormatterKind::Custom(Box::new(UiFormatter::new(
formatter_kind,
ui_arc_clone,
))),
writer: alog::Writer::Custom(Box::new(ui_writer)),
thread_id: log_thread_id,
});
alog!("MAIN", MessageLevel::Debug, "Welcome to granite-cli!");
let config = config::Config::new().unwrap_or_else(|e| {
ui.error(&format!("Failed to load config: {e}"));
std::process::exit(1);
});
AppContext::new(config, ui)
}
use std::io::{self, Write};
use std::sync::Arc;
fn output_format_names() -> Vec<&'static str> {
let mut names: Vec<&'static str> = UI_REGISTRY.entries().into_keys().collect();
names.sort_unstable();
names
}
fn output_formats() -> clap::builder::PossibleValuesParser {
let entries = UI_REGISTRY.entries();
output_format_names()
.into_iter()
.map(|name| clap::builder::PossibleValue::new(name).help(entries[name].description.clone()))
.collect::<Vec<_>>()
.into()
}
fn write_completions(shell: clap_complete::Shell, out: &mut dyn Write) -> io::Result<()> {
let mut cmd = completion_command();
let mut buf: Vec<u8> = Vec::new();
if shell != clap_complete::Shell::Bash {
clap_complete::generate(shell, &mut cmd, "granite-cli", &mut buf);
return out.write_all(&buf);
}
clap_complete::generate(shell, &mut cmd, "granite_cli", &mut buf);
let script = String::from_utf8_lossy(&buf);
for line in script.lines() {
match line.strip_suffix(" granite_cli") {
Some(prefix) if line.trim_start().starts_with("complete -F _granite_cli ") => {
writeln!(out, "{prefix} granite-cli")?
}
_ => writeln!(out, "{line}")?,
}
}
Ok(())
}
fn completion_command() -> clap::Command {
let full = Cli::command();
let mut cmd = clap::Command::new("granite-cli")
.args(full.get_arguments().cloned())
.subcommands(full.get_subcommands().filter(|s| !s.is_hide_set()).cloned());
if let Some(about) = full.get_about() {
cmd = cmd.about(about.clone());
}
cmd
}
struct UiWriter {
ui: Arc<dyn Ui>,
}
impl Write for UiWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let text = std::str::from_utf8(buf).unwrap_or("");
for line in text.split('\n') {
if line.is_empty() {
continue;
}
self.ui.info(line);
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
struct UiFormatter {
inner: Box<dyn alog::Formatter>,
ui: Arc<dyn Ui>,
}
impl UiFormatter {
fn new(kind: alog::FormatterKind, ui: Arc<dyn Ui>) -> Self {
Self {
inner: match kind {
alog::FormatterKind::Pretty => Box::new(alog::PrettyFormatter::default()),
alog::FormatterKind::Json => Box::new(alog::JsonFormatter),
alog::FormatterKind::Custom(c) => c,
},
ui,
}
}
}
impl alog::Formatter for UiFormatter {
fn format(&self, record: &alog::LogRecord<'_>) -> String {
let formatted = self.inner.format(record).trim_end_matches('\n').to_string();
match record.level {
MessageLevel::Fatal | MessageLevel::Error => self.ui.error_mark(&formatted),
MessageLevel::Warning => self.ui.warn_mark(&formatted),
MessageLevel::Info => formatted,
_ => self.ui.detail_mark(&formatted),
}
}
}
#[tokio::main]
async fn main() {
tokio::spawn(async {
if tokio::signal::ctrl_c().await.is_ok() {
let _ = dialoguer::console::Term::stderr().show_cursor();
std::process::exit(130);
}
});
let cli = Cli::parse();
let log_level = cli.log_level.clone();
let log_filters = cli.log_filters.clone();
let log_json = cli.log_json;
let log_thread_id = cli.log_thread_id;
let command = cli.command;
let result: Result<(), ()> = match command {
Some(Commands::Model(wrapper)) => {
let mut ctx = construct_context(
&wrapper.output,
&log_level,
&log_filters,
log_json,
log_thread_id,
);
run_model_command(&mut ctx, wrapper.subcommand)
.await
.map_err(|e| ctx.ui.error(&e.to_string()))
}
Some(Commands::Capability(wrapper)) => {
let mut ctx = construct_context(
&wrapper.output,
&log_level,
&log_filters,
log_json,
log_thread_id,
);
run_capability_command(&mut ctx, wrapper.subcommand)
.await
.map_err(|e| ctx.ui.error(&e.to_string()))
}
Some(Commands::Provider(wrapper)) => {
let mut ctx = construct_context(
&wrapper.output,
&log_level,
&log_filters,
log_json,
log_thread_id,
);
run_provider_command(&mut ctx, wrapper.subcommand)
.await
.map_err(|e| ctx.ui.error(&e.to_string()))
}
Some(Commands::Hardware) => {
let ctx = construct_context(
"terminal",
&log_level,
&log_filters,
log_json,
log_thread_id,
);
HardwareCommands::show(&ctx).map_err(|e| ctx.ui.error(&e.to_string()))
}
Some(Commands::Launcher(wrapper)) => {
let mut ctx = construct_context(
&wrapper.output,
&log_level,
&log_filters,
log_json,
log_thread_id,
);
run_launcher_command(&mut ctx, wrapper.subcommand)
.await
.map_err(|e| ctx.ui.error(&e.to_string()))
}
Some(Commands::Setup {
auto,
pull,
skip_pull,
}) => {
let pull_opt = if pull {
Some(true)
} else if skip_pull {
Some(false)
} else {
None
};
let mut ctx = construct_context(
"terminal",
&log_level,
&log_filters,
log_json,
log_thread_id,
);
SetupCommands::run(&mut ctx, auto, pull_opt)
.await
.map_err(|e| ctx.ui.error(&e.to_string()))
}
Some(Commands::Launch(wrapper)) => {
let mut ctx = construct_context(
&wrapper.output,
&log_level,
&log_filters,
log_json,
log_thread_id,
);
run_launch(
&mut ctx,
&wrapper.launcher_id,
&wrapper.args,
wrapper.dry_run,
)
.await
.map_err(|e| ctx.ui.error(&e.to_string()))
}
Some(Commands::Version) => {
println!("{}", version::version_string());
Ok(())
}
Some(Commands::Completions { shell }) => {
match write_completions(shell, &mut std::io::stdout()) {
Err(e) if e.kind() != std::io::ErrorKind::BrokenPipe => {
eprintln!("Error writing completion script: {e}");
Err(())
}
_ => Ok(()),
}
}
Some(Commands::Internal(args)) => match args.subcommand {
InternalSubcommands::BobHookCapture {
output_path,
ancestor_lock,
} => {
use std::io::Read;
let mut buf = Vec::new();
std::io::stdin()
.read_to_end(&mut buf)
.map_err(|e| eprintln!("Error reading stdin: {e}"))
.and_then(|_| {
if !bob_hook_capture_should_write(ancestor_lock.as_deref()) {
return Ok(());
}
std::fs::write(&output_path, &buf)
.map_err(|e| eprintln!("Error writing {}: {e}", output_path.display()))
})
}
},
None => {
let ctx = construct_context(
"terminal",
&log_level,
&log_filters,
log_json,
log_thread_id,
);
run_interactive_tui(ctx, log_level.parse().unwrap(), &log_filters)
.await
.map_err(|e| eprintln!("Error: {e}"))
}
};
if result.is_err() {
std::process::exit(1);
}
}
async fn run_model_command(ctx: &mut AppContext, subcmd: ModelSubcommands) -> anyhow::Result<()> {
match subcmd {
ModelSubcommands::Catalog { r#type } => ModelCommands::catalog(ctx, r#type),
ModelSubcommands::List { r#type } => ModelCommands::list(ctx, r#type),
ModelSubcommands::Search { query } => ModelCommands::search(ctx, &query),
ModelSubcommands::Recommend {
r#type,
providers,
wide,
} => ModelCommands::recommend(ctx, r#type, &providers, wide),
ModelSubcommands::Info { model_id } => ModelCommands::info(ctx, &model_id).await,
ModelSubcommands::Setup {
model_type,
instance_id,
} => ModelCommands::setup(ctx, &model_type, instance_id.as_deref(), false).await,
ModelSubcommands::Pull { model_id } => ModelCommands::pull(ctx, &model_id).await,
ModelSubcommands::Remove {
model_id,
dependents,
} => ModelCommands::remove(ctx, &model_id, dependents),
}
}
async fn run_capability_command(
ctx: &mut AppContext,
subcmd: CapabilitySubcommands,
) -> anyhow::Result<()> {
match subcmd {
CapabilitySubcommands::Catalog => CapabilityCommands::catalog(ctx),
CapabilitySubcommands::List => CapabilityCommands::list(ctx),
CapabilitySubcommands::Info { capability_id } => {
CapabilityCommands::info(ctx, &capability_id).await
}
CapabilitySubcommands::Setup {
capability_type,
instance_id,
} => CapabilityCommands::setup(ctx, &capability_type, instance_id.as_deref(), false).await,
CapabilitySubcommands::Remove {
capability_id,
dependents,
} => CapabilityCommands::remove(ctx, &capability_id, dependents),
}
}
async fn run_provider_command(
ctx: &mut AppContext,
subcmd: ProviderSubcommands,
) -> anyhow::Result<()> {
match subcmd {
ProviderSubcommands::Catalog { wide } => ProviderCommands::catalog(ctx, wide),
ProviderSubcommands::List => ProviderCommands::list(ctx),
ProviderSubcommands::Info { provider_id } => ProviderCommands::info(ctx, &provider_id),
ProviderSubcommands::Setup {
provider_type,
instance_id,
} => ProviderCommands::setup(ctx, &provider_type, instance_id.as_deref(), false).await,
ProviderSubcommands::Health { provider_id } => {
ProviderCommands::health(ctx, provider_id.as_deref()).await
}
ProviderSubcommands::Remove {
provider_id,
dependents,
} => ProviderCommands::remove(ctx, &provider_id, dependents),
}
}
async fn run_launcher_command(
ctx: &mut AppContext,
subcmd: LauncherSubcommands,
) -> anyhow::Result<()> {
match subcmd {
LauncherSubcommands::Catalog => LauncherCommands::catalog(ctx),
LauncherSubcommands::List => LauncherCommands::list(ctx),
LauncherSubcommands::Info { launcher_id } => LauncherCommands::info(ctx, &launcher_id),
LauncherSubcommands::Setup {
launcher_type,
instance_id,
} => LauncherCommands::setup(ctx, &launcher_type, instance_id.as_deref(), false).await,
LauncherSubcommands::Remove {
launcher_id,
dependents,
} => LauncherCommands::remove(ctx, &launcher_id, dependents),
}
}
pub(crate) async fn run_launch(
ctx: &mut AppContext,
launcher_id: &str,
args: &[String],
dry_run: bool,
) -> anyhow::Result<()> {
use crate::capabilities::CAPABILITY_REGISTRY;
use crate::launchers::LAUNCHER_REGISTRY;
use crate::launchers::LaunchContext;
use crate::proxy::ProxyServer;
use crate::session;
ctx.set_config(crate::config::Config::new()?);
LauncherCommands::prelaunch(ctx, launcher_id).await?;
let ui = std::sync::Arc::clone(&ctx.ui);
let ui: &dyn Ui = ui.as_ref();
let lc = ctx
.config()
.get_launcher(launcher_id)
.ok_or_else(|| {
anyhow::anyhow!(
"No launcher configured with id '{launcher_id}'. \
Run `granite-cli launcher setup {launcher_id}` first."
)
})?
.clone();
let boot_proxy = !dry_run;
let proxy_server = if boot_proxy {
Some(ProxyServer::start()?)
} else {
None
};
if let Some(server) = &proxy_server {
ctx.set_model_proxy(server.handle.clone());
}
let model_proxy = proxy_server.as_ref().map(|s| s.handle.clone());
let sources = ctx.sources();
let tracker = proxy_server.as_ref().map(|s| s.handle.tracker());
let capabilities_with_deps: Vec<(
crate::config::CapabilityConfig,
Vec<crate::capabilities::Dependency>,
)> = lc
.enabled_capabilities
.iter()
.filter_map(|id| {
let cap_cfg = ctx.config().get_capability(id)?;
let cap_meta = CAPABILITY_REGISTRY.get(&cap_cfg.capability_type)?;
Some((cap_cfg.clone(), cap_meta.dependencies.clone()))
})
.collect();
let session_id = session::generate_session_id();
let session_meta =
session::create_session_meta(&session_id, ctx.config(), &lc, &capabilities_with_deps);
session::write_session_file(&session_meta).ok();
let mut launcher = LAUNCHER_REGISTRY
.construct(&lc.launcher_type, &lc.launcher_id, &lc.config)
.map_err(|e| anyhow::anyhow!("Failed to construct launcher: {e}"))?;
let launch_ctx = LaunchContext {
launcher_id: launcher_id.to_string(),
working_dir: std::env::current_dir()?,
base_env: std::collections::HashMap::new(),
dry_run,
usage_tracker: tracker.clone(),
model_proxy: model_proxy.clone(),
};
let bound_capabilities = crate::launchers::resolve_and_bind(
launcher.as_mut(),
&lc.enabled_capabilities,
&sources.capabilities(),
)
.await?;
if let Some(server) = &proxy_server {
crate::proxy::register_proxy_routes(ctx, &lc.enabled_capabilities, &server.handle, ui);
}
for capability in &bound_capabilities {
capability.on_pre_launch(&launch_ctx).await?;
}
let writer_handle = if let Some(ref t) = tracker {
let (tx, mut rx) = tokio::sync::watch::channel(());
t.set_notifier(tx);
let writer_session_id = session_id.clone();
let writer_tracker = t.clone();
Some(tokio::spawn(async move {
loop {
if rx.changed().await.is_err() {
break;
}
let snapshot = writer_tracker.snapshot();
session::update_session_usage(&writer_session_id, &snapshot).ok();
}
}))
} else {
None
};
let launch_result = launcher.launch(args, &launch_ctx, ui).await;
for capability in bound_capabilities.iter().rev() {
if let Err(e) = capability.on_post_launch(&launch_ctx).await {
ui.warn(&format!(
"on_post_launch failed for capability '{}': {e}",
capability.instance_id()
));
}
if let Err(e) = capability.on_shutdown(&launch_ctx).await {
ui.warn(&format!(
"on_shutdown failed for capability '{}': {e}",
capability.instance_id()
));
}
}
if let Some(handle) = writer_handle {
handle.abort();
}
if let Some(ref t) = tracker {
session::finish_session(&session_id, &t.snapshot()).ok();
}
let status = launch_result?;
if let Some(server) = proxy_server {
print_usage_summary(ui, &server.handle.tracker());
server.shutdown().await;
}
if !status.success() {
anyhow::bail!(
"'{}' exited with status {}",
launcher_id,
status.code().unwrap_or(-1)
);
}
Ok(())
}
fn print_usage_summary(ui: &dyn Ui, tracker: &proxy::UsageTracker) {
let snapshot = tracker.snapshot();
if snapshot.is_empty() {
return;
}
let mut rows: Vec<Vec<String>> = snapshot
.iter()
.map(|(label, s)| {
vec![
label.clone(),
s.requests.to_string(),
s.input_tokens.to_string(),
s.output_tokens.to_string(),
s.cache_creation_tokens.to_string(),
s.cache_read_tokens.to_string(),
]
})
.collect();
rows.sort_by(|a, b| a[0].cmp(&b[0]));
let total = snapshot
.values()
.fold(proxy::UsageStats::default(), |mut acc, s| {
acc.requests += s.requests;
acc.input_tokens += s.input_tokens;
acc.output_tokens += s.output_tokens;
acc.cache_creation_tokens += s.cache_creation_tokens;
acc.cache_read_tokens += s.cache_read_tokens;
acc
});
rows.push(vec![
"Total".to_string(),
total.requests.to_string(),
total.input_tokens.to_string(),
total.output_tokens.to_string(),
total.cache_creation_tokens.to_string(),
total.cache_read_tokens.to_string(),
]);
ui.table(
"Usage",
&[
"Binding",
"Requests",
"Input Tokens",
"Output Tokens",
"Cache Write",
"Cache Read",
],
&rows,
);
}
fn bob_hook_capture_should_write(ancestor_lock: Option<&std::path::Path>) -> bool {
if let Some(lock_path) = ancestor_lock {
let lock_content = match std::fs::read_to_string(lock_path) {
Ok(c) => c,
Err(_) => return true, };
let registrant_pid: u32 = match lock_content.trim().parse() {
Ok(p) => p,
Err(_) => return true, };
if registrant_pid == 0 {
return true; }
match crate::launchers::bob::hook::is_process_ancestor(registrant_pid, 16) {
Some(false) => false, Some(true) | None => true, }
} else {
true }
}
#[cfg(test)]
mod hook_capture_tests {
use super::*;
#[test]
fn gating_skips_when_lock_pid_not_ancestor() {
let tmp = tempfile::tempdir().unwrap();
let lock_file = tmp.path().join("lock");
std::fs::write(&lock_file, "999999999").unwrap();
assert!(!bob_hook_capture_should_write(Some(&lock_file)));
}
#[test]
fn gating_allows_when_no_lock_path() {
assert!(bob_hook_capture_should_write(None));
}
#[test]
fn gating_allows_when_lock_unreadable() {
assert!(bob_hook_capture_should_write(Some(std::path::Path::new(
"/no/such/file.lock"
))));
}
#[test]
fn gating_allows_when_lock_contains_non_numeric() {
let tmp = tempfile::tempdir().unwrap();
let lock_file = tmp.path().join("lock");
std::fs::write(&lock_file, "not-a-pid").unwrap();
assert!(bob_hook_capture_should_write(Some(&lock_file)));
}
#[test]
fn gating_allows_when_lock_pid_is_current_process() {
let tmp = tempfile::tempdir().unwrap();
let lock_file = tmp.path().join("lock");
std::fs::write(&lock_file, std::process::id().to_string()).unwrap();
assert!(bob_hook_capture_should_write(Some(&lock_file)));
}
#[test]
fn gating_allows_when_lock_pid_zero() {
let tmp = tempfile::tempdir().unwrap();
let lock_file = tmp.path().join("lock");
std::fs::write(&lock_file, "0").unwrap();
assert!(bob_hook_capture_should_write(Some(&lock_file)));
}
}
#[cfg(test)]
mod completions_tests {
use super::*;
use clap::ValueEnum;
use clap_complete::Shell;
fn completions_for(shell: Shell) -> String {
let mut buf: Vec<u8> = Vec::new();
write_completions(shell, &mut buf).unwrap();
String::from_utf8(buf).unwrap()
}
#[test]
fn completions_include_subcommands_for_every_shell() {
for shell in Shell::value_variants() {
let script = completions_for(*shell);
assert!(!script.is_empty(), "empty script for {shell}");
assert!(script.contains("model"), "`model` missing for {shell}");
assert!(
script.contains("completions"),
"`completions` missing for {shell}"
);
}
}
#[test]
fn completions_exclude_hidden_internal_subcommands() {
for shell in Shell::value_variants() {
let script = completions_for(*shell);
assert!(
!script.contains("bob-hook-capture"),
"`bob-hook-capture` present for {shell}"
);
assert!(
!script.contains("Internal commands used by granite-cli"),
"`internal` present for {shell}"
);
}
}
#[test]
fn completion_command_matches_cli_name_and_about() {
let full = Cli::command();
let cmd = completion_command();
assert_eq!(cmd.get_name(), full.get_name());
assert_eq!(
cmd.get_about().map(|a| a.to_string()),
full.get_about().map(|a| a.to_string())
);
}
#[test]
fn only_top_level_subcommands_are_hidden() {
fn assert_no_hidden(cmd: &clap::Command) {
for sub in cmd.get_subcommands() {
assert!(
!sub.is_hide_set(),
"nested subcommand `{}` is hidden",
sub.get_name()
);
assert_no_hidden(sub);
}
}
for sub in Cli::command().get_subcommands() {
assert_no_hidden(sub);
}
}
#[test]
fn bash_completions_register_granite_cli() {
let script = completions_for(Shell::Bash);
let registrations: Vec<&str> = script
.lines()
.map(str::trim)
.filter(|l| l.starts_with("complete -F "))
.collect();
assert!(!registrations.is_empty());
for line in registrations {
assert!(line.starts_with("complete -F _granite_cli "), "{line}");
assert!(line.ends_with(" granite-cli"), "{line}");
}
}
#[test]
fn bash_completions_have_a_case_for_every_subcommand_label() {
let script = completions_for(Shell::Bash);
let labels: Vec<&str> = script
.lines()
.filter_map(|l| l.trim().strip_prefix("cmd=\""))
.filter_map(|l| l.strip_suffix('"'))
.filter(|l| !l.is_empty())
.collect();
assert!(labels.contains(&"granite_cli__subcmd__model"));
for label in labels {
assert!(
script.contains(&format!("\n {label})\n")),
"no case arm for {label}"
);
}
}
#[test]
fn completions_include_output_format_and_model_type_values() {
let script = completions_for(Shell::Bash);
for name in output_format_names() {
assert!(script.contains(name), "output format `{name}` missing");
}
assert!(
script.contains("embedding"),
"model type `embedding` missing"
);
}
#[test]
fn output_formats_match_ui_registry() {
let mut expected: Vec<&str> = UI_REGISTRY.entries().into_keys().collect();
expected.sort_unstable();
assert_eq!(output_format_names(), expected);
let cli =
Cli::try_parse_from(["granite-cli", "model", "--output", "json", "list"]).unwrap();
assert!(matches!(cli.command, Some(Commands::Model(w)) if w.output == "json"));
assert!(Cli::try_parse_from(["granite-cli", "model", "--output", "yaml", "list"]).is_err());
}
#[test]
fn model_type_flag_parses_into_model_type() {
let cli =
Cli::try_parse_from(["granite-cli", "model", "catalog", "--type", "vision"]).unwrap();
let Some(Commands::Model(w)) = cli.command else {
panic!("expected model command");
};
assert!(matches!(
w.subcommand,
ModelSubcommands::Catalog {
r#type: Some(models::ModelType::Vision)
}
));
assert!(
Cli::try_parse_from(["granite-cli", "model", "catalog", "--type", "audio"]).is_err()
);
}
#[test]
fn completions_command_parses_shell() {
let cli = Cli::try_parse_from(["granite-cli", "completions", "bash"]).unwrap();
assert!(matches!(
cli.command,
Some(Commands::Completions { shell: Shell::Bash })
));
}
#[test]
fn completions_command_rejects_unknown_shell() {
assert!(Cli::try_parse_from(["granite-cli", "completions", "tcsh"]).is_err());
}
}
#[cfg(test)]
mod run_launch_tests {
use super::*;
use crate::utils::ui::base::tests::CaptureUi;
#[tokio::test]
async fn run_launch_unknown_launcher_returns_err() {
let _home = crate::config::TestConfigHome::new();
let mut ctx = AppContext::new(
crate::config::Config::default(),
Arc::new(CaptureUi::default()),
);
let result = run_launch(&mut ctx, "no-such-launcher", &[], true).await;
assert!(result.is_err());
let err = result.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Launch aborted"), "unexpected error: {msg}");
assert!(msg.contains("no-such-launcher"), "unexpected error: {msg}");
}
}