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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ External docs: https://clig.dev/llms.txt -> guide you can consult to write bette
- `CallToolResponse` is now proto-backed too. `src/main.rs` pretty-prints the typed response envelope directly.
- The CLI is dynamic at three levels: ListExtensions determines which top-level extensions exist, ListTools determines which tool subcommands exist under each extension, and each tool's schema determines its flags/options.
- We take a hybrid static/dynamic approach:
- **Top-level extensions are static.** `extensions.yaml` is generated from two sources (kGoose ListExtensionsGrpcAction + G2 web app OAuth config for late-init extensions like notion, asana), then manually curated. Run `just update-extensions-catalog` to regenerate. This lets all known extensions appear in `--help` even if the user hasn't connected them yet.
- **Extension subcommands are fully dynamic.** ListTools and CallTool hit the live kgoose API. If the user hasn't connected an extension, `--help` for that extension will fail with a "not connected" error pointing to G2 Connections. The static catalog is used only to produce helpful error messages (distinguishing "unknown extension" from "known but not connected").
- **`bl tools` top-level extensions are live.** Root help and `--describe-commands` call ListExtensions with the selected backend and caller authentication. Configured extensions with `auth_satisfied_for_caller = false` are marked `[not connected]`; they remain visible in the list.
- **`sq agent-tools` top-level extensions are static.** `extensions.yaml` is generated from two sources (kGoose ListExtensionsGrpcAction + G2 web app OAuth config for late-init extensions like notion, asana), then manually curated. Run `just update-extensions-catalog` to regenerate. This lets all known extensions appear in `--help` even if the user hasn't connected them yet.
- **Extension subcommands are fully dynamic.** ListTools and CallTool hit the live kgoose API. If the user hasn't connected an extension, `--help` for that extension will fail with a "not connected" error pointing to G2 Connections. `bl tools` loads the live ListExtensions set only when ListTools fails, to distinguish "unknown extension" from "configured but not connected"; it never reads the static catalog. `sq agent-tools` uses the static catalog for that error classification.
- **`--describe-commands` leaf node is the extension subcommand.** We don't return nested tool commands under an extension, so `sq` forwards args to the extension subcommand directly.
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

`bl` is the command-line interface for BuilderLab. It provides authenticated
access to BuilderLab skills, agents, workspaces, and apps, along with the
`bl tools` command for discovering connected tool extensions.
`bl tools` command for discovering configured tool extensions.

The CLI is written in Rust and is built from this repository. Run `bl --help`
to see the current command surface:
Expand Down Expand Up @@ -57,6 +57,17 @@ The CLI can also configure the organization used for service routing:
./target/debug/bl config get org
```

`bl tools` (or `bl tools --help`) fetches the configured extensions from the
selected KGoose backend. Extensions whose credentials are missing for the caller
appear with `[not connected]` beside their description. The same live list is
used by `bl tools --describe-commands`. Listing requires backend access and
authentication, just like inspecting or calling an extension's tools.
When inspecting an extension fails, `bl tools` also uses the live extension list
to distinguish an unknown name from a configured extension that is not connected.

The separately packaged `sq agent-tools` command uses the embedded
`extensions.yaml` catalog for its top-level help and command discovery.

Use `--json` for machine-readable output and `--verbose` for request
diagnostics. Do not include credentials or other sensitive data when sharing
verbose output.
Expand Down
95 changes: 70 additions & 25 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ use cli::{
TOOLS_COMMAND_NAME,
};
use runtime::{
load_extension, load_extensions, LoadedExtension, ParameterKind, RuntimeTool, ScalarKind,
ToolParameter,
load_extension, load_extensions, load_extensions_with_auth_status, LoadedExtension,
ParameterKind, RuntimeTool, ScalarKind, ToolParameter,
};
use serde::Serialize;
use serde_json::{Map, Value};
Expand Down Expand Up @@ -244,7 +244,7 @@ fn run_tools_cli(argv0: &str, raw_args: &[String], config: ToolsCliConfig) -> Re
}

if bootstrap.describe_commands {
if !bootstrap.command_tokens.is_empty() {
if config.builderlab_mode || !bootstrap.command_tokens.is_empty() {
apply_builderlab_mode_if_needed(&mut kgoose_config, config.builderlab_mode)?;
}
let description =
Expand Down Expand Up @@ -292,15 +292,20 @@ fn run_tools_cli(argv0: &str, raw_args: &[String], config: ToolsCliConfig) -> Re
selected_extension_name.as_deref()
{
apply_builderlab_mode_if_needed(&mut kgoose_config, config.builderlab_mode)?;
let known_extensions = load_extensions_catalog()?;
let loaded = load_extension(&client, &kgoose_config, extension_name, &known_extensions)?;
let loaded = load_tools_extension(
&client,
&kgoose_config,
extension_name,
config.builderlab_mode,
)?;
let extensions = vec![runtime::ExtensionSummary {
name: loaded.name.clone(),
about: loaded.about.clone(),
}];
(extensions, Some(loaded))
} else {
let extensions = load_extensions_catalog()?;
apply_builderlab_mode_if_needed(&mut kgoose_config, config.builderlab_mode)?;
let extensions = load_tools_extensions(&client, &kgoose_config, config.builderlab_mode)?;
Comment thread
torstenrudolf marked this conversation as resolved.
(extensions, None)
};

Expand Down Expand Up @@ -460,53 +465,91 @@ fn load_command_description(
.collect::<Vec<_>>();

match command_path.as_slice() {
[] => load_root_command_description(client, config, tools_cli.describe_root_name),
[] => load_root_command_description(client, config, tools_cli),
[APPKIT_COMMAND_NAME] => Ok(appkit_command_description()),
[APPKIT_COMMAND_NAME, ..] => {
anyhow::bail!(
"`--describe-commands` does not inspect nested appkit commands; run `{}`",
tools_cli.nested_appkit_help
)
}
[extension_name] => load_extension_command_description(client, config, extension_name),
[extension_name] => load_extension_command_description(
client,
config,
extension_name,
tools_cli.builderlab_mode,
),
[extension_name, EXTENSION_DESCRIBE_COMMAND_NAME] => {
load_extension_describe_command_description(client, config, extension_name)
}
[extension_name, tool_name] => {
load_tool_command_description(client, config, extension_name, tool_name)
load_extension_describe_command_description(
client,
config,
extension_name,
tools_cli.builderlab_mode,
)
}
[extension_name, tool_name] => load_tool_command_description(
client,
config,
extension_name,
tool_name,
tools_cli.builderlab_mode,
),
_ => anyhow::bail!(
"`--describe-commands` only supports the root command, an extension, or a tool path"
),
}
}

fn load_root_command_description(
_client: &impl KgooseClient,
_config: &KgooseConfig,
root_name: &str,
client: &impl KgooseClient,
config: &KgooseConfig,
tools_cli: ToolsCliConfig,
) -> Result<CommandDescription> {
let extensions = load_extensions_catalog()?;
Ok(root_command_description(&extensions, root_name))
let extensions = load_tools_extensions(client, config, tools_cli.builderlab_mode)?;
Ok(root_command_description(
&extensions,
tools_cli.describe_root_name,
))
}

/// Loads an extension by name, using the static catalog to produce helpful error
/// messages for extensions that exist but the user hasn't connected yet.
fn load_extension_with_catalog(
fn load_tools_extensions(
client: &impl KgooseClient,
config: &KgooseConfig,
builderlab_mode: bool,
) -> Result<Vec<runtime::ExtensionSummary>> {
if builderlab_mode {
load_extensions_with_auth_status(client, config)
} else {
load_extensions_catalog()
}
}

/// Uses live discovery for BuilderLab errors and the static catalog for sq.
fn load_tools_extension(
client: &impl KgooseClient,
config: &KgooseConfig,
extension_name: &str,
builderlab_mode: bool,
) -> Result<LoadedExtension> {
let known_extensions = load_extensions_catalog()?;
load_extension(client, config, extension_name, &known_extensions)
if builderlab_mode {
load_extension(client, config, extension_name, || {
load_extensions(client, config).map(Some)
})
} else {
let known_extensions = load_extensions_catalog()?;
load_extension(client, config, extension_name, || {
Ok((!known_extensions.is_empty()).then_some(known_extensions))
})
}
}

fn load_extension_command_description(
client: &impl KgooseClient,
config: &KgooseConfig,
extension_name: &str,
builderlab_mode: bool,
) -> Result<CommandDescription> {
let loaded = load_extension_with_catalog(client, config, extension_name)?;
let loaded = load_tools_extension(client, config, extension_name, builderlab_mode)?;
Ok(extension_command_description(&loaded))
}

Expand All @@ -515,8 +558,9 @@ fn load_tool_command_description(
config: &KgooseConfig,
extension_name: &str,
tool_name: &str,
builderlab_mode: bool,
) -> Result<CommandDescription> {
let loaded = load_extension_with_catalog(client, config, extension_name)?;
let loaded = load_tools_extension(client, config, extension_name, builderlab_mode)?;
let tool = loaded
.tools
.iter()
Expand All @@ -530,8 +574,9 @@ fn load_extension_describe_command_description(
client: &impl KgooseClient,
config: &KgooseConfig,
extension_name: &str,
builderlab_mode: bool,
) -> Result<CommandDescription> {
load_extension_with_catalog(client, config, extension_name)?;
load_tools_extension(client, config, extension_name, builderlab_mode)?;
Ok(extension_describe_command_description())
}

Expand Down
83 changes: 56 additions & 27 deletions src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,48 +83,78 @@ pub fn load_extensions(
config: &KgooseConfig,
) -> Result<Vec<ExtensionSummary>> {
let response = client.list_extensions(config)?;
Ok(sort_extensions(response)
Ok(extension_summaries(response, false))
}

pub fn load_extensions_with_auth_status(
client: &impl KgooseClient,
config: &KgooseConfig,
) -> Result<Vec<ExtensionSummary>> {
let response = client.list_extensions(config)?;
Ok(extension_summaries(response, true))
}

fn extension_summaries(
response: ListExtensionsResponse,
show_auth_status: bool,
) -> Vec<ExtensionSummary> {
sort_extensions(response)
.into_iter()
.filter_map(|extension| {
let name = extension.name?;
let about = compact_text(
extension
.description
.as_deref()
.unwrap_or(&format!("{} tools", extension.tool_count.unwrap_or(0))),
);
Some(ExtensionSummary {
about: compact_text(
extension
.description
.as_deref()
.unwrap_or(&format!("{} tools", extension.tool_count.unwrap_or(0))),
),
name,
about: if show_auth_status && extension.auth_satisfied_for_caller == Some(false) {
format!("[not connected] {about}")
} else {
about
},
name: extension.name?,
})
})
.collect())
.collect()
}

pub fn load_extension(
client: &impl KgooseClient,
config: &KgooseConfig,
extension_name: &str,
known_extensions: &[ExtensionSummary],
load_known_extensions: impl FnOnce() -> Result<Option<Vec<ExtensionSummary>>>,
) -> Result<LoadedExtension> {
let response = match client.list_tools(config, extension_name) {
Ok(response) => response,
Err(err) => {
// The static catalog covers all known extensions (both live API and late-init
// OAuth extensions like `asana` or `notion`). Using it here lets us distinguish
// "unknown extension" (typo) from "known but not yet connected" and surface a
// helpful G2 Connections hint for the latter.
// Discovery is only needed to distinguish unknown extensions from configured
// but inaccessible ones. Keep successful ListTools calls independent of it.
let Some(known_extensions) = load_known_extensions()
.with_context(|| format!("ListTools for `{extension_name}` failed: {err:#}"))?
else {
return Err(humanize_list_tools_error(
extension_name,
config.playpen.as_deref(),
&err,
));
};
let is_known = known_extensions
.iter()
.any(|extension| extension.name == extension_name);

if is_known || known_extensions.is_empty() {
if is_known {
return Err(humanize_list_tools_error(
extension_name,
config.playpen.as_deref(),
&err,
));
}

if known_extensions.is_empty() {
anyhow::bail!("unknown extension `{extension_name}` (no extensions configured in this backend)");
}

anyhow::bail!(
"unknown extension `{extension_name}` (available: {})",
known_extensions
Expand Down Expand Up @@ -670,8 +700,10 @@ mod tests {

#[test]
fn load_extension_discovers_tools_and_schema_parameters() {
let extension =
load_extension(&TestKgooseClient, &kgoose_config(), "utils", &[]).expect("extension");
let extension = load_extension(&TestKgooseClient, &kgoose_config(), "utils", || {
panic!("successful ListTools must not load extension discovery")
})
.expect("extension");
assert_eq!(extension.name, "utils");
assert_eq!(extension.about, "Utility helpers");
assert_eq!(extension.description, "Utility helpers");
Expand Down Expand Up @@ -702,7 +734,7 @@ mod tests {
&UnauthorizedKgooseClient,
&kgoose_config(),
"airtable",
&known,
|| Ok(Some(known)),
)
.expect_err("expected inaccessible extension");

Expand All @@ -723,7 +755,7 @@ mod tests {
&ConnectFailureKgooseClient,
&kgoose_config(),
"sourcegraph",
&known,
|| Ok(Some(known)),
)
.expect_err("expected connect failure");

Expand All @@ -740,15 +772,12 @@ mod tests {

#[test]
fn load_extension_rewrites_catalog_known_inaccessible_errors_for_humans() {
let error = load_extension(
&UnauthorizedKgooseClient,
&kgoose_config(),
"asana",
&[ExtensionSummary {
let error = load_extension(&UnauthorizedKgooseClient, &kgoose_config(), "asana", || {
Ok(Some(vec![ExtensionSummary {
name: "asana".to_string(),
about: "Asana tools".to_string(),
}],
)
}]))
})
.expect_err("expected inaccessible extension");

let message = error.to_string();
Expand Down
Loading
Loading