diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index d06abce1..b4cb6e6d 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -51,17 +51,18 @@ jobs: if: runner.os != 'Windows' run: | mkdir -p dist - cp target/${{ matrix.target }}/release/cargo-agents dist/ + cp target/${{ matrix.target }}/release/cargo-agents target/${{ matrix.target }}/release/symposium dist/ cd dist - tar -czvf cargo-agents-${{ matrix.target }}.tar.gz cargo-agents + tar -czvf cargo-agents-${{ matrix.target }}.tar.gz cargo-agents symposium - name: Package (Windows) if: runner.os == 'Windows' run: | mkdir dist copy target\${{ matrix.target }}\release\cargo-agents.exe dist\ + copy target\${{ matrix.target }}\release\symposium.exe dist\ cd dist - 7z a cargo-agents-${{ matrix.target }}.zip cargo-agents.exe + 7z a cargo-agents-${{ matrix.target }}.zip cargo-agents.exe symposium.exe - name: Upload to release if: github.event_name == 'release' diff --git a/Cargo.toml b/Cargo.toml index 8e080599..d332ab48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,9 +15,14 @@ path = "src/lib.rs" name = "cargo-agents" path = "src/bin/cargo-agents.rs" +# Same program under a standalone name; see `src/entry.rs`. +[[bin]] +name = "symposium" +path = "src/bin/symposium.rs" + [package.metadata.binstall] pkg-url = "{ repo }/releases/download/{ name }-v{ version }/cargo-agents-{ target }.tar.gz" -bin-dir = "cargo-agents{ binary-ext }" +bin-dir = "{ bin }{ binary-ext }" pkg-fmt = "tgz" [package.metadata.binstall.overrides.x86_64-pc-windows-msvc] pkg-url = "{ repo }/releases/download/{ name }-v{ version }/cargo-agents-{ target }.zip" diff --git a/README.md b/README.md index 2e165282..ac490da6 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Install with [`cargo binstall`](https://github.com/cargo-bins/cargo-binstall) (p cargo binstall symposium # or: cargo install symposium ``` -Both provide the `cargo-agents` binary, invoked as `cargo agents`. +Both install two identical executables: `cargo-agents`, invoked as `cargo agents`, and `symposium`, for use outside of Cargo. Every command below also works as `symposium `. ## Quick start diff --git a/md/design/important-flows.md b/md/design/important-flows.md index 11eff832..f618baec 100644 --- a/md/design/important-flows.md +++ b/md/design/important-flows.md @@ -38,7 +38,7 @@ The key code paths are in `discovery.rs`, `config.rs` (`PluginsConfig`, `UseEntr 3. For ` --help`, `help_text` re-renders clap's per-command help by walking clap's command tree to the named subcommand — so required-arg commands (`crate-info`), required-subcommand groups (`plugin`), and nested commands (`plugin list`) all work even though clap's auto help flag is disabled. 4. A plugin-vended ` --help` is left alone: `help_text` returns `None`, and dispatch forwards `--help` to the child binary, which owns its own help. -clap's auto help flag and help subcommand are disabled in `cli::Cli`; `--help`/`-h` is a manual `global` bool. The key code paths are in `help_render.rs` (`help_text`, `render`, `subcommand_help`), `cli.rs` (`builtin_audience`, the `Cli` flags), and `bin/cargo-agents.rs` plus `symposium-testlib` (the parse-then-`help_text` wiring). +clap's auto help flag and help subcommand are disabled in `cli::Cli`; `--help`/`-h` is a manual `global` bool. The key code paths are in `help_render.rs` (`help_text`, `render`, `subcommand_help`), `cli.rs` (`builtin_audience`, the `Cli` flags), and `entry.rs` plus `symposium-testlib` (the parse-then-`help_text` wiring). ## Subcommand dispatch @@ -49,4 +49,4 @@ When the user runs `cargo agents ` for a name not built into the binary, c 3. The matched subcommand's `command` field names an `Installation` on the same plugin. `installation::resolve_runnable` acquires the source if any, runs `install_commands`, and picks the `Runnable` (`Exec` for binaries, `Script` for shell scripts). 4. The child is spawned with stdio inherited. Its exit code is collapsed to a `u8` — the binary wraps it in `ExitCode::from`; the library treats non-zero as an error so the test harness can assert on success/failure. -The key code paths are in `subcommand_dispatch.rs`, `cli.rs` (the `External` arm), and `bin/cargo-agents.rs` (binary-side wrapping that surfaces the numeric exit code to the OS). +The key code paths are in `subcommand_dispatch.rs`, `cli.rs` (the `External` arm), and `entry.rs` (binary-side wrapping that surfaces the numeric exit code to the OS). diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 4925caa0..35bd6c66 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -1,6 +1,6 @@ # Key modules -Symposium is a Rust crate with both a library (`src/lib.rs`) and a binary (`src/bin/cargo-agents.rs`). The library re-exports all modules so that integration tests can access internals. +Symposium is a Rust crate with a library (`src/lib.rs`) and two binaries, `cargo-agents` and `symposium`. Both are one-line shims in `src/bin/` that call `entry::main`; the shared `main` body lives in `src/entry.rs`. `cli::Invocation` records which name the process was started under (by the file stem of `argv[0]`) so help and error output name the right program; `cli::command()` builds the clap `Command` with that name and should be used wherever help is rendered. The two are functionally identical today; `Invocation` is the seam for giving `cargo agents` different defaults later. The library re-exports all modules so that integration tests can access internals. ### `config.rs` — application context diff --git a/src/bin/cargo-agents.rs b/src/bin/cargo-agents.rs index cab3773d..2ebe115a 100644 --- a/src/bin/cargo-agents.rs +++ b/src/bin/cargo-agents.rs @@ -1,360 +1,6 @@ -use clap::Parser; -use std::env; -use std::process::ExitCode; - -use symposium::cli::{Cli, Commands, PluginCommand}; -use symposium::config; -use symposium::help_render; -use symposium::hook; -use symposium::output::Output; -use symposium::plugins; -use symposium::report; -use symposium::self_update; -use symposium::state; -use symposium::subcommand_dispatch::dispatch_external; +//! `cargo agents` entry point. See `symposium::entry`. #[tokio::main] -async fn main() -> ExitCode { - let mut sym = config::Symposium::from_environment(); - - // When invoked as `cargo agents`, cargo passes "agents" as the first arg. - // Strip it so clap sees the real arguments. - let args: Vec<_> = std::env::args_os().collect(); - let filtered: Vec<_> = if args.len() > 1 && args[1] == "agents" { - std::iter::once(args[0].clone()) - .chain(args[2..].iter().cloned()) - .collect() - } else { - args - }; - - let cwd = env::current_dir().expect("failed to get current directory"); - - // Parse without exiting on error: a help request on a built-in with required args - // (`crate-info --help`, `plugin --help`) surfaces a parse error that `help_text` recovers. - // `args_str` feeds the subcommand-name walk. - let args_str = filtered - .iter() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect::>(); - let parse = Cli::try_parse_from(filtered); - - // `--help` / `-h` / `help` / no subcommand -> audience-grouped top-level help (or clap's - // per-command help for ` --help`). - // Plugin ` --help` returns `None` here and is forwarded to the child by dispatch below. - if let Some(text) = help_render::help_text(parse.as_ref(), &args_str, &sym, &cwd).await { - print!("{text}"); - return ExitCode::SUCCESS; - } - - let cli = match parse { - Ok(cli) => cli, - Err(err) => err.exit(), - }; - - // Always install the report layer. Mode determines output format: - // --json → accumulate JSON array; -v → stderr trace; default → stdout. - let (mode, level) = if cli.json { - let level = if cli.verbose { - tracing::Level::DEBUG - } else { - tracing::Level::INFO - }; - (report::ReportMode::Json, level) - } else if cli.verbose { - (report::ReportMode::Verbose, tracing::Level::DEBUG) - } else { - (report::ReportMode::Normal, tracing::Level::INFO) - }; - let (report_layer, report_handle) = report::ReportLayer::new(mode, level); - sym.init_logging(Some(report_layer)); - - // Log the command being invoked - match &cli.command { - Some(Commands::Init { .. }) => tracing::info!("cargo agents init"), - Some(Commands::Sync) => tracing::info!("cargo agents sync"), - Some(Commands::Search { query }) => tracing::info!(%query, "cargo agents search"), - Some(Commands::Use { - name, - global, - remove, - }) => tracing::info!(%name, global, remove, "cargo agents use"), - Some(Commands::Status) => tracing::info!("cargo agents status"), - Some(Commands::Plugin { command }) => { - tracing::info!(subcommand = ?command, "cargo agents plugin"); - } - Some(Commands::Hook { agent, event }) => { - tracing::debug!(?agent, ?event, "cargo agents hook"); - } - Some(Commands::SelfUpdate) => tracing::info!("cargo agents self-update"), - Some(Commands::CrateInfo { name, version }) => { - tracing::debug!(%name, version = ?version, "cargo agents crate-info"); - } - Some(Commands::Telemetry { command }) => { - tracing::info!(subcommand = ?command, "cargo agents telemetry"); - } - Some(Commands::External(argv)) => { - tracing::info!(argv = ?argv, "cargo agents "); - } - None => {} - } - - // Stamp state.toml with the running binary version (silently updates on mismatch). - state::ensure_current(sym.config_dir()); - - // Hook commands are quiet by default (they're invoked by the agent, not the user). - // JSON mode also suppresses human output (only JSON goes to stdout). - let is_hook = matches!(cli.command, Some(Commands::Hook { .. })); - let out = if cli.quiet || is_hook || cli.json { - Output::quiet() - } else { - Output::normal() - }; - - // Ensure git-based plugin sources are up to date (non-blocking on failure). - // SessionStart runs once per session, so we force a real freshness check - // there; other invocations use the `--update` level (debounced by default). - let source_update = match &cli.command { - Some(Commands::Hook { event, .. }) - if *event == symposium::hook::HookEvent::SessionStart => - { - symposium_install::UpdateLevel::Check - } - _ => cli.update, - }; - plugins::ensure_registries(&sym, source_update).await; - - // Auto-update = "on": check for updates and re-exec if a new binary was - // installed. Skipped for self-update (which always checks explicitly) - // and for hooks (session-start injects the warn nudge into hook output; - // the "on" re-exec for hooks is handled here). - if !matches!(cli.command, Some(Commands::SelfUpdate)) && !is_hook { - if self_update::maybe_check_for_update(&sym, &out).await { - self_update::re_exec(); - } - } else if is_hook - && sym.config.auto_update == config::AutoUpdate::On - && self_update::maybe_check_for_update(&sym, &Output::quiet()).await - { - self_update::re_exec(); - } - - match cli.command { - // Commands that need direct I/O (stdin/stdout) stay in the binary - Some(Commands::Hook { agent, event }) => hook::run(&sym, agent, event).await, - - Some(Commands::Plugin { command }) => { - let code = handle_plugin_command(&sym, command).await; - let events = report_handle.drain(); - if !events.is_empty() { - println!("{}", serde_json::to_string_pretty(&events).unwrap()); - } - code - } - - Some(Commands::External(argv)) => match dispatch_external(&sym, &cwd, argv).await { - Ok(result) => { - use std::io::Write; - std::io::stdout().write_all(&result.stdout).ok(); - std::io::stderr().write_all(&result.stderr).ok(); - ExitCode::from(result.exit_code) - } - Err(err) => { - eprintln!("Error: {err:#}"); - ExitCode::FAILURE - } - }, - // No-subcommand and the `help` keyword are handled by the help branch - // right after parsing, above. - None => unreachable!("no-subcommand routes to the help renderer above"), - - // Everything else delegates to the library - Some(cmd) => match symposium::cli::run(&mut sym, cmd, &cwd, &out, cli.update).await { - Ok(()) => { - let events = report_handle.drain(); - if !events.is_empty() { - println!("{}", serde_json::to_string_pretty(&events).unwrap()); - } - ExitCode::SUCCESS - } - Err(e) => { - eprintln!("Error: {e:#}"); - ExitCode::FAILURE - } - }, - } -} - -async fn handle_plugin_command(sym: &config::Symposium, command: PluginCommand) -> ExitCode { - match command { - PluginCommand::Sync { provider } => { - match plugins::sync_registries(sym, provider.as_deref()).await { - Ok(synced) => { - if synced.is_empty() { - if let Some(ref p) = provider { - println!("No git source found for provider: {p}"); - } else { - println!("No git sources to sync."); - } - } else { - for name in &synced { - println!("Synced: {name}"); - } - } - ExitCode::SUCCESS - } - Err(e) => { - eprintln!("Sync failed: {e}"); - ExitCode::FAILURE - } - } - } - PluginCommand::List => { - let providers = plugins::list_plugins(sym).await; - for provider in &providers { - tracing::info!( - report = %report::ReportEvent::ProviderListed { - name: provider.name.clone(), - source_type: provider.source_type.to_string(), - url: provider.git_url.clone(), - path: provider.path.clone(), - plugins: provider.plugins.iter().map(|p| p.name.clone()).collect(), - }, - ); - } - ExitCode::SUCCESS - } - PluginCommand::Validate { - path, - no_check_crates, - } => { - if path.is_dir() { - let mut errors = 0; - - match plugins::validate_source_dir(&path) { - Ok(results) => { - if results.is_empty() { - eprintln!("No plugins or skills found in {}", path.display()); - return ExitCode::FAILURE; - } - for r in &results { - errors += emit_validation_results(r); - } - } - Err(e) => { - eprintln!("✗ {}: {e}", path.display()); - return ExitCode::FAILURE; - } - } - - if !no_check_crates { - match plugins::collect_crate_names_in_source_dir(&path) { - Ok(crate_names) => { - for name in &crate_names { - let exists = plugins::check_crate_exists(name).await; - tracing::info!( - report = %report::ReportEvent::Validated { - path: name.clone(), - item_kind: "crate".into(), - valid: exists, - error: if exists { None } else { Some("not found on crates.io".into()) }, - warning: None, - }, - ); - if !exists { - errors += 1; - } - } - } - Err(e) => { - tracing::info!( - report = %report::ReportEvent::Validated { - path: path.display().to_string(), - item_kind: "crate-check".into(), - valid: false, - error: Some(format!("failed to collect crate names: {e}")), - warning: None, - }, - ); - errors += 1; - } - } - } - - if errors > 0 { - ExitCode::FAILURE - } else { - ExitCode::SUCCESS - } - } else { - let parent = path.parent().unwrap_or(&path); - match plugins::load_plugin(&path, "", parent) { - Ok(_) => { - // `path` is the manifest file being validated. - println!("{}", tokio::fs::read_to_string(&path).await.unwrap()); - ExitCode::SUCCESS - } - Err(e) => { - eprintln!("{}: {e}", path.display()); - ExitCode::FAILURE - } - } - } - } - PluginCommand::Show { plugin } => match plugins::find_plugin(sym, &plugin).await { - // A plugin is identified by its id; render its effective (resolved) - // configuration rather than re-reading a manifest file. - Some(p) => { - println!("# {}", p.canonical); - match toml::to_string_pretty(&p.plugin) { - Ok(rendered) => { - println!(); - print!("{rendered}"); - ExitCode::SUCCESS - } - Err(e) => { - eprintln!("cannot render `{}`: {e}", p.canonical); - ExitCode::FAILURE - } - } - } - None => { - eprintln!("Plugin not found: {plugin}"); - ExitCode::FAILURE - } - }, - } -} - -fn emit_validation_results(r: &plugins::ValidationResult) -> usize { - let mut errors = 0; - match &r.result { - Ok(()) => { - tracing::info!( - report = %report::ReportEvent::Validated { - path: r.id.clone(), - item_kind: r.kind.to_string(), - valid: true, - error: None, - warning: r.warning.clone(), - }, - ); - } - Err(e) => { - tracing::info!( - report = %report::ReportEvent::Validated { - path: r.id.clone(), - item_kind: r.kind.to_string(), - valid: false, - error: Some(e.to_string()), - warning: None, - }, - ); - errors += 1; - } - } - for child in &r.children { - errors += emit_validation_results(child); - } - errors +async fn main() -> std::process::ExitCode { + symposium::entry::main().await } diff --git a/src/bin/symposium.rs b/src/bin/symposium.rs new file mode 100644 index 00000000..5eb9cd3a --- /dev/null +++ b/src/bin/symposium.rs @@ -0,0 +1,6 @@ +//! Standalone `symposium` entry point. See `symposium::entry`. + +#[tokio::main] +async fn main() -> std::process::ExitCode { + symposium::entry::main().await +} diff --git a/src/cli.rs b/src/cli.rs index 4868a998..f7f9d4f4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -23,6 +23,65 @@ use crate::subcommand_dispatch::dispatch_external; use crate::sync; use crate::use_command; +/// Which executable name this process was started under. +/// +/// `cargo-agents` (whether or not cargo itself dispatched to it) and the +/// standalone `symposium` binary run the same code today; this only picks +/// the program name shown in help and error output. It is the seam for +/// giving the two different defaults later. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Invocation { + /// Started as `cargo-agents` (typically via `cargo agents`). + CargoSubcommand, + /// Started as `symposium`. + Standalone, +} + +static INVOCATION: std::sync::OnceLock = std::sync::OnceLock::new(); + +impl Invocation { + /// Classify by the file stem of `argv[0]`. + pub fn detect(argv0: &std::ffi::OsStr) -> Self { + match Path::new(argv0).file_stem().and_then(|s| s.to_str()) { + Some("cargo-agents") => Self::CargoSubcommand, + _ => Self::Standalone, + } + } + + /// Program name as the user typed it. + pub fn bin_name(self) -> &'static str { + match self { + Self::CargoSubcommand => "cargo agents", + Self::Standalone => "symposium", + } + } + + /// Record this process's invocation. Later calls are no-ops. + pub fn set_current(self) { + let _ = INVOCATION.set(self); + } + + /// The recorded invocation; `CargoSubcommand` if none was set (tests). + pub fn current() -> Self { + INVOCATION.get().copied().unwrap_or(Self::CargoSubcommand) + } +} + +/// The clap command for [`Cli`], with `bin_name` matching [`Invocation::current`]. +/// +/// Use this instead of `Cli::command()` wherever help or usage text is rendered. +pub fn command() -> clap::Command { + use clap::CommandFactory; + let invocation = Invocation::current(); + // `bin_name` drives usage lines; `display_name` drives `--version`. + Cli::command() + .bin_name(invocation.bin_name()) + .display_name(match invocation { + Invocation::CargoSubcommand => "cargo-agents", + Invocation::Standalone => "symposium", + }) +} + /// Parsed CLI arguments. #[derive(Debug, Parser)] #[command( diff --git a/src/entry.rs b/src/entry.rs new file mode 100644 index 00000000..6fc90d23 --- /dev/null +++ b/src/entry.rs @@ -0,0 +1,370 @@ +//! Shared `main` for the `cargo-agents` and `symposium` binaries. +//! +//! Both executables are the same program; `src/bin/*.rs` are one-line shims +//! that call [`main`]. Which name was used is recorded as a +//! [`cli::Invocation`] so help text and error messages name the right +//! program, and so the two can grow different defaults later. + +use clap::FromArgMatches; +use std::env; +use std::process::ExitCode; + +use crate::cli::{self, Cli, Commands, Invocation, PluginCommand}; +use crate::config; +use crate::help_render; +use crate::hook; +use crate::output::Output; +use crate::plugins; +use crate::report; +use crate::self_update; +use crate::state; +use crate::subcommand_dispatch::dispatch_external; + +pub async fn main() -> ExitCode { + let mut sym = config::Symposium::from_environment(); + + let args: Vec<_> = std::env::args_os().collect(); + let invocation = Invocation::detect(&args[0]); + invocation.set_current(); + + // When invoked as `cargo agents`, cargo passes "agents" as the first arg. + // Strip it so clap sees the real arguments. + let filtered: Vec<_> = + if invocation == Invocation::CargoSubcommand && args.len() > 1 && args[1] == "agents" { + std::iter::once(args[0].clone()) + .chain(args[2..].iter().cloned()) + .collect() + } else { + args + }; + + let cwd = env::current_dir().expect("failed to get current directory"); + + // Parse without exiting on error: a help request on a built-in with required args + // (`crate-info --help`, `plugin --help`) surfaces a parse error that `help_text` recovers. + // `args_str` feeds the subcommand-name walk. + let args_str = filtered + .iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + let parse = cli::command() + .try_get_matches_from(filtered) + .and_then(|matches| Cli::from_arg_matches(&matches)); + + // `--help` / `-h` / `help` / no subcommand -> audience-grouped top-level help (or clap's + // per-command help for ` --help`). + // Plugin ` --help` returns `None` here and is forwarded to the child by dispatch below. + if let Some(text) = help_render::help_text(parse.as_ref(), &args_str, &sym, &cwd).await { + print!("{text}"); + return ExitCode::SUCCESS; + } + + let cli = match parse { + Ok(cli) => cli, + Err(err) => err.exit(), + }; + + // Always install the report layer. Mode determines output format: + // --json → accumulate JSON array; -v → stderr trace; default → stdout. + let (mode, level) = if cli.json { + let level = if cli.verbose { + tracing::Level::DEBUG + } else { + tracing::Level::INFO + }; + (report::ReportMode::Json, level) + } else if cli.verbose { + (report::ReportMode::Verbose, tracing::Level::DEBUG) + } else { + (report::ReportMode::Normal, tracing::Level::INFO) + }; + let (report_layer, report_handle) = report::ReportLayer::new(mode, level); + sym.init_logging(Some(report_layer)); + + // Log the command being invoked + match &cli.command { + Some(Commands::Init { .. }) => tracing::info!("cargo agents init"), + Some(Commands::Sync) => tracing::info!("cargo agents sync"), + Some(Commands::Search { query }) => tracing::info!(%query, "cargo agents search"), + Some(Commands::Use { + name, + global, + remove, + }) => tracing::info!(%name, global, remove, "cargo agents use"), + Some(Commands::Status) => tracing::info!("cargo agents status"), + Some(Commands::Plugin { command }) => { + tracing::info!(subcommand = ?command, "cargo agents plugin"); + } + Some(Commands::Hook { agent, event }) => { + tracing::debug!(?agent, ?event, "cargo agents hook"); + } + Some(Commands::SelfUpdate) => tracing::info!("cargo agents self-update"), + Some(Commands::CrateInfo { name, version }) => { + tracing::debug!(%name, version = ?version, "cargo agents crate-info"); + } + Some(Commands::Telemetry { command }) => { + tracing::info!(subcommand = ?command, "cargo agents telemetry"); + } + Some(Commands::External(argv)) => { + tracing::info!(argv = ?argv, "cargo agents "); + } + None => {} + } + + // Stamp state.toml with the running binary version (silently updates on mismatch). + state::ensure_current(sym.config_dir()); + + // Hook commands are quiet by default (they're invoked by the agent, not the user). + // JSON mode also suppresses human output (only JSON goes to stdout). + let is_hook = matches!(cli.command, Some(Commands::Hook { .. })); + let out = if cli.quiet || is_hook || cli.json { + Output::quiet() + } else { + Output::normal() + }; + + // Ensure git-based plugin sources are up to date (non-blocking on failure). + // SessionStart runs once per session, so we force a real freshness check + // there; other invocations use the `--update` level (debounced by default). + let source_update = match &cli.command { + Some(Commands::Hook { event, .. }) if *event == hook::HookEvent::SessionStart => { + symposium_install::UpdateLevel::Check + } + _ => cli.update, + }; + plugins::ensure_registries(&sym, source_update).await; + + // Auto-update = "on": check for updates and re-exec if a new binary was + // installed. Skipped for self-update (which always checks explicitly) + // and for hooks (session-start injects the warn nudge into hook output; + // the "on" re-exec for hooks is handled here). + if !matches!(cli.command, Some(Commands::SelfUpdate)) && !is_hook { + if self_update::maybe_check_for_update(&sym, &out).await { + self_update::re_exec(); + } + } else if is_hook + && sym.config.auto_update == config::AutoUpdate::On + && self_update::maybe_check_for_update(&sym, &Output::quiet()).await + { + self_update::re_exec(); + } + + match cli.command { + // Commands that need direct I/O (stdin/stdout) stay in the binary + Some(Commands::Hook { agent, event }) => hook::run(&sym, agent, event).await, + + Some(Commands::Plugin { command }) => { + let code = handle_plugin_command(&sym, command).await; + let events = report_handle.drain(); + if !events.is_empty() { + println!("{}", serde_json::to_string_pretty(&events).unwrap()); + } + code + } + + Some(Commands::External(argv)) => match dispatch_external(&sym, &cwd, argv).await { + Ok(result) => { + use std::io::Write; + std::io::stdout().write_all(&result.stdout).ok(); + std::io::stderr().write_all(&result.stderr).ok(); + ExitCode::from(result.exit_code) + } + Err(err) => { + eprintln!("Error: {err:#}"); + ExitCode::FAILURE + } + }, + // No-subcommand and the `help` keyword are handled by the help branch + // right after parsing, above. + None => unreachable!("no-subcommand routes to the help renderer above"), + + // Everything else delegates to the library + Some(cmd) => match cli::run(&mut sym, cmd, &cwd, &out, cli.update).await { + Ok(()) => { + let events = report_handle.drain(); + if !events.is_empty() { + println!("{}", serde_json::to_string_pretty(&events).unwrap()); + } + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("Error: {e:#}"); + ExitCode::FAILURE + } + }, + } +} + +async fn handle_plugin_command(sym: &config::Symposium, command: PluginCommand) -> ExitCode { + match command { + PluginCommand::Sync { provider } => { + match plugins::sync_registries(sym, provider.as_deref()).await { + Ok(synced) => { + if synced.is_empty() { + if let Some(ref p) = provider { + println!("No git source found for provider: {p}"); + } else { + println!("No git sources to sync."); + } + } else { + for name in &synced { + println!("Synced: {name}"); + } + } + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("Sync failed: {e}"); + ExitCode::FAILURE + } + } + } + PluginCommand::List => { + let providers = plugins::list_plugins(sym).await; + for provider in &providers { + tracing::info!( + report = %report::ReportEvent::ProviderListed { + name: provider.name.clone(), + source_type: provider.source_type.to_string(), + url: provider.git_url.clone(), + path: provider.path.clone(), + plugins: provider.plugins.iter().map(|p| p.name.clone()).collect(), + }, + ); + } + ExitCode::SUCCESS + } + PluginCommand::Validate { + path, + no_check_crates, + } => { + if path.is_dir() { + let mut errors = 0; + + match plugins::validate_source_dir(&path) { + Ok(results) => { + if results.is_empty() { + eprintln!("No plugins or skills found in {}", path.display()); + return ExitCode::FAILURE; + } + for r in &results { + errors += emit_validation_results(r); + } + } + Err(e) => { + eprintln!("✗ {}: {e}", path.display()); + return ExitCode::FAILURE; + } + } + + if !no_check_crates { + match plugins::collect_crate_names_in_source_dir(&path) { + Ok(crate_names) => { + for name in &crate_names { + let exists = plugins::check_crate_exists(name).await; + tracing::info!( + report = %report::ReportEvent::Validated { + path: name.clone(), + item_kind: "crate".into(), + valid: exists, + error: if exists { None } else { Some("not found on crates.io".into()) }, + warning: None, + }, + ); + if !exists { + errors += 1; + } + } + } + Err(e) => { + tracing::info!( + report = %report::ReportEvent::Validated { + path: path.display().to_string(), + item_kind: "crate-check".into(), + valid: false, + error: Some(format!("failed to collect crate names: {e}")), + warning: None, + }, + ); + errors += 1; + } + } + } + + if errors > 0 { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + } + } else { + let parent = path.parent().unwrap_or(&path); + match plugins::load_plugin(&path, "", parent) { + Ok(_) => { + // `path` is the manifest file being validated. + println!("{}", tokio::fs::read_to_string(&path).await.unwrap()); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("{}: {e}", path.display()); + ExitCode::FAILURE + } + } + } + } + PluginCommand::Show { plugin } => match plugins::find_plugin(sym, &plugin).await { + // A plugin is identified by its id; render its effective (resolved) + // configuration rather than re-reading a manifest file. + Some(p) => { + println!("# {}", p.canonical); + match toml::to_string_pretty(&p.plugin) { + Ok(rendered) => { + println!(); + print!("{rendered}"); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("cannot render `{}`: {e}", p.canonical); + ExitCode::FAILURE + } + } + } + None => { + eprintln!("Plugin not found: {plugin}"); + ExitCode::FAILURE + } + }, + } +} + +fn emit_validation_results(r: &plugins::ValidationResult) -> usize { + let mut errors = 0; + match &r.result { + Ok(()) => { + tracing::info!( + report = %report::ReportEvent::Validated { + path: r.id.clone(), + item_kind: r.kind.to_string(), + valid: true, + error: None, + warning: r.warning.clone(), + }, + ); + } + Err(e) => { + tracing::info!( + report = %report::ReportEvent::Validated { + path: r.id.clone(), + item_kind: r.kind.to_string(), + valid: false, + error: Some(e.to_string()), + warning: None, + }, + ); + errors += 1; + } + } + for child in &r.children { + errors += emit_validation_results(child); + } + errors +} diff --git a/src/help_render.rs b/src/help_render.rs index 823b26ed..77957262 100644 --- a/src/help_render.rs +++ b/src/help_render.rs @@ -8,7 +8,7 @@ use std::{fmt::Write as _, path::Path}; -use clap::{Command, CommandFactory}; +use clap::Command; use crate::{ cli::{Cli, Commands, builtin_audience}, @@ -65,7 +65,7 @@ pub async fn help_text( /// Render clap's help for the deepest built-in subcommand named in `args`, or `None` if none is present (top-level invocation, or a plugin name). pub fn subcommand_help(args: &[String]) -> Option { - let mut root = Cli::command(); + let mut root = crate::cli::command(); root.build(); let mut current = &root; @@ -115,7 +115,7 @@ pub async fn render_help(sym: &Symposium, cwd: &Path) -> String { } fn render(plugins: &[ParsedPlugin], deps: &[PackageId], used: &[&str]) -> String { - let mut cmd = Cli::command(); + let mut cmd = crate::cli::command(); let full = cmd.render_help().to_string(); let (Some(commands_idx), Some(options_idx)) = diff --git a/src/lib.rs b/src/lib.rs index 7ca53ed2..0ca6346b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod config; pub mod crate_command; pub mod dirs; pub mod discovery; +pub mod entry; pub mod help_render; pub mod hook; pub mod hook_schema;