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

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 

11 

12 

13class ExecutableNotFoundError(Exception): 

14 """Raised when executable cannot be found in system PATH.""" 

15 pass 

16 

17 

18 

19app = typer.Typer() 

20env = os.environ.copy() 

21console = Console() 

22#TODO: get a list of availbale models from aws cli 

23 

24 

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() 

75 

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 

89 

90 show_welcome_logo(console=console) 

91 

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 } 

100 

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 ) 

109 

110 typer.echo("Opening the AWS SSO wizard. You can accept the defaults unless your team specifies otherwise.") 

111 

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) 

115 

116 typer.secho("Step 2/3 — Configuring models...", fg=typer.colors.BLUE) 

117 

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}") 

123 

124 # Get custom style from config manager 

125 custom_style = get_style(config_manager.get_custom_style()) 

126 

127 use_existing = inquirer.confirm( 

128 message="Use existing model configuration?", 

129 default=True, 

130 style=custom_style 

131 ).execute() 

132 

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 ) 

148 

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() 

159 

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() 

170 

171 model_map = {id:arn for id,arn in zip(model_ids,model_arns)} 

172 

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 ) 

187 

188 # Get custom style from config manager 

189 custom_style = get_style(config_manager.get_custom_style()) 

190 

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() 

201 

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() 

212 

213 model_map = {id:arn for id,arn in zip(model_ids,model_arns)} 

214 

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 ) 

222 

223 typer.echo(f"Default model: {model_id_default}") 

224 typer.echo(f"Small/Fast model: {model_id_fast}") 

225 

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 ) 

235 

236 typer.echo(f"""default model: {model_id_default}\n small/fast model: {model_id_fast}\n""") 

237 

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) 

256 

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}") 

260 

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") 

266 

267 console.print(dedent(""" 

268 [bold]Welcome to CLAUTH[/bold] 

269 Let’s set up your environment for Claude Code on Amazon Bedrock. 

270 

271 Prerequisites: 

272 • AWS CLI v2 

273 • Claude Code CLI 

274 

275 Tip: run [bold]clauth init --help[/bold] to view options. 

276 """).strip()) 

277 

278 

279def clear_screen(): 

280 os.system('cls' if os.name=='nt' else 'clear') 

281 

282 

283def get_app_path(exe_name: str = 'claude') -> str: 

284 """Find the full path to an executable in a cross-platform way. 

285 

286 On Windows, prefers .cmd and .exe versions when multiple variants exist, 

287 matching the original behavior that selected the .cmd version specifically. 

288 

289 Args: 

290 exe_name: Name of the executable to find 

291 

292 Returns: 

293 Full path to the executable 

294 

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}') 

301 

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.') 

306 

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 

316 

317 typer.echo(f"Using executable: {claude_path}") 

318 return claude_path 

319 

320 

321 

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() 

331 

332 if profile is not None: 

333 config.aws.profile = profile 

334 if region is not None: 

335 config.aws.region = region 

336 

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) 

346 

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) 

351 

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 }) 

361 

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) 

378 

379 

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() 

389 

390 if profile is not None: 

391 config.aws.profile = profile 

392 if region is not None: 

393 config.aws.region = region 

394 

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.") 

397 

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) 

408 

409 

410 

411 

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 

422 

423 

424# Configuration management command group 

425config_app = typer.Typer(help="Configuration management commands") 

426app.add_typer(config_app, name="config") 

427 

428 

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. 

435 

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) 

442 

443 console.print("\n[bold cyan]CLAUTH Configuration[/bold cyan]") 

444 

445 if profile: 

446 console.print(f"[bold]Profile:[/bold] {profile}") 

447 else: 

448 console.print("[bold]Profile:[/bold] default") 

449 

450 if show_path: 

451 config_file = config_manager._get_config_file(profile) 

452 console.print(f"[bold]Config File:[/bold] {config_file}") 

453 

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}") 

461 

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'}") 

466 

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}") 

472 

473 

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) 

483 

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) 

489 

490 section, setting = key_parts 

491 

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) 

505 

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) 

516 

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) 

531 

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) 

535 

536 # Save the updated configuration 

537 config_manager._config = config 

538 config_manager.save(profile) 

539 

540 profile_text = f" (profile: {profile})" if profile else "" 

541 typer.secho(f"Set {key} = {value}{profile_text}", fg=typer.colors.GREEN) 

542 

543 except Exception as e: 

544 typer.secho(f"Error: Failed to set configuration: {e}", fg=typer.colors.RED) 

545 raise typer.Exit(1) 

546 

547 

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 "" 

555 

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) 

560 

561 config_manager = get_config_manager() 

562 

563 # Create new default configuration 

564 default_config = ClauthConfig() 

565 config_manager._config = default_config 

566 config_manager.save(profile) 

567 

568 typer.secho(f"Configuration reset to defaults{profile_text}", fg=typer.colors.GREEN) 

569 

570 

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() 

576 

577 if not profiles: 

578 console.print("[yellow]No configuration profiles found.[/yellow]") 

579 return 

580 

581 console.print("\n[bold cyan]Configuration Profiles:[/bold cyan]") 

582 for profile in profiles: 

583 console.print(f" • {profile}") 

584 

585 

586if __name__ == "__main__": 

587 app()