@@ -9,6 +9,7 @@ use clap_complete::env::CompleteEnv;
99use miette:: Result ;
1010use owo_colors:: OwoColorize ;
1111use std:: io:: Write ;
12+ use std:: path:: PathBuf ;
1213
1314use 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 ) ]
693678enum 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