mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 07:30:11 +00:00
* feat: add extension registry with metadata catalog, CLI, and onboarding integration Adds a central registry that catalogs all 14 available extensions (10 tools, 4 channels) with their capabilities, auth requirements, and artifact references. The onboarding wizard now shows installable channels from the registry and offers tool installation as a new Step 7. - registry/ folder with per-extension JSON manifests and bundle definitions - src/registry/ module: manifest structs, catalog loader, installer - `ironclaw registry list|info|install|install-defaults` CLI commands - Setup wizard enhanced: channels from registry, new extensions step (8 steps) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(setup): resolve workspace errors for tool crates and channels-only onboarding Tool crates in tools-src/ and channels-src/ failed `cargo metadata` during onboard install because Cargo resolved them as part of the root workspace. Add `[workspace]` table to each standalone crate and extend the root `workspace.exclude` list so they build independently. Channels-only mode (`onboard --channels-only`) failed with "Secrets not configured" and "No database connection" because it skipped database and security setup. Add `reconnect_existing_db()` to establish the DB connection and load saved settings before running channel configuration. Also improve the tunnel "already configured" display to show full provider details (domain, mode, command) instead of just the provider name. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(registry): address PR review feedback on installer and catalog - Use manifest.name (not crate_name) for installed filenames so discovery, auth, and CLI commands all agree on the stem (#1) - Add AlreadyInstalled error variant instead of misleading ExtensionNotFound (#2) - Add DownloadFailed error variant with URL context instead of stuffing URLs into PathBuf (#3) - Validate HTTP status with error_for_status() before reading response bytes in artifact downloads (#4) - Switch build_wasm_component to tokio::process::Command with status() so build output streams to the terminal (#6) - Find WASM artifact by crate_name specifically instead of picking the first .wasm file in the release directory (#7) - Add is_file() guard in catalog loader to skip directories (#8) - Detect ambiguous bare-name lookups when both tools/<name> and channels/<name> exist, with get_strict() returning an error (#9) - Fix wizard step_extensions to check tool.name for installed detection, consistent with the new naming (#11, #12) - Fix redundant closures and map_or clippy warnings in changed files Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(setup): restore DB connection fields after settings reload reconnect_postgres() and reconnect_libsql() called Settings::from_db_map() which overwrote database_url / libsql_path / libsql_url set from env vars. Also use get_strict() in cmd_info to surface ambiguous bare-name errors. Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: fix clippy collapsible_if and print_literal warnings Collapse nested if-let chains and inline string literals in format macros to satisfy CI clippy lint checks (deny warnings). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(registry): prefer artifacts for install-defaults and improve dir lookup - InstallDefaults now defaults to downloading pre-built artifacts (matching `registry install` behavior), with --build flag for source builds. - find_registry_dir() walks up 3 ancestor levels from the exe and adds a CARGO_MANIFEST_DIR fallback, matching load_registry_catalog() logic. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
340 lines
10 KiB
Rust
340 lines
10 KiB
Rust
//! Registry CLI commands for discovering and installing extensions.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use clap::Subcommand;
|
|
|
|
use crate::registry::catalog::RegistryCatalog;
|
|
use crate::registry::installer::RegistryInstaller;
|
|
use crate::registry::manifest::ManifestKind;
|
|
|
|
#[derive(Subcommand, Debug, Clone)]
|
|
pub enum RegistryCommand {
|
|
/// List available extensions in the registry
|
|
List {
|
|
/// Filter by kind: "tool" or "channel"
|
|
#[arg(short, long)]
|
|
kind: Option<String>,
|
|
|
|
/// Filter by tag (e.g. "default", "google", "messaging")
|
|
#[arg(short, long)]
|
|
tag: Option<String>,
|
|
|
|
/// Show detailed information
|
|
#[arg(short, long)]
|
|
verbose: bool,
|
|
},
|
|
|
|
/// Show detailed information about an extension or bundle
|
|
Info {
|
|
/// Extension or bundle name (e.g. "slack", "google", "tools/gmail")
|
|
name: String,
|
|
},
|
|
|
|
/// Install an extension or bundle from the registry
|
|
Install {
|
|
/// Extension or bundle name (e.g. "slack", "google", "default")
|
|
name: String,
|
|
|
|
/// Force overwrite if already installed
|
|
#[arg(short, long)]
|
|
force: bool,
|
|
|
|
/// Build from source instead of downloading pre-built artifact
|
|
#[arg(long)]
|
|
build: bool,
|
|
},
|
|
|
|
/// Install the default bundle of recommended extensions
|
|
InstallDefaults {
|
|
/// Force overwrite if already installed
|
|
#[arg(short, long)]
|
|
force: bool,
|
|
|
|
/// Build from source instead of downloading pre-built artifact
|
|
#[arg(long)]
|
|
build: bool,
|
|
},
|
|
}
|
|
|
|
/// Run a registry command.
|
|
pub async fn run_registry_command(cmd: RegistryCommand) -> anyhow::Result<()> {
|
|
let registry_dir = find_registry_dir()?;
|
|
let catalog = RegistryCatalog::load(®istry_dir)?;
|
|
|
|
match cmd {
|
|
RegistryCommand::List { kind, tag, verbose } => {
|
|
cmd_list(&catalog, kind.as_deref(), tag.as_deref(), verbose)
|
|
}
|
|
RegistryCommand::Info { name } => cmd_info(&catalog, &name),
|
|
RegistryCommand::Install { name, force, build } => {
|
|
cmd_install(&catalog, ®istry_dir, &name, force, build).await
|
|
}
|
|
RegistryCommand::InstallDefaults { force, build } => {
|
|
cmd_install(&catalog, ®istry_dir, "default", force, build).await
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Find the registry directory by looking relative to the current executable or cwd.
|
|
fn find_registry_dir() -> anyhow::Result<PathBuf> {
|
|
// Try relative to current directory (for dev usage)
|
|
let cwd = std::env::current_dir()?;
|
|
let candidate = cwd.join("registry");
|
|
if candidate.is_dir() {
|
|
return Ok(candidate);
|
|
}
|
|
|
|
// Try relative to executable (covers installed binary, target/debug/, target/release/)
|
|
if let Ok(exe) = std::env::current_exe()
|
|
&& let Some(parent) = exe.parent()
|
|
{
|
|
// Walk up to 3 levels: exe dir, parent (target/release → target), grandparent (→ repo root)
|
|
let mut dir = Some(parent);
|
|
for _ in 0..3 {
|
|
if let Some(d) = dir {
|
|
let candidate = d.join("registry");
|
|
if candidate.is_dir() {
|
|
return Ok(candidate);
|
|
}
|
|
dir = d.parent();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try CARGO_MANIFEST_DIR (compile-time, works in dev builds)
|
|
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
let candidate = manifest_dir.join("registry");
|
|
if candidate.is_dir() {
|
|
return Ok(candidate);
|
|
}
|
|
|
|
anyhow::bail!(
|
|
"Could not find registry/ directory. Run from the ironclaw repo root, \
|
|
or ensure registry/ is next to the ironclaw binary."
|
|
)
|
|
}
|
|
|
|
fn cmd_list(
|
|
catalog: &RegistryCatalog,
|
|
kind: Option<&str>,
|
|
tag: Option<&str>,
|
|
verbose: bool,
|
|
) -> anyhow::Result<()> {
|
|
let kind_filter = match kind {
|
|
Some("tool" | "tools") => Some(ManifestKind::Tool),
|
|
Some("channel" | "channels") => Some(ManifestKind::Channel),
|
|
Some(other) => anyhow::bail!("Unknown kind '{}'. Use 'tool' or 'channel'.", other),
|
|
None => None,
|
|
};
|
|
|
|
let manifests = catalog.list(kind_filter, tag);
|
|
|
|
if manifests.is_empty() {
|
|
println!("No extensions found matching the criteria.");
|
|
return Ok(());
|
|
}
|
|
|
|
// Print header
|
|
if verbose {
|
|
println!(
|
|
"{:<20} {:<8} {:<8} {:<10} DESCRIPTION",
|
|
"NAME", "KIND", "VERSION", "AUTH"
|
|
);
|
|
println!("{}", "-".repeat(80));
|
|
} else {
|
|
println!("{:<20} {:<8} DESCRIPTION", "NAME", "KIND");
|
|
println!("{}", "-".repeat(60));
|
|
}
|
|
|
|
for m in &manifests {
|
|
if verbose {
|
|
let auth = m
|
|
.auth_summary
|
|
.as_ref()
|
|
.and_then(|a| a.method.as_deref())
|
|
.unwrap_or("none");
|
|
println!(
|
|
"{:<20} {:<8} {:<8} {:<10} {}",
|
|
m.name, m.kind, m.version, auth, m.description
|
|
);
|
|
} else {
|
|
println!("{:<20} {:<8} {}", m.name, m.kind, m.description);
|
|
}
|
|
}
|
|
|
|
println!("\n{} extension(s) found.", manifests.len());
|
|
|
|
// Show bundles hint
|
|
let bundle_names = catalog.bundle_names();
|
|
if !bundle_names.is_empty() {
|
|
println!("\nBundles available: {}", bundle_names.join(", "));
|
|
println!("Use `ironclaw registry info <bundle>` for details.");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_info(catalog: &RegistryCatalog, name: &str) -> anyhow::Result<()> {
|
|
// Check if it's a bundle
|
|
if let Some(bundle) = catalog.get_bundle(name) {
|
|
println!("Bundle: {}", bundle.display_name);
|
|
if let Some(desc) = &bundle.description {
|
|
println!(" {}", desc);
|
|
}
|
|
println!("\nExtensions:");
|
|
for ext_key in &bundle.extensions {
|
|
if let Some(m) = catalog.get(ext_key) {
|
|
println!(" {} - {} ({})", ext_key, m.description, m.kind);
|
|
} else {
|
|
println!(" {} (not found in registry)", ext_key);
|
|
}
|
|
}
|
|
if let Some(shared) = &bundle.shared_auth {
|
|
println!("\nShared auth: {}", shared);
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// Single extension (use get_strict to surface ambiguous bare names)
|
|
let manifest = catalog
|
|
.get_strict(name)
|
|
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
|
|
|
println!("{} ({})", manifest.display_name, manifest.kind);
|
|
println!(" Version: {}", manifest.version);
|
|
println!(" {}", manifest.description);
|
|
|
|
if !manifest.keywords.is_empty() {
|
|
println!(" Keywords: {}", manifest.keywords.join(", "));
|
|
}
|
|
|
|
println!("\nSource:");
|
|
println!(" Directory: {}", manifest.source.dir);
|
|
println!(" Crate: {}", manifest.source.crate_name);
|
|
println!(" Capabilities: {}", manifest.source.capabilities);
|
|
|
|
if let Some(artifact) = manifest.artifacts.get("wasm32-wasip2") {
|
|
println!("\nArtifact (wasm32-wasip2):");
|
|
match &artifact.url {
|
|
Some(url) => println!(" URL: {}", url),
|
|
None => println!(" URL: (not yet published)"),
|
|
}
|
|
match &artifact.sha256 {
|
|
Some(sha) => println!(" SHA256: {}", sha),
|
|
None => println!(" SHA256: (not yet computed)"),
|
|
}
|
|
}
|
|
|
|
if let Some(auth) = &manifest.auth_summary {
|
|
println!("\nAuthentication:");
|
|
if let Some(method) = &auth.method {
|
|
println!(" Method: {}", method);
|
|
}
|
|
if let Some(provider) = &auth.provider {
|
|
println!(" Provider: {}", provider);
|
|
}
|
|
if !auth.secrets.is_empty() {
|
|
println!(" Secrets: {}", auth.secrets.join(", "));
|
|
}
|
|
if let Some(shared) = &auth.shared_auth {
|
|
println!(" Shared with: {}", shared);
|
|
}
|
|
if let Some(url) = &auth.setup_url {
|
|
println!(" Setup: {}", url);
|
|
}
|
|
}
|
|
|
|
if !manifest.tags.is_empty() {
|
|
println!("\nTags: {}", manifest.tags.join(", "));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn cmd_install(
|
|
catalog: &RegistryCatalog,
|
|
registry_dir: &std::path::Path,
|
|
name: &str,
|
|
force: bool,
|
|
prefer_build: bool,
|
|
) -> anyhow::Result<()> {
|
|
// Registry dir parent is the repo root
|
|
let repo_root = registry_dir
|
|
.parent()
|
|
.ok_or_else(|| anyhow::anyhow!("Cannot determine repo root from registry dir"))?;
|
|
|
|
let installer = RegistryInstaller::with_defaults(repo_root.to_path_buf());
|
|
|
|
let (manifests, bundle) = catalog.resolve(name)?;
|
|
|
|
if manifests.is_empty() {
|
|
anyhow::bail!("No extensions found for '{}'.", name);
|
|
}
|
|
|
|
if let Some(bundle_def) = bundle {
|
|
// Bundle install
|
|
println!(
|
|
"Installing bundle '{}' ({} extensions)...\n",
|
|
bundle_def.display_name,
|
|
manifests.len()
|
|
);
|
|
|
|
let (outcomes, hints) = installer
|
|
.install_bundle(&manifests, bundle_def, force, prefer_build)
|
|
.await;
|
|
|
|
println!("\n--- Results ---");
|
|
for outcome in &outcomes {
|
|
let caps_status = if outcome.has_capabilities { "+" } else { "-" };
|
|
println!(
|
|
" [{}] {} ({}) -> {}",
|
|
caps_status,
|
|
outcome.name,
|
|
outcome.kind,
|
|
outcome.wasm_path.display()
|
|
);
|
|
for w in &outcome.warnings {
|
|
println!(" Warning: {}", w);
|
|
}
|
|
}
|
|
|
|
if !hints.is_empty() {
|
|
println!("\nAuth setup:");
|
|
for hint in &hints {
|
|
println!("{}", hint);
|
|
}
|
|
}
|
|
|
|
println!(
|
|
"\nInstalled {}/{} extensions.",
|
|
outcomes.len(),
|
|
manifests.len()
|
|
);
|
|
} else {
|
|
// Single extension
|
|
let manifest = manifests[0];
|
|
let outcome = installer.install(manifest, force, prefer_build).await?;
|
|
|
|
println!("\nInstalled successfully:");
|
|
println!(" Name: {}", outcome.name);
|
|
println!(" Kind: {}", outcome.kind);
|
|
println!(" WASM: {}", outcome.wasm_path.display());
|
|
println!(" Capabilities: {}", outcome.has_capabilities);
|
|
|
|
if let Some(auth) = &manifest.auth_summary
|
|
&& auth.method.as_deref() != Some("none")
|
|
{
|
|
println!(
|
|
"\nNext step: authenticate with `ironclaw tool auth {}`",
|
|
manifest.name
|
|
);
|
|
if let Some(url) = &auth.setup_url {
|
|
println!(" Setup credentials at: {}", url);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|