Skip to content

Commit cdb1de5

Browse files
authored
feat(providers): add custom profile registry (#1170)
Add custom profile registry. Allow attaching custom profiles at sandbox start.
1 parent 8594cb7 commit cdb1de5

22 files changed

Lines changed: 2980 additions & 193 deletions

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/openshell-cli/src/main.rs

Lines changed: 204 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use clap_complete::env::CompleteEnv;
99
use miette::Result;
1010
use owo_colors::OwoColorize;
1111
use std::io::Write;
12+
use std::path::PathBuf;
1213

1314
use openshell_bootstrap::{
1415
edge_token::load_edge_token, get_gateway_metadata, list_gateways, load_active_gateway,
@@ -642,18 +643,20 @@ fn normalize_completion_script(output: Vec<u8>, executable: &std::path::Path) ->
642643
}
643644

644645
#[derive(Clone, Debug, ValueEnum)]
645-
enum CliProviderType {
646-
Claude,
647-
Opencode,
648-
Codex,
649-
Copilot,
650-
Generic,
651-
Openai,
652-
Anthropic,
653-
Nvidia,
654-
Gitlab,
655-
Github,
656-
Outlook,
646+
enum ProviderProfileOutput {
647+
Table,
648+
Yaml,
649+
Json,
650+
}
651+
652+
impl ProviderProfileOutput {
653+
fn as_str(&self) -> &'static str {
654+
match self {
655+
Self::Table => "table",
656+
Self::Yaml => "yaml",
657+
Self::Json => "json",
658+
}
659+
}
657660
}
658661

659662
#[derive(Clone, Debug, ValueEnum)]
@@ -671,24 +674,6 @@ impl From<CliEditor> for openshell_cli::ssh::Editor {
671674
}
672675
}
673676

674-
impl CliProviderType {
675-
fn as_str(&self) -> &'static str {
676-
match self {
677-
Self::Claude => "claude",
678-
Self::Opencode => "opencode",
679-
Self::Codex => "codex",
680-
Self::Copilot => "copilot",
681-
Self::Generic => "generic",
682-
Self::Openai => "openai",
683-
Self::Anthropic => "anthropic",
684-
Self::Nvidia => "nvidia",
685-
Self::Gitlab => "gitlab",
686-
Self::Github => "github",
687-
Self::Outlook => "outlook",
688-
}
689-
}
690-
}
691-
692677
#[derive(Subcommand, Debug)]
693678
enum ProviderCommands {
694679
/// Create a provider config.
@@ -699,8 +684,8 @@ enum ProviderCommands {
699684
name: String,
700685

701686
/// Provider type.
702-
#[arg(long = "type", value_enum)]
703-
provider_type: CliProviderType,
687+
#[arg(long = "type")]
688+
provider_type: String,
704689

705690
/// Load provider credentials/config from existing local state.
706691
#[arg(long, conflicts_with = "credentials")]
@@ -745,7 +730,15 @@ enum ProviderCommands {
745730

746731
/// List available provider profiles.
747732
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
748-
ListProfiles,
733+
ListProfiles {
734+
/// Output format.
735+
#[arg(short = 'o', long = "output", value_enum, default_value_t = ProviderProfileOutput::Table)]
736+
output: ProviderProfileOutput,
737+
},
738+
739+
/// Manage provider profiles.
740+
#[command(subcommand, help_template = SUBCOMMAND_HELP_TEMPLATE)]
741+
Profile(ProviderProfileCommands),
749742

750743
/// Update an existing provider's credentials or config.
751744
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
@@ -780,6 +773,51 @@ enum ProviderCommands {
780773
},
781774
}
782775

776+
#[derive(Subcommand, Debug)]
777+
enum ProviderProfileCommands {
778+
/// Export a provider profile.
779+
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
780+
Export {
781+
/// Provider profile id.
782+
id: String,
783+
784+
/// Output format.
785+
#[arg(short = 'o', long = "output", value_enum, default_value_t = ProviderProfileOutput::Yaml)]
786+
output: ProviderProfileOutput,
787+
},
788+
789+
/// Import provider profiles from a file or directory.
790+
#[command(group = clap::ArgGroup::new("source").required(true).args(["file", "from"]), help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
791+
Import {
792+
/// Profile file to import.
793+
#[arg(short = 'f', long = "file", value_hint = ValueHint::FilePath)]
794+
file: Option<PathBuf>,
795+
796+
/// Directory containing profile files to import.
797+
#[arg(long = "from", value_hint = ValueHint::DirPath)]
798+
from: Option<PathBuf>,
799+
},
800+
801+
/// Validate provider profile files without registering them.
802+
#[command(group = clap::ArgGroup::new("source").required(true).args(["file", "from"]), help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
803+
Lint {
804+
/// Profile file to lint.
805+
#[arg(short = 'f', long = "file", value_hint = ValueHint::FilePath)]
806+
file: Option<PathBuf>,
807+
808+
/// Directory containing profile files to lint.
809+
#[arg(long = "from", value_hint = ValueHint::DirPath)]
810+
from: Option<PathBuf>,
811+
},
812+
813+
/// Delete a custom provider profile.
814+
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
815+
Delete {
816+
/// Provider profile id.
817+
id: String,
818+
},
819+
}
820+
783821
// -----------------------------------------------------------------------
784822
// Gateway commands (replaces the old `cluster` / `cluster admin` groups)
785823
// -----------------------------------------------------------------------
@@ -2787,9 +2825,35 @@ async fn main() -> Result<()> {
27872825
} => {
27882826
run::provider_list(endpoint, limit, offset, names, &tls).await?;
27892827
}
2790-
ProviderCommands::ListProfiles => {
2791-
run::provider_list_profiles(endpoint, &tls).await?;
2828+
ProviderCommands::ListProfiles { output } => {
2829+
run::provider_list_profiles(endpoint, output.as_str(), &tls).await?;
27922830
}
2831+
ProviderCommands::Profile(command) => match command {
2832+
ProviderProfileCommands::Export { id, output } => {
2833+
run::provider_profile_export(endpoint, &id, output.as_str(), &tls).await?;
2834+
}
2835+
ProviderProfileCommands::Import { file, from } => {
2836+
run::provider_profile_import(
2837+
endpoint,
2838+
file.as_deref(),
2839+
from.as_deref(),
2840+
&tls,
2841+
)
2842+
.await?;
2843+
}
2844+
ProviderProfileCommands::Lint { file, from } => {
2845+
run::provider_profile_lint(
2846+
endpoint,
2847+
file.as_deref(),
2848+
from.as_deref(),
2849+
&tls,
2850+
)
2851+
.await?;
2852+
}
2853+
ProviderProfileCommands::Delete { id } => {
2854+
run::provider_profile_delete(endpoint, &id, &tls).await?;
2855+
}
2856+
},
27932857
ProviderCommands::Update {
27942858
name,
27952859
from_existing,
@@ -3489,9 +3553,113 @@ mod tests {
34893553
assert!(matches!(
34903554
cli.command,
34913555
Some(Commands::Provider {
3492-
command: Some(ProviderCommands::ListProfiles)
3556+
command: Some(ProviderCommands::ListProfiles {
3557+
output: ProviderProfileOutput::Table
3558+
})
3559+
})
3560+
));
3561+
}
3562+
3563+
#[test]
3564+
fn provider_list_profiles_accepts_output_format() {
3565+
let cli = Cli::try_parse_from(["openshell", "provider", "list-profiles", "-o", "json"])
3566+
.expect("provider list-profiles -o json should parse");
3567+
3568+
assert!(matches!(
3569+
cli.command,
3570+
Some(Commands::Provider {
3571+
command: Some(ProviderCommands::ListProfiles {
3572+
output: ProviderProfileOutput::Json
3573+
})
3574+
})
3575+
));
3576+
}
3577+
3578+
#[test]
3579+
fn provider_profile_commands_parse() {
3580+
let export = Cli::try_parse_from([
3581+
"openshell",
3582+
"provider",
3583+
"profile",
3584+
"export",
3585+
"custom-api",
3586+
"-o",
3587+
"yaml",
3588+
])
3589+
.expect("provider profile export should parse");
3590+
assert!(matches!(
3591+
export.command,
3592+
Some(Commands::Provider {
3593+
command: Some(ProviderCommands::Profile(ProviderProfileCommands::Export {
3594+
id,
3595+
output: ProviderProfileOutput::Yaml
3596+
}))
3597+
}) if id == "custom-api"
3598+
));
3599+
3600+
let import = Cli::try_parse_from([
3601+
"openshell",
3602+
"provider",
3603+
"profile",
3604+
"import",
3605+
"--from",
3606+
"./profiles",
3607+
])
3608+
.expect("provider profile import should parse");
3609+
assert!(matches!(
3610+
import.command,
3611+
Some(Commands::Provider {
3612+
command: Some(ProviderCommands::Profile(ProviderProfileCommands::Import {
3613+
from: Some(_),
3614+
..
3615+
}))
34933616
})
34943617
));
3618+
3619+
let delete =
3620+
Cli::try_parse_from(["openshell", "provider", "profile", "delete", "custom-api"])
3621+
.expect("provider profile delete should parse");
3622+
assert!(matches!(
3623+
delete.command,
3624+
Some(Commands::Provider {
3625+
command: Some(ProviderCommands::Profile(ProviderProfileCommands::Delete {
3626+
id
3627+
}))
3628+
}) if id == "custom-api"
3629+
));
3630+
}
3631+
3632+
#[test]
3633+
fn provider_create_accepts_custom_profile_type_ids() {
3634+
let cli = Cli::try_parse_from([
3635+
"openshell",
3636+
"provider",
3637+
"create",
3638+
"--name",
3639+
"work-github",
3640+
"--type",
3641+
"github-readonly",
3642+
"--credential",
3643+
"GITHUB_TOKEN=token",
3644+
])
3645+
.expect("provider create should parse custom profile ids");
3646+
3647+
match cli.command {
3648+
Some(Commands::Provider {
3649+
command:
3650+
Some(ProviderCommands::Create {
3651+
name,
3652+
provider_type,
3653+
credentials,
3654+
..
3655+
}),
3656+
}) => {
3657+
assert_eq!(name, "work-github");
3658+
assert_eq!(provider_type, "github-readonly");
3659+
assert_eq!(credentials, vec!["GITHUB_TOKEN=token"]);
3660+
}
3661+
other => panic!("expected provider create command, got: {other:?}"),
3662+
}
34953663
}
34963664

34973665
#[test]
@@ -3640,29 +3808,4 @@ mod tests {
36403808
}
36413809
}
36423810
}
3643-
3644-
/// Ensure every provider registered in `ProviderRegistry` has a
3645-
/// corresponding `CliProviderType` variant (and vice-versa).
3646-
/// This test would have caught the missing `Copilot` variant from #707.
3647-
#[test]
3648-
fn cli_provider_types_match_registry() {
3649-
let registry = openshell_providers::ProviderRegistry::new();
3650-
let registry_types: std::collections::BTreeSet<&str> =
3651-
registry.known_types().into_iter().collect();
3652-
3653-
let cli_types: std::collections::BTreeSet<&str> =
3654-
<CliProviderType as ValueEnum>::value_variants()
3655-
.iter()
3656-
.map(CliProviderType::as_str)
3657-
.collect();
3658-
3659-
assert_eq!(
3660-
cli_types,
3661-
registry_types,
3662-
"CliProviderType variants must match ProviderRegistry.known_types(). \
3663-
CLI-only: {:?}, Registry-only: {:?}",
3664-
cli_types.difference(&registry_types).collect::<Vec<_>>(),
3665-
registry_types.difference(&cli_types).collect::<Vec<_>>(),
3666-
);
3667-
}
36683811
}

0 commit comments

Comments
 (0)