Coverage for src\clauth\cli.py: 19%
264 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-18 10:09 -0400
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-18 10:09 -0400
1import typer
2import subprocess
3import os
4import shutil
5import clauth.aws_utils as aws
6from clauth.config import get_config_manager, ClauthConfig
7from InquirerPy import inquirer
8from textwrap import dedent
9from rich.console import Console
10from InquirerPy import get_style
13class ExecutableNotFoundError(Exception):
14 """Raised when executable cannot be found in system PATH."""
15 pass
19app = typer.Typer()
20env = os.environ.copy()
21console = Console()
22#TODO: get a list of availbale models from aws cli
25@app.command(
26 help=(
27 "First-time setup for CLAUTH: creates an SSO session, links an AWS profile, "
28 "runs the AWS SSO wizard, logs you in, and optionally launches the Claude CLI."
29 )
30)
31def init(
32 profile: str = typer.Option(
33 None,
34 "--profile",
35 "-p",
36 help="AWS profile to create or update (saved under [profile <name>] in ~/.aws/config).",
37 rich_help_panel="AWS Profile",
38 ),
39 session_name: str = typer.Option(
40 None,
41 "--session-name",
42 "-s",
43 help="Name of the SSO session to create (saved under [sso-session <name>] in ~/.aws/config).",
44 rich_help_panel="AWS SSO",
45 ),
46 sso_start_url: str = typer.Option(
47 None,
48 "--sso-start-url",
49 help="IAM Identity Center (SSO) Start URL (e.g., https://d-…awsapps.com/start/).",
50 rich_help_panel="AWS SSO",
51 ),
52 sso_region: str = typer.Option(
53 None,
54 "--sso-region",
55 help="Region that hosts your IAM Identity Center (SSO) instance.",
56 rich_help_panel="AWS SSO",
57 ),
58 region: str = typer.Option(
59 None,
60 "--region",
61 "-r",
62 help="Default AWS client region for this profile (used for STS/Bedrock calls).",
63 rich_help_panel="AWS Profile",
64 ),
65 auto_start: bool = typer.Option(
66 None,
67 "--auto-start/--no-auto-start",
68 help="Launch the Claude CLI immediately after successful setup.",
69 rich_help_panel="Behavior",
70 ),
71 ):
72 # Load configuration and apply CLI overrides
73 config_manager = get_config_manager()
74 config = config_manager.load()
76 # Override config with CLI parameters if provided
77 if profile is not None:
78 config.aws.profile = profile
79 if session_name is not None:
80 config.aws.session_name = session_name
81 if sso_start_url is not None:
82 config.aws.sso_start_url = sso_start_url
83 if sso_region is not None:
84 config.aws.sso_region = sso_region
85 if region is not None:
86 config.aws.region = region
87 if auto_start is not None:
88 config.cli.auto_start = auto_start
90 show_welcome_logo(console=console)
92 args = {
93 "sso_start_url": config.aws.sso_start_url,
94 "sso_region": config.aws.sso_region,
95 "region": config.aws.sso_region,
96 'output': config.aws.output_format,
97 'sso_session':'claude-auth',
98 'sso_session.session_name.name': config.aws.session_name
99 }
101 try:
102 typer.secho("Step 1/3 — Configuring AWS SSO profile...",fg=typer.colors.BLUE)
103 # Setup the default profile entries for better UX
104 for arg, value in args.items():
105 subprocess.run(
106 ["aws", "configure", "set", arg, value, "--profile", config.aws.profile],
107 check=True,
108 )
110 typer.echo("Opening the AWS SSO wizard. You can accept the defaults unless your team specifies otherwise.")
112 subprocess.run(["aws", "configure", "sso", "--profile", config.aws.profile], check=True)
113 subprocess.run(["aws", "sso", "login", "--profile", config.aws.profile])
114 typer.secho(f"SSO login successful for profile '{config.aws.profile}'.", fg=typer.colors.GREEN)
116 typer.secho("Step 2/3 — Configuring models...", fg=typer.colors.BLUE)
118 # Check if we have existing model configuration
119 if config.models.default_model_arn and config.models.fast_model_arn:
120 typer.echo(f"Found existing model configuration:")
121 typer.echo(f" Default model: {config.models.default_model}")
122 typer.echo(f" Small/Fast model: {config.models.fast_model}")
124 # Get custom style from config manager
125 custom_style = get_style(config_manager.get_custom_style())
127 use_existing = inquirer.confirm(
128 message="Use existing model configuration?",
129 default=True,
130 style=custom_style
131 ).execute()
133 if use_existing:
134 model_id_default = config.models.default_model
135 model_id_fast = config.models.fast_model
136 model_map = {
137 model_id_default: config.models.default_model_arn,
138 model_id_fast: config.models.fast_model_arn
139 }
140 typer.echo(f"Using saved models: {model_id_default}, {model_id_fast}")
141 else:
142 # Re-discover and select models
143 model_ids, model_arns = aws.list_bedrock_profiles(
144 profile=config.aws.profile,
145 region=config.aws.region,
146 provider=config.models.provider_filter
147 )
149 model_id_default = inquirer.select(
150 message="Select your [default] model:",
151 instruction="↑↓ move • Enter select",
152 pointer="❯",
153 amark="✔",
154 choices=model_ids,
155 default=config.models.default_model if config.models.default_model in model_ids else (model_ids[0] if model_ids else None),
156 style=custom_style,
157 max_height="100%"
158 ).execute()
160 model_id_fast = inquirer.select(
161 message="Select your [small/fast] model (you can choose the same as default):",
162 instruction="↑↓ move • Enter select",
163 pointer="❯",
164 amark="✔",
165 choices=model_ids,
166 default=config.models.fast_model if config.models.fast_model in model_ids else (model_ids[-1] if model_ids else None),
167 style=custom_style,
168 max_height="100%"
169 ).execute()
171 model_map = {id:arn for id,arn in zip(model_ids,model_arns)}
173 # Save updated model selections to configuration
174 config_manager.update_model_settings(
175 default_model=model_id_default,
176 fast_model=model_id_fast,
177 default_arn=model_map[model_id_default],
178 fast_arn=model_map[model_id_fast]
179 )
180 else:
181 # No existing configuration, do full model discovery and selection
182 model_ids, model_arns = aws.list_bedrock_profiles(
183 profile=config.aws.profile,
184 region=config.aws.region,
185 provider=config.models.provider_filter
186 )
188 # Get custom style from config manager
189 custom_style = get_style(config_manager.get_custom_style())
191 model_id_default = inquirer.select(
192 message="Select your [default] model:",
193 instruction="↑↓ move • Enter select",
194 pointer="❯",
195 amark="✔",
196 choices=model_ids,
197 default=model_ids[0] if model_ids else None,
198 style=custom_style,
199 max_height="100%"
200 ).execute()
202 model_id_fast = inquirer.select(
203 message="Select your [small/fast] model (you can choose the same as default):",
204 instruction="↑↓ move • Enter select",
205 pointer="❯",
206 amark="✔",
207 choices=model_ids,
208 default=model_ids[-1] if model_ids else None,
209 style=custom_style,
210 max_height="100%"
211 ).execute()
213 model_map = {id:arn for id,arn in zip(model_ids,model_arns)}
215 # Save model selections to configuration
216 config_manager.update_model_settings(
217 default_model=model_id_default,
218 fast_model=model_id_fast,
219 default_arn=model_map[model_id_default],
220 fast_arn=model_map[model_id_fast]
221 )
223 typer.echo(f"Default model: {model_id_default}")
224 typer.echo(f"Small/Fast model: {model_id_fast}")
226 env.update(
227 {
228 "AWS_PROFILE": config.aws.profile,
229 "AWS_REGION": config.aws.region,
230 "CLAUDE_CODE_USE_BEDROCK": "1",
231 "ANTHROPIC_MODEL": model_map[model_id_default],
232 "ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION": model_map[model_id_fast],
233 }
234 )
236 typer.echo(f"""default model: {model_id_default}\n small/fast model: {model_id_fast}\n""")
238 if config.cli.auto_start:
239 typer.secho("Setup complete ✅", fg=typer.colors.GREEN)
240 typer.secho("Step 3/3 — Launching Claude Code...",fg=typer.colors.BLUE)
241 try:
242 claude_path = get_app_path(config.cli.claude_cli_name)
243 clear_screen()
244 subprocess.run([claude_path], env=env, check=True)
245 except ExecutableNotFoundError as e:
246 typer.secho(f"Setup failed: {e}", fg=typer.colors.RED)
247 typer.secho(f"Please install Claude Code CLI and ensure it's in your PATH.", fg=typer.colors.YELLOW)
248 raise typer.Exit(1)
249 except ValueError as e:
250 typer.secho(f"Configuration error: {e}", fg=typer.colors.RED)
251 raise typer.Exit(1)
252 else:
253 typer.secho("Step 3/3 — Setup complete.", fg=typer.colors.GREEN)
254 typer.echo("Run the Claude Code CLI when you're ready: ", nl=False)
255 typer.secho(config.cli.claude_cli_name, bold=True)
257 except subprocess.CalledProcessError as e:
258 typer.secho(f"Setup failed. Exit code: {e.returncode}", fg=typer.colors.RED)
259 exit(f"Failed to setup. Error Code: {e.returncode}")
261def show_welcome_logo(console: Console)->None:
262 logo = """┌─────────────── CLAUTH ───────────────┐
263│ Claude + AWS SSO helper for Bedrock │
264└──────────────────────────────────────┘"""
265 console.print(logo, style="bold cyan")
267 console.print(dedent("""
268 [bold]Welcome to CLAUTH[/bold]
269 Let’s set up your environment for Claude Code on Amazon Bedrock.
271 Prerequisites:
272 • AWS CLI v2
273 • Claude Code CLI
275 Tip: run [bold]clauth init --help[/bold] to view options.
276 """).strip())
279def clear_screen():
280 os.system('cls' if os.name=='nt' else 'clear')
283def get_app_path(exe_name: str = 'claude') -> str:
284 """Find the full path to an executable in a cross-platform way.
286 On Windows, prefers .cmd and .exe versions when multiple variants exist,
287 matching the original behavior that selected the .cmd version specifically.
289 Args:
290 exe_name: Name of the executable to find
292 Returns:
293 Full path to the executable
295 Raises:
296 ExecutableNotFoundError: If executable is not found in PATH
297 ValueError: If executable name is invalid
298 """
299 if not exe_name or not exe_name.strip():
300 raise ValueError(f'Invalid executable name provided: {exe_name!r}')
302 # First, try the basic lookup
303 claude_path = shutil.which(exe_name)
304 if claude_path is None:
305 raise ExecutableNotFoundError(f'{exe_name} not found in system PATH. Please ensure it is installed and in your PATH.')
307 # On Windows, prefer .cmd/.exe versions if they exist (matches original behavior)
308 if os.name == 'nt':
309 preferred_extensions = ['.cmd', '.exe']
310 for ext in preferred_extensions:
311 if not exe_name.lower().endswith(ext):
312 preferred_path = shutil.which(exe_name + ext)
313 if preferred_path:
314 typer.echo(f"Found multiple {exe_name} executables, using: {preferred_path}")
315 return preferred_path
317 typer.echo(f"Using executable: {claude_path}")
318 return claude_path
322@app.command()
323def claude(
324 profile: str = typer.Option(None, "--profile", "-p", help="AWS profile to use"),
325 region: str = typer.Option(None, "--region", "-r", help="AWS region to use")
326):
327 """Launch Claude Code with proper environment variables from saved configuration."""
328 # Load configuration and apply CLI overrides
329 config_manager = get_config_manager()
330 config = config_manager.load()
332 if profile is not None:
333 config.aws.profile = profile
334 if region is not None:
335 config.aws.region = region
337 # Check if user is authenticated
338 if not aws.user_is_authenticated(profile=config.aws.profile):
339 typer.secho("Authentication required. Logging in with AWS SSO...", fg=typer.colors.YELLOW)
340 try:
341 subprocess.run(["aws", "sso", "login", "--profile", config.aws.profile], check=True)
342 typer.secho(f"Successfully authenticated with profile '{config.aws.profile}'", fg=typer.colors.GREEN)
343 except subprocess.CalledProcessError:
344 typer.secho("Authentication failed. Run 'clauth init' for full setup.", fg=typer.colors.RED)
345 raise typer.Exit(1)
347 # Check if model settings are configured
348 if not config.models.default_model_arn or not config.models.fast_model_arn:
349 typer.secho("Model configuration missing. Run 'clauth init' for full setup.", fg=typer.colors.RED)
350 raise typer.Exit(1)
352 # Set up environment variables
353 env = os.environ.copy()
354 env.update({
355 "AWS_PROFILE": config.aws.profile,
356 "AWS_REGION": config.aws.region,
357 "CLAUDE_CODE_USE_BEDROCK": "1",
358 "ANTHROPIC_MODEL": config.models.default_model_arn,
359 "ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION": config.models.fast_model_arn,
360 })
362 # Launch Claude Code
363 typer.secho("Launching Claude Code with Bedrock configuration...", fg=typer.colors.BLUE)
364 try:
365 claude_path = get_app_path(config.cli.claude_cli_name)
366 clear_screen()
367 subprocess.run([claude_path], env=env, check=True)
368 except ExecutableNotFoundError as e:
369 typer.secho(f"Launch failed: {e}", fg=typer.colors.RED)
370 typer.secho("Please install Claude Code CLI and ensure it's in your PATH.", fg=typer.colors.YELLOW)
371 raise typer.Exit(1)
372 except ValueError as e:
373 typer.secho(f"Configuration error: {e}", fg=typer.colors.RED)
374 raise typer.Exit(1)
375 except subprocess.CalledProcessError as e:
376 typer.secho(f"Failed to launch Claude Code. Exit code: {e.returncode}", fg=typer.colors.RED)
377 raise typer.Exit(1)
380@app.command()
381def list_models(
382 profile: str = typer.Option(None, "--profile", "-p", help="AWS profile to use"),
383 region: str = typer.Option(None, "--region", "-r", help="AWS region to use"),
384 show_arn: bool = typer.Option(False, "--show-arn", help="Show model ARNs")
385):
386 # Load configuration and apply CLI overrides
387 config_manager = get_config_manager()
388 config = config_manager.load()
390 if profile is not None:
391 config.aws.profile = profile
392 if region is not None:
393 config.aws.region = region
395 if not aws.user_is_authenticated(profile=config.aws.profile):
396 exit("Credentials are missing or expired. Run `clauth init` to authenticate with AWS.")
398 model_ids, model_arns = aws.list_bedrock_profiles(
399 profile=config.aws.profile,
400 region=config.aws.region,
401 provider=config.models.provider_filter
402 )
403 for model_id, model_arn in zip(model_ids,model_arns):
404 if show_arn:
405 print(model_id , ' --> ', model_arn)
406 else:
407 print(model_id)
412def validate_model_id(id: str):
413 config = get_config_manager().load()
414 model_ids, model_arns = aws.list_bedrock_profiles(
415 profile=config.aws.profile,
416 region=config.aws.region,
417 provider=config.models.provider_filter
418 )
419 if id not in model_ids:
420 raise typer.BadParameter(f'{id} is not valid or supported model. Valid Models: {model_ids}')
421 return id
424# Configuration management command group
425config_app = typer.Typer(help="Configuration management commands")
426app.add_typer(config_app, name="config")
429@config_app.command("show")
430def config_show(
431 profile: str = typer.Option(None, "--profile", help="Show specific profile configuration"),
432 show_path: bool = typer.Option(False, "--path", help="Show configuration file location")
433):
434 """Display current configuration.
436 Shows all configuration settings including AWS, model, and CLI preferences.
437 Use --path to show the location of the configuration file.
438 Use --profile to show configuration for a specific profile.
439 """
440 config_manager = get_config_manager()
441 config = config_manager.load(profile)
443 console.print("\n[bold cyan]CLAUTH Configuration[/bold cyan]")
445 if profile:
446 console.print(f"[bold]Profile:[/bold] {profile}")
447 else:
448 console.print("[bold]Profile:[/bold] default")
450 if show_path:
451 config_file = config_manager._get_config_file(profile)
452 console.print(f"[bold]Config File:[/bold] {config_file}")
454 console.print(f"\n[bold yellow]AWS Settings:[/bold yellow]")
455 console.print(f" Profile: {config.aws.profile}")
456 console.print(f" Region: {config.aws.region}")
457 console.print(f" SSO Start URL: {config.aws.sso_start_url}")
458 console.print(f" SSO Region: {config.aws.sso_region}")
459 console.print(f" Session Name: {config.aws.session_name}")
460 console.print(f" Output Format: {config.aws.output_format}")
462 console.print(f"\n[bold yellow]Model Settings:[/bold yellow]")
463 console.print(f" Provider Filter: {config.models.provider_filter}")
464 console.print(f" Default Model: {config.models.default_model or 'Not set'}")
465 console.print(f" Fast Model: {config.models.fast_model or 'Not set'}")
467 console.print(f"\n[bold yellow]CLI Settings:[/bold yellow]")
468 console.print(f" Claude CLI Name: {config.cli.claude_cli_name}")
469 console.print(f" Auto Start: {config.cli.auto_start}")
470 console.print(f" Show Progress: {config.cli.show_progress}")
471 console.print(f" Color Output: {config.cli.color_output}")
474@config_app.command("set")
475def config_set(
476 key: str = typer.Argument(help="Configuration key (e.g., aws.profile, models.provider_filter)"),
477 value: str = typer.Argument(help="Configuration value"),
478 profile: str = typer.Option(None, "--profile", help="Set value for specific profile")
479):
480 """Set a configuration value."""
481 config_manager = get_config_manager()
482 config = config_manager.load(profile)
484 # Parse the key path (e.g., "aws.profile" -> ["aws", "profile"])
485 key_parts = key.split('.')
486 if len(key_parts) != 2:
487 typer.secho("Error: Key must be in format 'section.setting' (e.g., 'aws.profile')", fg=typer.colors.RED)
488 raise typer.Exit(1)
490 section, setting = key_parts
492 # Validate and set the configuration value
493 try:
494 if section == "aws":
495 if hasattr(config.aws, setting):
496 # Convert string values to appropriate types
497 if setting in ["profile", "region", "sso_start_url", "sso_region", "session_name", "output_format"]:
498 setattr(config.aws, setting, value)
499 else:
500 typer.secho(f"Error: Unknown AWS setting '{setting}'", fg=typer.colors.RED)
501 raise typer.Exit(1)
502 else:
503 typer.secho(f"Error: Unknown AWS setting '{setting}'", fg=typer.colors.RED)
504 raise typer.Exit(1)
506 elif section == "models":
507 if hasattr(config.models, setting):
508 if setting in ["provider_filter", "default_model", "fast_model", "default_model_arn", "fast_model_arn"]:
509 setattr(config.models, setting, value)
510 else:
511 typer.secho(f"Error: Unknown model setting '{setting}'", fg=typer.colors.RED)
512 raise typer.Exit(1)
513 else:
514 typer.secho(f"Error: Unknown model setting '{setting}'", fg=typer.colors.RED)
515 raise typer.Exit(1)
517 elif section == "cli":
518 if hasattr(config.cli, setting):
519 if setting == "claude_cli_name":
520 setattr(config.cli, setting, value)
521 elif setting in ["auto_start", "show_progress", "color_output"]:
522 # Convert to boolean
523 bool_value = value.lower() in ('true', '1', 'yes', 'on')
524 setattr(config.cli, setting, bool_value)
525 else:
526 typer.secho(f"Error: Unknown CLI setting '{setting}'", fg=typer.colors.RED)
527 raise typer.Exit(1)
528 else:
529 typer.secho(f"Error: Unknown CLI setting '{setting}'", fg=typer.colors.RED)
530 raise typer.Exit(1)
532 else:
533 typer.secho(f"Error: Unknown configuration section '{section}'. Valid sections: aws, models, cli", fg=typer.colors.RED)
534 raise typer.Exit(1)
536 # Save the updated configuration
537 config_manager._config = config
538 config_manager.save(profile)
540 profile_text = f" (profile: {profile})" if profile else ""
541 typer.secho(f"Set {key} = {value}{profile_text}", fg=typer.colors.GREEN)
543 except Exception as e:
544 typer.secho(f"Error: Failed to set configuration: {e}", fg=typer.colors.RED)
545 raise typer.Exit(1)
548@config_app.command("reset")
549def config_reset(
550 profile: str = typer.Option(None, "--profile", help="Reset specific profile configuration"),
551 confirm: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt")
552):
553 """Reset configuration to defaults."""
554 profile_text = f" for profile '{profile}'" if profile else ""
556 if not confirm:
557 if not typer.confirm(f"Are you sure you want to reset configuration{profile_text}?"):
558 typer.echo("Configuration reset cancelled.")
559 raise typer.Exit(0)
561 config_manager = get_config_manager()
563 # Create new default configuration
564 default_config = ClauthConfig()
565 config_manager._config = default_config
566 config_manager.save(profile)
568 typer.secho(f"Configuration reset to defaults{profile_text}", fg=typer.colors.GREEN)
571@config_app.command("profiles")
572def config_profiles():
573 """List available configuration profiles."""
574 config_manager = get_config_manager()
575 profiles = config_manager.list_profiles()
577 if not profiles:
578 console.print("[yellow]No configuration profiles found.[/yellow]")
579 return
581 console.print("\n[bold cyan]Configuration Profiles:[/bold cyan]")
582 for profile in profiles:
583 console.print(f" • {profile}")
586if __name__ == "__main__":
587 app()