@@ -100,6 +100,27 @@ def _extract_error_detail(response) -> str:
100100 return response .text
101101
102102
103+ def _resolve_provider_id (identifier : str ) -> str | None :
104+ """Resolve a provider identifier (id or name) to a provider id.
105+
106+ Provider names are unique within a team (enforced by the API), and the CLI
107+ is always team-scoped, so a name resolves to at most one provider. Returns
108+ the provider id, or None if no provider matches the identifier.
109+ """
110+ response = api .get ("/compute_provider/providers/?include_disabled=true" )
111+ if response .status_code != 200 :
112+ return None
113+
114+ providers = response .json ()
115+ for provider in providers :
116+ if provider .get ("id" ) == identifier :
117+ return identifier
118+ for provider in providers :
119+ if provider .get ("name" ) == identifier :
120+ return provider .get ("id" )
121+ return None
122+
123+
103124def _extract_provider_check_reason (result : dict ) -> str :
104125 """Extract a human-readable provider check failure reason."""
105126 reason = result .get ("reason" )
@@ -189,7 +210,8 @@ def _upload_aws_credentials(provider_id: str, access_key_id: str, secret_access_
189210 json_data = {"access_key_id" : access_key_id , "secret_access_key" : secret_access_key },
190211 )
191212 if response .status_code == 200 :
192- console .print ("[success]✓[/success] AWS credentials saved to API host." )
213+ if cli_state .output_format != "json" :
214+ console .print ("[success]✓[/success] AWS credentials saved to API host." )
193215 else :
194216 console .print (f"[error]Error:[/error] Failed to upload AWS credentials. { _extract_error_detail (response )} " )
195217 raise typer .Exit (1 )
@@ -205,68 +227,25 @@ def _upload_gcp_service_account(provider_id: str, service_account_json: str) ->
205227 json_data = {"service_account_json" : service_account_json },
206228 )
207229 if response .status_code == 200 :
208- console .print ("[success]✓[/success] GCP service account saved." )
230+ if cli_state .output_format != "json" :
231+ console .print ("[success]✓[/success] GCP service account saved." )
209232 else :
210233 console .print (f"[error]Error:[/error] Failed to upload GCP service account. { _extract_error_detail (response )} " )
211234 raise typer .Exit (1 )
212235
213236
214- ## COMMANDS ##
215-
216-
217- @app .command ("list" )
218- def command_provider_list (
219- include_disabled : bool = typer .Option (False , "--include-disabled" , help = "Include disabled providers" ),
220- ):
221- """List all compute providers."""
222- check_configs (output_format = cli_state .output_format )
223-
224- with console .status ("[bold success]Fetching providers...[/bold success]" , spinner = "dots" ):
225- response = api .get (f"/compute_provider/providers/?include_disabled={ str (include_disabled ).lower ()} " )
226-
227- if response .status_code == 200 :
228- providers = response .json ()
229- table_columns = ["id" , "name" , "type" , "disabled" , "is_default" , "created_at" , "updated_at" ]
230- render_table (
231- data = providers ,
232- format_type = cli_state .output_format ,
233- table_columns = table_columns ,
234- title = "Providers" ,
235- column_options = {
236- # Keep provider names as a single token (no wrapping to "Skypilot / New").
237- "name" : {"no_wrap" : True , "overflow" : "crop" },
238- },
239- )
240- else :
241- console .print (f"[error]Error:[/error] Failed to fetch providers. { _extract_error_detail (response )} " )
242- raise typer .Exit (1 )
243-
244-
245- @app .command ("add" )
246- def command_provider_add (
247- name : str = typer .Option (None , "--name" , help = "Provider name" ),
248- provider_type : str = typer .Option (
249- None ,
250- "--type" ,
251- help = f"Provider type ({ ', ' .join (PROVIDER_TYPES )} )" ,
252- ),
253- config : str = typer .Option (None , "--config" , help = "Provider config as JSON string" ),
254- interactive : bool = typer .Option (True , "--interactive/--no-interactive" , help = "Use interactive prompts" ),
255- credentials_file : str = typer .Option (
256- None ,
257- "--credentials-file" ,
258- help = (
259- "Path to a JSON file containing provider secrets. Keeps secrets out of argv "
260- "(shell history, ps listings). Shape depends on --type: for aws, "
261- '{"aws_access_key_id": "...", "aws_secret_access_key": "..."}; for gcp, the '
262- "raw service account JSON key file; for everything else, a flat object whose "
263- "fields are merged on top of --config (file values take precedence)."
264- ),
265- ),
266- ):
267- """Add a new compute provider."""
268- check_configs (output_format = cli_state .output_format )
237+ def create_provider_interactively (
238+ name : str | None ,
239+ provider_type : str | None ,
240+ config : str | None ,
241+ interactive : bool ,
242+ credentials_file : str | None ,
243+ ) -> str :
244+ """Resolve provider inputs, create the provider, upload any credentials, and return its id.
269245
246+ Does NOT run the health check — callers run it when appropriate. Raises typer.Exit on any
247+ validation or API failure, printing an error first (matching the CLI's existing behavior).
248+ """
270249 if not interactive :
271250 if not name or not provider_type or config is None :
272251 console .print ("[error]Error:[/error] --name, --type, and --config are required with --no-interactive" )
@@ -300,11 +279,6 @@ def command_provider_add(
300279 else :
301280 config_dict = _prompt_config_fields (provider_type )
302281
303- # --credentials-file: shape depends on provider type.
304- # aws: JSON object with aws_access_key_id / aws_secret_access_key (uploaded via /aws/credentials).
305- # Any remaining keys merge into config.
306- # gcp: the raw service account JSON key file (uploaded via /gcp/credentials).
307- # other: a flat JSON object merged on top of --config (file values win).
308282 aws_access_key_id : str = ""
309283 aws_secret_access_key : str = ""
310284 gcp_service_account_json : str | None = None
@@ -318,7 +292,6 @@ def command_provider_add(
318292 aws_secret_access_key = str (creds .pop ("aws_secret_access_key" , "" ) or "" )
319293 config_dict .update (creds )
320294
321- # AWS access key pair: must be both-or-neither.
322295 if provider_type == "aws" :
323296 if bool (aws_access_key_id ) != bool (aws_secret_access_key ):
324297 console .print (
@@ -336,7 +309,6 @@ def command_provider_add(
336309 )
337310 raise typer .Exit (1 )
338311
339- # GCP service account JSON: required at create time (provider will be unhealthy without it).
340312 if provider_type == "gcp" :
341313 if not gcp_service_account_json and interactive :
342314 gcp_service_account_json = _prompt_gcp_service_account_json ()
@@ -359,22 +331,89 @@ def command_provider_add(
359331
360332 result = response .json ()
361333 provider_id = result .get ("id" , "unknown" )
362- console .print (f"[success]✓[/success] Provider created with ID: [bold]{ provider_id } [/bold]" )
334+ if cli_state .output_format != "json" :
335+ console .print (f"[success]✓[/success] Provider created with ID: [bold]{ provider_id } [/bold]" )
363336
364- # Submit AWS credentials and GCP service account JSON via their dedicated endpoints,
365- # before the health check (the check will fail without credentials).
366337 if provider_type == "aws" and aws_access_key_id and aws_secret_access_key :
367338 _upload_aws_credentials (provider_id , aws_access_key_id , aws_secret_access_key )
368339 if provider_type == "gcp" and gcp_service_account_json :
369340 _upload_gcp_service_account (provider_id , gcp_service_account_json )
370341
342+ return provider_id
343+
344+
345+ ## COMMANDS ##
346+
347+
348+ @app .command ("list" )
349+ def command_provider_list (
350+ include_disabled : bool = typer .Option (False , "--include-disabled" , help = "Include disabled providers" ),
351+ ):
352+ """List all compute providers."""
353+ check_configs (output_format = cli_state .output_format )
354+
355+ with console .status ("[bold success]Fetching providers...[/bold success]" , spinner = "dots" ):
356+ response = api .get (f"/compute_provider/providers/?include_disabled={ str (include_disabled ).lower ()} " )
357+
358+ if response .status_code == 200 :
359+ providers = response .json ()
360+ table_columns = ["id" , "name" , "type" , "disabled" , "is_default" , "created_at" , "updated_at" ]
361+ render_table (
362+ data = providers ,
363+ format_type = cli_state .output_format ,
364+ table_columns = table_columns ,
365+ title = "Providers" ,
366+ column_options = {
367+ # Keep provider names as a single token (no wrapping to "Skypilot / New").
368+ "name" : {"no_wrap" : True , "overflow" : "crop" },
369+ },
370+ )
371+ else :
372+ console .print (f"[error]Error:[/error] Failed to fetch providers. { _extract_error_detail (response )} " )
373+ raise typer .Exit (1 )
374+
375+
376+ @app .command ("add" )
377+ def command_provider_add (
378+ name : str = typer .Option (None , "--name" , help = "Provider name" ),
379+ provider_type : str = typer .Option (
380+ None ,
381+ "--type" ,
382+ help = f"Provider type ({ ', ' .join (PROVIDER_TYPES )} )" ,
383+ ),
384+ config : str = typer .Option (None , "--config" , help = "Provider config as JSON string" ),
385+ interactive : bool = typer .Option (True , "--interactive/--no-interactive" , help = "Use interactive prompts" ),
386+ credentials_file : str = typer .Option (
387+ None ,
388+ "--credentials-file" ,
389+ help = (
390+ "Path to a JSON file containing provider secrets. Keeps secrets out of argv "
391+ "(shell history, ps listings). Shape depends on --type: for aws, "
392+ '{"aws_access_key_id": "...", "aws_secret_access_key": "..."}; for gcp, the '
393+ "raw service account JSON key file; for everything else, a flat object whose "
394+ "fields are merged on top of --config (file values take precedence)."
395+ ),
396+ ),
397+ ):
398+ """Add a new compute provider."""
399+ check_configs (output_format = cli_state .output_format )
400+
401+ provider_id = create_provider_interactively (
402+ name = name ,
403+ provider_type = provider_type ,
404+ config = config ,
405+ interactive = interactive ,
406+ credentials_file = credentials_file ,
407+ )
408+
371409 with console .status (f"[bold success]Checking provider { provider_id } ...[/bold success]" , spinner = "dots" ):
372410 check_response = api .get (f"/compute_provider/providers/{ provider_id } /check" , timeout = 60.0 )
373411
374412 if check_response .status_code == 200 :
375413 check_result = check_response .json ()
376414 if check_result .get ("status" ):
377- console .print ("[success]✓[/success] Provider health check passed." )
415+ if cli_state .output_format != "json" :
416+ console .print ("[success]✓[/success] Provider health check passed." )
378417 else :
379418 reason = _extract_provider_check_reason (check_result )
380419 console .print (f"[error]Error:[/error] Provider health check failed. { reason } " )
@@ -519,14 +558,21 @@ def command_provider_update(
519558
520559@app .command ("delete" )
521560def command_provider_delete (
522- provider_id : str = typer .Argument (..., help = "Provider ID to delete" ),
561+ identifier : str = typer .Argument (..., help = "Provider ID or name to delete" ),
523562 no_interactive : bool = typer .Option (False , "--no-interactive" , help = "Skip confirmation prompt" ),
524563):
525- """Delete a compute provider."""
564+ """Delete a compute provider by ID or name ."""
526565 check_configs (output_format = cli_state .output_format )
527566
567+ # Resolve the identifier (id or name) up front so we only prompt for
568+ # confirmation when the provider actually exists.
569+ provider_id = _resolve_provider_id (identifier )
570+ if provider_id is None :
571+ console .print (f"[error]Error:[/error] Provider { identifier } not found." )
572+ raise typer .Exit (1 )
573+
528574 if not no_interactive :
529- typer .confirm (f"Delete provider { provider_id } ?" , abort = True )
575+ typer .confirm (f"Delete provider { identifier } ?" , abort = True )
530576
531577 with console .status (f"[bold success]Deleting provider { provider_id } ...[/bold success]" , spinner = "dots" ):
532578 response = api .delete (f"/compute_provider/providers/{ provider_id } " )
0 commit comments