From cc787d487156d564d51511206454a4f3045c11bb Mon Sep 17 00:00:00 2001 From: Nathan DeMoss Date: Mon, 31 Aug 2026 19:52:48 -0400 Subject: [PATCH] feat(cli): promote profile to a top-level command Profiles were reachable only through `provider list-profiles` and the nested `provider profile` group, which broke the `noun verb` shape the rest of the CLI follows and left no way to read a single profile. Add a top-level `profile` noun with list, describe, export, import, update, lint, and delete. `describe` is new: it renders one profile for reading, and reuses the export serializer for `-o json` and `-o yaml` so the two commands cannot drift apart. `list` gains `--type`, which is a no-op today because `provider` is the only profile type the gateway stores, and exists so callers can pin the type they expect. The new commands call the same `run::provider_profile_*` functions that already backed the nested group, so the original spellings keep working against one shared implementation rather than a parallel code path. Signed-off-by: Nathan DeMoss --- .agents/skills/openshell-cli/SKILL.md | 9 +- .agents/skills/openshell-cli/cli-reference.md | 31 +- crates/openshell-cli/src/main.rs | 323 ++++++++++++++++++ crates/openshell-cli/src/run.rs | 251 +++++++++++++- docs/sandboxes/manage-providers.mdx | 4 +- docs/sandboxes/providers-v2.mdx | 16 +- 6 files changed, 618 insertions(+), 16 deletions(-) diff --git a/.agents/skills/openshell-cli/SKILL.md b/.agents/skills/openshell-cli/SKILL.md index eb5c467cb6..87b9575124 100644 --- a/.agents/skills/openshell-cli/SKILL.md +++ b/.agents/skills/openshell-cli/SKILL.md @@ -106,10 +106,13 @@ openshell sandbox delete Providers supply credentials and provider-specific configuration to sandboxes. Provider types come from built-in and custom profiles; do not rely on a hard-coded type list. Discover the profiles available on the selected gateway: ```bash -openshell provider list-profiles -openshell provider list-profiles --output json +openshell profile list +openshell profile list --output json +openshell profile describe ``` +`openshell provider list-profiles` remains available as an alias. + ### Create a provider from local credentials ```bash @@ -799,7 +802,7 @@ $ openshell sandbox upload --help | Download files from sandbox | `openshell sandbox download ` | | Create provider | `openshell provider create --name N --type T --from-existing` | | List providers | `openshell provider list` | -| Discover provider profiles | `openshell provider list-profiles` | +| Discover provider profiles | `openshell profile list` | | List attached providers | `openshell sandbox provider list ` | | View settings | `openshell settings get [name]` | | Configure managed inference | `openshell inference set --provider P --model M` | diff --git a/.agents/skills/openshell-cli/cli-reference.md b/.agents/skills/openshell-cli/cli-reference.md index 4d39143d8d..fd514171c8 100644 --- a/.agents/skills/openshell-cli/cli-reference.md +++ b/.agents/skills/openshell-cli/cli-reference.md @@ -105,8 +105,17 @@ openshell │ │ ├── update --file │ │ ├── lint (--file |--from ) │ │ └── delete +│ │ (aliases of the top-level `profile` command) │ ├── update [opts] │ └── delete ... +├── profile +│ ├── list [--type provider] [opts] +│ ├── describe [opts] +│ ├── export [opts] +│ ├── import (--file |--from ) +│ ├── update --file +│ ├── lint (--file |--from ) +│ └── delete ├── doctor │ └── check ├── term @@ -548,14 +557,24 @@ Update an existing provider without changing its type. Delete one or more providers by name. -### Provider profiles +### Profiles + +- `openshell profile list [--type provider] [--output table|yaml|json]` +- `openshell profile describe [--output table|yaml|json]` +- `openshell profile export [--output table|yaml|json]` +- `openshell profile import (--file |--from )` +- `openshell profile update --file ` +- `openshell profile lint (--file |--from )` +- `openshell profile delete ` + +Every profile is currently a provider profile, so `--type provider` matches the +full inventory. The filter exists so a caller can pin the type it expects +without the command changing meaning when other profile types are added. + +The original spellings remain available and run the same code: - `openshell provider list-profiles [--output table|yaml|json]` -- `openshell provider profile export [--output table|yaml|json]` -- `openshell provider profile import (--file |--from )` -- `openshell provider profile update --file ` -- `openshell provider profile lint (--file |--from )` -- `openshell provider profile delete ` +- `openshell provider profile export|import|update|lint|delete ...` ### Provider credential refresh diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index ad03fb4ec1..de3ee0c8b0 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -564,6 +564,13 @@ enum Commands { command: Option, }, + /// Manage profiles. + #[command(help_template = SUBCOMMAND_HELP_TEMPLATE)] + Profile { + #[command(subcommand)] + command: Option, + }, + /// Manage workspaces. #[command(alias = "ws", after_help = WORKSPACE_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] Workspace { @@ -1026,6 +1033,130 @@ enum ProviderRefreshCommands { }, } +/// Profile type filter for `openshell profile list`. +/// +/// Every profile is currently a provider profile. The filter exists so the +/// command stays usable unchanged when other profile types are introduced. +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +enum ProfileType { + Provider, +} + +impl ProfileType { + const fn as_str(self) -> &'static str { + match self { + Self::Provider => "provider", + } + } +} + +#[derive(Subcommand, Debug)] +enum ProfileCommands { + /// List available profiles. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + List { + /// Only list profiles of this type. + #[arg(long = "type", value_enum)] + profile_type: Option, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, + + /// List platform-scoped profiles (ignores --workspace). + #[arg(long)] + global: bool, + }, + + /// Show the full contents of a single profile. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Describe { + /// Profile id. + id: String, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, + + /// Target platform-scoped profiles (ignores --workspace). + #[arg(long)] + global: bool, + }, + + /// Export a profile. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Export { + /// Profile id. + id: String, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Yaml)] + output: OutputFormat, + + /// Target platform-scoped profiles (ignores --workspace). + #[arg(long)] + global: bool, + }, + + /// Import profiles from a file or directory. + #[command(group = clap::ArgGroup::new("profile_import_source").required(true).args(["file", "from"]), help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Import { + /// Profile file to import. + #[arg(short = 'f', long = "file", value_hint = ValueHint::FilePath)] + file: Option, + + /// Directory containing profile files to import. + #[arg(long = "from", value_hint = ValueHint::DirPath)] + from: Option, + + /// Import as platform-scoped profiles (ignores --workspace). + #[arg(long)] + global: bool, + }, + + /// Update an existing custom profile from a file. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Update { + /// Existing profile id to update. + id: String, + + /// Profile file to update. + #[arg(short = 'f', long = "file", value_hint = ValueHint::FilePath)] + file: PathBuf, + + /// Target platform-scoped profile (ignores --workspace). + #[arg(long)] + global: bool, + }, + + /// Validate profile files without registering them. + #[command(group = clap::ArgGroup::new("profile_lint_source").required(true).args(["file", "from"]), help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Lint { + /// Profile file to lint. + #[arg(short = 'f', long = "file", value_hint = ValueHint::FilePath)] + file: Option, + + /// Directory containing profile files to lint. + #[arg(long = "from", value_hint = ValueHint::DirPath)] + from: Option, + + /// Lint against platform scope (ignores --workspace). + #[arg(long)] + global: bool, + }, + + /// Delete a custom profile. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Delete { + /// Profile id. + id: String, + + /// Target platform-scoped profile (ignores --workspace). + #[arg(long)] + global: bool, + }, +} + #[derive(Subcommand, Debug)] enum ProviderProfileCommands { /// Export a provider profile. @@ -3730,6 +3861,94 @@ async fn run_async() -> Result<()> { .print_help() .expect("Failed to print help"); } + Some(Commands::Profile { + command: Some(command), + }) => { + let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; + let endpoint = &ctx.endpoint; + let mut tls = tls.with_gateway_name(&ctx.name); + apply_auth(&mut tls, &ctx.name); + let profile_workspace = + |global: bool| -> &str { if global { "" } else { &cli.workspace } }; + + match command { + ProfileCommands::List { + profile_type, + output, + global, + } => { + run::profile_list( + endpoint, + profile_type.map(ProfileType::as_str), + output.as_str(), + profile_workspace(global), + &tls, + ) + .await?; + } + ProfileCommands::Describe { id, output, global } => { + run::profile_describe( + endpoint, + &id, + output.as_str(), + profile_workspace(global), + &tls, + ) + .await?; + } + ProfileCommands::Export { id, output, global } => { + run::provider_profile_export( + endpoint, + &id, + output.as_str(), + profile_workspace(global), + &tls, + ) + .await?; + } + ProfileCommands::Import { file, from, global } => { + run::provider_profile_import( + endpoint, + file.as_deref(), + from.as_deref(), + profile_workspace(global), + &tls, + ) + .await?; + } + ProfileCommands::Update { id, file, global } => { + run::provider_profile_update( + endpoint, + &id, + &file, + profile_workspace(global), + &tls, + ) + .await?; + } + ProfileCommands::Lint { file, from, global } => { + run::provider_profile_lint( + endpoint, + file.as_deref(), + from.as_deref(), + profile_workspace(global), + &tls, + ) + .await?; + } + ProfileCommands::Delete { id, global } => { + run::provider_profile_delete(endpoint, &id, profile_workspace(global), &tls) + .await?; + } + } + } + Some(Commands::Profile { command: None }) => { + Cli::command() + .find_subcommand_mut("profile") + .expect("profile subcommand exists") + .print_help() + .expect("Failed to print help"); + } Some(Commands::Gateway { command: None }) => { Cli::command() .find_subcommand_mut("gateway") @@ -4192,6 +4411,110 @@ mod tests { assert_eq!(dest.get_value_hint(), ValueHint::AnyPath); } + #[test] + fn profile_list_accepts_type_and_output_flags() { + let cli = Cli::try_parse_from([ + "openshell", + "profile", + "list", + "--type", + "provider", + "-o", + "json", + ]) + .expect("profile list should parse"); + + let Some(Commands::Profile { + command: + Some(ProfileCommands::List { + profile_type, + output, + global, + }), + }) = cli.command + else { + panic!("expected profile list command"); + }; + assert_eq!(profile_type, Some(ProfileType::Provider)); + assert_eq!(output.as_str(), "json"); + assert!(!global); + } + + #[test] + fn profile_list_defaults_to_table_and_no_type_filter() { + let cli = Cli::try_parse_from(["openshell", "profile", "list"]) + .expect("profile list should parse"); + + let Some(Commands::Profile { + command: + Some(ProfileCommands::List { + profile_type, + output, + .. + }), + }) = cli.command + else { + panic!("expected profile list command"); + }; + assert_eq!(profile_type, None); + assert_eq!(output.as_str(), "table"); + } + + #[test] + fn profile_list_rejects_unknown_type() { + Cli::try_parse_from(["openshell", "profile", "list", "--type", "nonsense"]) + .expect_err("unknown profile type should be rejected"); + } + + #[test] + fn profile_describe_parses_id_and_output() { + let cli = Cli::try_parse_from(["openshell", "profile", "describe", "openai", "-o", "yaml"]) + .expect("profile describe should parse"); + + let Some(Commands::Profile { + command: Some(ProfileCommands::Describe { id, output, .. }), + }) = cli.command + else { + panic!("expected profile describe command"); + }; + assert_eq!(id, "openai"); + assert_eq!(output.as_str(), "yaml"); + } + + #[test] + fn profile_import_requires_a_source() { + Cli::try_parse_from(["openshell", "profile", "import"]) + .expect_err("import without --file or --from should be rejected"); + + Cli::try_parse_from(["openshell", "profile", "import", "--file", "p.yaml"]) + .expect("import with --file should parse"); + } + + #[test] + fn profile_subcommands_accept_global_scope() { + for args in [ + vec!["openshell", "profile", "list", "--global"], + vec!["openshell", "profile", "describe", "openai", "--global"], + vec!["openshell", "profile", "export", "openai", "--global"], + vec!["openshell", "profile", "delete", "custom", "--global"], + ] { + Cli::try_parse_from(args.clone()) + .unwrap_or_else(|err| panic!("{args:?} should parse: {err}")); + } + } + + /// The issue promotes `profile` to a top-level noun without removing the + /// original commands, so both spellings must keep parsing. + #[test] + fn legacy_provider_profile_commands_still_parse() { + Cli::try_parse_from(["openshell", "provider", "list-profiles"]) + .expect("provider list-profiles should still parse"); + Cli::try_parse_from(["openshell", "provider", "profile", "export", "openai"]) + .expect("provider profile export should still parse"); + Cli::try_parse_from(["openshell", "provider", "profile", "delete", "custom"]) + .expect("provider profile delete should still parse"); + } + #[test] fn parse_upload_spec_without_remote() { let (local, remote) = parse_upload_spec("./src"); diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index bead4b4319..7c27d38871 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -4156,6 +4156,9 @@ pub async fn provider_list( Ok(()) } +/// The only profile type the gateway currently stores. +const PROVIDER_PROFILE_TYPE: &str = "provider"; + pub async fn provider_list_profiles( server: &str, output: &str, @@ -4214,6 +4217,171 @@ pub async fn provider_list_profiles( Ok(()) } +/// List profiles, optionally narrowed to a single profile type. +/// +/// Every profile the gateway stores is currently a provider profile, so a +/// `Some("provider")` filter is a no-op and any other value yields an empty +/// collection rather than an error. That keeps `--type` forward-compatible: +/// callers can pin the type they expect without the command failing once other +/// types exist. +pub async fn profile_list( + server: &str, + profile_type: Option<&str>, + output: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + if profile_type.is_some_and(|requested| requested != PROVIDER_PROFILE_TYPE) { + return print_empty_profile_list(output); + } + provider_list_profiles(server, output, workspace, tls).await +} + +/// Render an empty profile collection for a type filter that matches nothing. +fn print_empty_profile_list(output: &str) -> Result<()> { + let empty: Vec = Vec::new(); + if crate::output::print_output_direct( + output, + || profiles_to_json(&empty).into_diagnostic(), + || profiles_to_yaml(&empty).into_diagnostic(), + )? { + return Ok(()); + } + println!("No provider profiles found."); + Ok(()) +} + +/// Show a single profile in full. +/// +/// Structured output reuses the same serializer as `profile export`, so the +/// records stay identical across both commands. Table output is a +/// human-readable rendering that keeps full values rather than the abbreviated +/// columns used by `profile list`. +pub async fn profile_describe( + server: &str, + id: &str, + output: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + if matches!(output, "json" | "yaml") { + let rendered = provider_profile_export_text(server, id, output, workspace, tls).await?; + if output == "json" { + println!("{rendered}"); + } else { + print!("{rendered}"); + } + return Ok(()); + } + + let mut client = grpc_client(server, tls).await?; + let response = client + .get_provider_profile(GetProviderProfileRequest { + id: id.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()?; + let profile = response + .into_inner() + .profile + .ok_or_else(|| miette!("provider profile '{id}' not found"))?; + print!( + "{}", + render_profile_describe(&ProviderTypeProfile::from_proto(&profile)) + ); + Ok(()) +} + +/// Format a profile for `profile describe` table output. +/// +/// Returned as a string rather than printed so tests can assert on the exact +/// rendering without capturing stdout. +fn render_profile_describe(profile: &ProviderTypeProfile) -> String { + use std::fmt::Write as _; + + let mut out = String::new(); + let _ = writeln!(out, "{} ({PROVIDER_PROFILE_TYPE})", profile.id); + if !profile.display_name.is_empty() { + let _ = writeln!(out, "{}", profile.display_name); + } + if !profile.description.is_empty() { + let _ = writeln!(out, "{}", profile.description); + } + + let _ = writeln!(out); + let _ = writeln!( + out, + "Category: {}", + display_provider_category(profile.category as i32) + ); + + if !profile.credentials.is_empty() { + let _ = writeln!(out, "Credentials:"); + for credential in &profile.credentials { + let env = if credential.env_vars.is_empty() { + String::new() + } else { + format!(" [{}]", credential.env_vars.join(", ")) + }; + let auth = if credential.auth_style.is_empty() { + String::new() + } else { + format!(" ({})", credential.auth_style) + }; + let required = if credential.required { + " required" + } else { + " optional" + }; + let _ = writeln!(out, " {}{env}{auth}{required}", credential.name); + } + } + + if !profile.endpoints.is_empty() { + let _ = writeln!(out, "Endpoints:"); + for endpoint in &profile.endpoints { + let mut attrs = Vec::new(); + for value in [ + endpoint.protocol.as_str(), + endpoint.access.as_str(), + endpoint.enforcement.as_str(), + ] { + if !value.is_empty() { + attrs.push(value); + } + } + let suffix = if attrs.is_empty() { + String::new() + } else { + format!(" ({})", attrs.join(", ")) + }; + let _ = writeln!(out, " {}:{}{suffix}", endpoint.host, endpoint.port); + } + } + + if !profile.binaries.is_empty() { + let paths = profile + .binaries + .iter() + .map(|binary| binary.path.clone()) + .collect::>(); + let _ = writeln!(out, "Binaries: {}", paths.join(", ")); + } + + if profile.inference_capable { + let _ = writeln!(out, "Inference: capable"); + } + if !profile.scope.is_empty() { + let _ = writeln!(out, "Scope: {}", profile.scope); + } + if !profile.source.is_empty() { + let _ = writeln!(out, "Source: {}", profile.source); + } + + out +} + pub async fn provider_profile_export( server: &str, id: &str, @@ -7511,6 +7679,82 @@ fn format_endpoint(endpoint: &openshell_core::proto::NetworkEndpoint) -> String #[cfg(test)] mod tests { + /// Build a proto profile and convert it, so the fixture exercises the same + /// `from_proto` path the command uses rather than a hand-built DTO. + fn describe_fixture() -> ProviderTypeProfile { + let proto = ProviderProfile { + id: "openai".to_string(), + display_name: "OpenAI".to_string(), + description: "OpenAI-compatible inference".to_string(), + credentials: vec![ProviderProfileCredential { + name: "OPENAI_API_KEY".to_string(), + env_vars: vec!["OPENAI_API_KEY".to_string()], + required: true, + auth_style: "bearer".to_string(), + ..Default::default() + }], + endpoints: vec![openshell_core::proto::NetworkEndpoint { + host: "api.openai.com".to_string(), + port: 443, + protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + ..Default::default() + }], + binaries: vec![openshell_core::proto::NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + inference_capable: true, + source: "built-in".to_string(), + ..Default::default() + }; + ProviderTypeProfile::from_proto(&proto) + } + + #[test] + fn render_profile_describe_includes_identity_and_sections() { + let rendered = render_profile_describe(&describe_fixture()); + + assert!(rendered.starts_with("openai (provider)\n"), "{rendered}"); + assert!( + rendered.contains("OpenAI-compatible inference"), + "{rendered}" + ); + assert!(rendered.contains("OPENAI_API_KEY"), "{rendered}"); + assert!(rendered.contains("api.openai.com:443"), "{rendered}"); + assert!(rendered.contains("/usr/bin/curl"), "{rendered}"); + assert!(rendered.contains("built-in"), "{rendered}"); + } + + #[test] + fn render_profile_describe_keeps_full_values_without_ansi() { + let rendered = render_profile_describe(&describe_fixture()); + + assert!( + !rendered.contains('\u{1b}'), + "describe output must not contain ANSI escapes: {rendered}" + ); + assert!( + !rendered.contains('\u{2026}'), + "describe output must not truncate values: {rendered}" + ); + } + + #[test] + fn render_profile_describe_omits_empty_sections() { + let profile = ProviderTypeProfile::from_proto(&ProviderProfile { + id: "bare".to_string(), + ..Default::default() + }); + let rendered = render_profile_describe(&profile); + + assert!(rendered.starts_with("bare (provider)\n"), "{rendered}"); + assert!(!rendered.contains("Credentials:"), "{rendered}"); + assert!(!rendered.contains("Endpoints:"), "{rendered}"); + assert!(!rendered.contains("Binaries:"), "{rendered}"); + assert!(!rendered.contains("Inference:"), "{rendered}"); + } + use super::{ PolicyGetView, ProvisioningStep, build_sandbox_resource_limits, dockerfile_sources_supported_for_gateway, format_endpoint, format_log_line, @@ -7519,13 +7763,14 @@ mod tests { parse_credential_expiry_pairs, parse_credential_pairs, parse_driver_config_json, parse_secret_material_env_pairs, policy_revision_to_json, provider_profile_allows_empty_credentials, provisioning_timeout_message, - ready_false_condition_message, refresh_status_header, refresh_status_row, resolve_from, - sandbox_should_persist, sandbox_upload_plan, service_expose_status_error, - service_url_for_gateway, + ready_false_condition_message, refresh_status_header, refresh_status_row, + render_profile_describe, resolve_from, sandbox_should_persist, sandbox_upload_plan, + service_expose_status_error, service_url_for_gateway, }; use crate::TEST_ENV_LOCK; use crate::commands::common::progress_step_from_metadata; use crate::test_utils::EnvVarGuard; + use openshell_providers::ProviderTypeProfile; use std::fs; use std::io::Write; use std::path::Path; diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index 66fc418041..13b2b31016 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -20,9 +20,11 @@ Provider profiles include metadata for known endpoints and binaries. View the available profiles before creating a provider: ```shell -openshell provider list-profiles +openshell profile list ``` +`openshell provider list-profiles` remains available as an alias. + ## Create a Provider Providers can be created from local environment variables or with explicit credential values. diff --git a/docs/sandboxes/providers-v2.mdx b/docs/sandboxes/providers-v2.mdx index 7a0f96b5ac..a889f3103e 100644 --- a/docs/sandboxes/providers-v2.mdx +++ b/docs/sandboxes/providers-v2.mdx @@ -53,8 +53,9 @@ Providers v2 currently includes these user-facing features: - Built-in provider profiles loaded by the gateway by default. - Gateway configuration can compose built-in, user-managed, and interceptor-vended profile sources. Selecting only an interceptor makes its catalog authoritative by omission. -- `openshell provider list-profiles` with table, YAML, and JSON output. -- `openshell provider profile export`, `import`, `update`, `lint`, and `delete` for custom profiles. +- `openshell profile list` with table, YAML, and JSON output, and `openshell profile describe ` for a single profile. +- `openshell profile export`, `import`, `update`, `lint`, and `delete` for custom profiles. +- `openshell provider list-profiles` and `openshell provider profile ...` remain available as aliases for the commands above. - Provider instances created from built-in or imported profile IDs with `openshell provider create --type `. - Provider instances whose submitted credentials can be stored by a configured gateway credential driver. - Profile-backed credential discovery for explicit `openshell provider create --from-existing` and `openshell provider update --from-existing` flows. The built-in `google-vertex-ai` profile also supplements discovery with Vertex config env vars such as `VERTEX_AI_PROJECT_ID` and `VERTEX_AI_REGION`. @@ -231,9 +232,18 @@ A provider profile defines a provider type. It contains metadata, credential dec List available profiles: ```shell -openshell provider list-profiles +openshell profile list ``` +Restrict the listing to a single profile type, or inspect one profile in full: + +```shell +openshell profile list --type provider +openshell profile describe openai +``` + +`openshell provider list-profiles` remains available and behaves identically. + By default the gateway lists built-in profiles plus custom profiles imported through the profile APIs. When a configured gateway interceptor vends an authoritative provider profile catalog, that catalog becomes the visible source