"""Studio CLI commands for generation (audio, report, quiz, etc.).""" import typer from rich.progress import Progress, SpinnerColumn, TextColumn from notebooklm_tools.cli.formatters import detect_output_format, get_formatter from notebooklm_tools.cli.utils import get_client, handle_error, make_console from notebooklm_tools.core.alias import get_alias_manager from notebooklm_tools.core.exceptions import NLMError from notebooklm_tools.services import ServiceError, ValidationError from notebooklm_tools.services import studio as studio_service from notebooklm_tools.utils.config import get_default_language console = make_console() # Main studio app for status/delete app = typer.Typer( help="Manage studio artifacts", rich_markup_mode="rich", no_args_is_help=True, ) # Individual generation apps audio_app = typer.Typer( help="Create audio overviews", rich_markup_mode="rich", no_args_is_help=True, ) report_app = typer.Typer( help="Create reports", rich_markup_mode="rich", no_args_is_help=True, ) quiz_app = typer.Typer( help="Create quizzes", rich_markup_mode="rich", no_args_is_help=True, ) flashcards_app = typer.Typer( help="Create flashcards", rich_markup_mode="rich", no_args_is_help=True, ) mindmap_app = typer.Typer( help="Create and manage mind maps", rich_markup_mode="rich", no_args_is_help=True, ) slides_app = typer.Typer( help="Create slide decks", rich_markup_mode="rich", no_args_is_help=True, ) infographic_app = typer.Typer( help="Create infographics", rich_markup_mode="rich", no_args_is_help=True, ) video_app = typer.Typer( help="Create video overviews", rich_markup_mode="rich", no_args_is_help=True, ) data_table_app = typer.Typer( help="Create data tables", rich_markup_mode="rich", no_args_is_help=True, ) def parse_source_ids(source_ids: str | None) -> list[str] | None: """Parse comma-separated source IDs.""" if source_ids: return [get_alias_manager().resolve(s.strip()) for s in source_ids.split(",")] return None def _run_create( notebook_id: str, artifact_type: str, label: str, profile: str | None, **kwargs, ) -> None: """Shared CLI creation logic: spinner + service call + formatted output. CLI-specific concerns (confirmation, arg parsing) happen in each command. This helper handles the common pattern of spinner → create → print result. """ if not kwargs.get("language"): kwargs["language"] = get_default_language() try: notebook_id = get_alias_manager().resolve(notebook_id) with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=console, ) as progress: progress.add_task(f"Creating {label}...", total=None) with get_client(profile) as client: result = studio_service.create_artifact( client, notebook_id, artifact_type, **kwargs, ) # Mind map has a different result shape if artifact_type == "mind_map": console.print("[green]✓[/green] Mind map created") console.print(f" ID: {result.get('artifact_id', 'unknown')}") console.print(f" Title: {result.get('title', 'Mind Map')}") else: console.print(f"[green]✓[/green] {label.title()} generation started") console.print(f" Artifact ID: {result.get('artifact_id', 'unknown')}") console.print(f"\n[dim]Run 'nlm studio status {notebook_id}' to check progress.[/dim]") except (ValidationError, ServiceError) as e: msg = e.user_message if isinstance(e, ServiceError) else str(e) console.print(f"[red]Error:[/red] {msg}") if isinstance(e, ServiceError) and "rejected" in str(e): console.print("[dim]Try again later or create from NotebookLM UI for diagnosis.[/dim]") raise typer.Exit(1) from e except NLMError as e: handle_error(e, json_output=locals().get("json_output", False)) # ========== Studio Status/Delete ========== @app.command("status") def studio_status( notebook_id: str = typer.Argument(..., help="Notebook ID"), full: bool = typer.Option(False, "--full", "-a", help="Show all details"), json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"), profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"), ) -> None: """List all studio artifacts and their status (including mind maps).""" try: notebook_id = get_alias_manager().resolve(notebook_id) with get_client(profile) as client: result = studio_service.get_studio_status(client, notebook_id) fmt = detect_output_format(json_output) formatter = get_formatter(fmt, console) formatter.format_artifacts(result["artifacts"], full=full) except (ServiceError, NLMError) as e: handle_error(e, json_output=locals().get("json_output", False)) @app.command("delete") def studio_delete( notebook_id: str = typer.Argument(..., help="Notebook ID"), artifact_id: str = typer.Argument(..., help="Artifact ID to delete"), confirm: bool = typer.Option(False, "--confirm", "-y", help="Skip confirmation"), profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"), ) -> None: """Delete a studio artifact permanently.""" notebook_id = get_alias_manager().resolve(notebook_id) artifact_id = get_alias_manager().resolve(artifact_id) if not confirm: typer.confirm(f"Are you sure you want to delete artifact {artifact_id}?", abort=True) try: with get_client(profile) as client: studio_service.delete_artifact(client, artifact_id, notebook_id) console.print(f"[green]✓[/green] Deleted artifact: {artifact_id}") except (ServiceError, NLMError) as e: handle_error(e, json_output=locals().get("json_output", False)) @app.command("rename") def studio_rename( artifact_id: str = typer.Argument(..., help="Artifact ID to rename"), new_title: str = typer.Argument(..., help="New title for the artifact"), profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"), ) -> None: """Rename a studio artifact.""" artifact_id = get_alias_manager().resolve(artifact_id) try: with get_client(profile) as client: result = studio_service.rename_artifact(client, artifact_id, new_title) console.print(f"[green]✓[/green] Renamed artifact to: {result['new_title']}") except (ValidationError, ServiceError) as e: handle_error(e) except NLMError as e: handle_error(e) # ========== Audio ========== @audio_app.command("create") def create_audio( notebook_id: str = typer.Argument(..., help="Notebook ID"), format: str = typer.Option( "deep_dive", "--format", "-f", help="Overview format (deep_dive, brief, critique, debate)", ), length: str = typer.Option( "default", "--length", "-l", help="Length (short, default, long)", ), language: str = typer.Option( "", "--language", help="BCP-47 language code (default: NOTEBOOKLM_HL or en)", ), focus: str | None = typer.Option( None, "--focus", help="Optional focus topic", ), source_ids: str | None = typer.Option( None, "--source-ids", "-s", help="Comma-separated source IDs", ), confirm: bool = typer.Option(False, "--confirm", "-y", help="Skip confirmation"), profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"), ) -> None: """Create an audio overview (podcast) from notebook sources.""" if not confirm: typer.confirm(f"Create {format} audio overview?", abort=True) _run_create( notebook_id, "audio", "audio", profile=profile, source_ids=parse_source_ids(source_ids), audio_format=format, audio_length=length, language=language, focus_prompt=focus or "", ) # ========== Report ========== @report_app.command("create") def create_report( notebook_id: str = typer.Argument(..., help="Notebook ID"), format: str = typer.Option( "Briefing Doc", "--format", "-f", help="Format: 'Briefing Doc', 'Study Guide', 'Blog Post', 'Create Your Own'", ), prompt: str = typer.Option( "", "--prompt", help="Custom prompt (required for 'Create Your Own')" ), language: str = typer.Option( "", "--language", help="BCP-47 language code (default: NOTEBOOKLM_HL or en)" ), source_ids: str | None = typer.Option( None, "--source-ids", "-s", help="Comma-separated source IDs" ), confirm: bool = typer.Option(False, "--confirm", "-y", help="Skip confirmation"), profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"), ) -> None: """Create a report from notebook sources.""" if format == "Create Your Own" and not prompt: console.print("[red]Error:[/red] --prompt is required when format is 'Create Your Own'") raise typer.Exit(1) if not confirm: typer.confirm(f"Create '{format}' report?", abort=True) _run_create( notebook_id, "report", "report", profile=profile, source_ids=parse_source_ids(source_ids), report_format=format, custom_prompt=prompt, language=language, ) # ========== Quiz ========== @quiz_app.command("create") def create_quiz( notebook_id: str = typer.Argument(..., help="Notebook ID"), count: int = typer.Option(2, "--count", "-c", help="Number of questions"), difficulty: int = typer.Option(2, "--difficulty", "-d", help="Difficulty 1-5 (1=easy, 5=hard)"), focus: str | None = typer.Option( None, "--focus", "-f", help="Focus prompt to guide generation" ), source_ids: str | None = typer.Option( None, "--source-ids", "-s", help="Comma-separated source IDs" ), confirm: bool = typer.Option(False, "--confirm", "-y", help="Skip confirmation"), profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"), ) -> None: """Create a quiz from notebook sources.""" if not confirm: typer.confirm(f"Create quiz with {count} questions?", abort=True) # Quiz CLI sends raw int codes directly — bypass service string resolution try: notebook_id_resolved = get_alias_manager().resolve(notebook_id) with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=console, ) as progress: progress.add_task("Creating quiz...", total=None) with get_client(profile) as client: result = client.create_quiz( notebook_id_resolved, question_count=count, difficulty=difficulty, source_ids=parse_source_ids(source_ids), focus_prompt=focus or "", ) if not result or not result.get("artifact_id"): console.print( "[red]Error:[/red] NotebookLM rejected quiz creation (no artifact returned)." ) console.print("[dim]Try again later or create from NotebookLM UI for diagnosis.[/dim]") raise typer.Exit(1) console.print("[green]✓[/green] Quiz generation started") console.print(f" Artifact ID: {result.get('artifact_id', 'unknown')}") console.print( f"\n[dim]Run 'nlm studio status {notebook_id_resolved}' to check progress.[/dim]" ) except (ServiceError, NLMError) as e: handle_error(e, json_output=locals().get("json_output", False)) # ========== Flashcards ========== @flashcards_app.command("create") def create_flashcards( notebook_id: str = typer.Argument(..., help="Notebook ID"), difficulty: str = typer.Option( "medium", "--difficulty", "-d", help="Difficulty: easy, medium, hard" ), focus: str | None = typer.Option( None, "--focus", "-f", help="Focus prompt to guide generation" ), source_ids: str | None = typer.Option( None, "--source-ids", "-s", help="Comma-separated source IDs" ), confirm: bool = typer.Option(False, "--confirm", "-y", help="Skip confirmation"), profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"), ) -> None: """Create flashcards from notebook sources.""" if not confirm: typer.confirm("Create flashcards?", abort=True) _run_create( notebook_id, "flashcards", "flashcards", profile=profile, source_ids=parse_source_ids(source_ids), difficulty=difficulty, focus_prompt=focus or "", ) # ========== Mind Map ========== @mindmap_app.command("create") def create_mindmap( notebook_id: str = typer.Argument(..., help="Notebook ID"), title: str = typer.Option("Mind Map", "--title", "-t", help="Mind map title"), source_ids: str | None = typer.Option( None, "--source-ids", "-s", help="Comma-separated source IDs" ), confirm: bool = typer.Option(False, "--confirm", "-y", help="Skip confirmation"), profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"), ) -> None: """Create a mind map from notebook sources.""" if not confirm: typer.confirm("Create mind map?", abort=True) _run_create( notebook_id, "mind_map", "mind map", profile=profile, source_ids=parse_source_ids(source_ids), title=title, ) # Note: mindmap list removed - use 'studio status' which now includes mindmaps # ========== Slides ========== @slides_app.command("create") def create_slides( notebook_id: str = typer.Argument(..., help="Notebook ID"), format: str = typer.Option( "detailed_deck", "--format", "-f", help="Format: detailed_deck, presenter_slides" ), length: str = typer.Option("default", "--length", "-l", help="Length: short, default"), language: str = typer.Option( "", "--language", help="BCP-47 language code (default: NOTEBOOKLM_HL or en)" ), focus: str = typer.Option("", "--focus", help="Optional focus topic"), source_ids: str | None = typer.Option( None, "--source-ids", "-s", help="Comma-separated source IDs" ), confirm: bool = typer.Option(False, "--confirm", "-y", help="Skip confirmation"), profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"), ) -> None: """Create a slide deck from notebook sources.""" if not confirm: typer.confirm("Create slide deck?", abort=True) _run_create( notebook_id, "slide_deck", "slide deck", profile=profile, source_ids=parse_source_ids(source_ids), slide_format=format, slide_length=length, language=language, focus_prompt=focus, ) @slides_app.command("revise") def revise_slides( artifact_id: str = typer.Argument(..., help="Artifact ID of the slide deck to revise"), slide: list[str] = typer.Option( # noqa: B008 ..., "--slide", help='Slide revision in format: SLIDE_NUM "instruction" (e.g., --slide 1 "Make title larger")', ), confirm: bool = typer.Option(False, "--confirm", "-y", help="Skip confirmation"), profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"), ) -> None: """Revise individual slides in an existing slide deck. Creates a NEW slide deck with revisions applied. The original is not modified. Examples: nlm slides revise --slide '1 Make the title larger' --confirm nlm slides revise --slide '1 Make title larger' --slide '3 Remove the image' --confirm """ artifact_id = get_alias_manager().resolve(artifact_id) # Parse --slide arguments: each is "NUMBER instruction text" instructions: list[dict] = [] for s in slide: parts = s.strip().split(None, 1) if len(parts) < 2: console.print( f"[red]Error:[/red] Invalid --slide format: '{s}'. Expected: NUMBER \"instruction\"" ) raise typer.Exit(1) try: slide_num = int(parts[0]) except ValueError: console.print( f"[red]Error:[/red] Invalid slide number: '{parts[0]}'. Must be an integer >= 1." ) raise typer.Exit(1) from None instructions.append({"slide": slide_num, "instruction": parts[1]}) if not confirm: console.print("[bold]Slides to revise:[/bold]") for inst in instructions: console.print(f" Slide {inst['slide']}: {inst['instruction']}") console.print("\n[dim]This creates a NEW slide deck. The original is not modified.[/dim]") typer.confirm("Proceed with revision?", abort=True) try: with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=console, ) as progress: progress.add_task("Revising slide deck...", total=None) with get_client(profile) as client: result = studio_service.revise_artifact( client, artifact_id, instructions, ) console.print("[green]✓[/green] Slide deck revision started") console.print(f" New Artifact ID: {result.get('artifact_id', 'unknown')}") console.print(f" Original: {artifact_id}") console.print("\n[dim]Run 'nlm studio status ' to check progress.[/dim]") except (ValidationError, ServiceError) as e: handle_error(e) except NLMError as e: handle_error(e) # ========== Infographic ========== @infographic_app.command("create") def create_infographic( notebook_id: str = typer.Argument(..., help="Notebook ID"), orientation: str = typer.Option( "landscape", "--orientation", "-o", help="Orientation: landscape, portrait, square" ), detail: str = typer.Option( "standard", "--detail", "-d", help="Detail level: concise, standard, detailed" ), style: str = typer.Option( "auto_select", "--style", help="Visual style: auto_select, sketch_note, professional, bento_grid, editorial, instructional, bricks, clay, anime, kawaii, scientific", ), language: str = typer.Option( "", "--language", help="BCP-47 language code (default: NOTEBOOKLM_HL or en)" ), focus: str = typer.Option("", "--focus", help="Optional focus topic"), source_ids: str | None = typer.Option( None, "--source-ids", "-s", help="Comma-separated source IDs" ), confirm: bool = typer.Option(False, "--confirm", "-y", help="Skip confirmation"), profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"), ) -> None: """Create an infographic from notebook sources.""" if not confirm: typer.confirm("Create infographic?", abort=True) _run_create( notebook_id, "infographic", "infographic", profile=profile, source_ids=parse_source_ids(source_ids), orientation=orientation, detail_level=detail, infographic_style=style, language=language, focus_prompt=focus, ) # ========== Video ========== @video_app.command("create") def create_video( notebook_id: str = typer.Argument(..., help="Notebook ID"), format: str = typer.Option( "explainer", "--format", "-f", help="Format: explainer, brief, cinematic, short" ), style: str = typer.Option( "auto_select", "--style", "-s", help="Visual style: auto_select, custom, classic, whiteboard, kawaii, anime, watercolor, retro_print, heritage, paper_craft", ), style_prompt: str = typer.Option( "", "--style-prompt", help="Custom visual style description. For explainer/brief: implies --style custom. For cinematic/short: mapped to --focus (custom_instructions).", ), language: str = typer.Option( "", "--language", help="BCP-47 language code (default: NOTEBOOKLM_HL or en; short format is English-only for now)", ), focus: str = typer.Option( "", "--focus", help="Focus topic or creative direction. For cinematic/short formats, this is the full steering prompt (visual style, audience, narrative).", ), source_ids: str | None = typer.Option(None, "--source-ids", help="Comma-separated source IDs"), confirm: bool = typer.Option(False, "--confirm", "-y", help="Skip confirmation"), profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"), ) -> None: """Create a video overview from notebook sources.""" if not confirm: typer.confirm("Create video overview?", abort=True) _run_create( notebook_id, "video", "video", profile=profile, source_ids=parse_source_ids(source_ids), video_format=format, visual_style=style, video_style_prompt=style_prompt, language=language, focus_prompt=focus, ) # ========== Data Table ========== @data_table_app.command("create") def create_data_table( notebook_id: str = typer.Argument(..., help="Notebook ID"), description: str = typer.Argument(..., help="Description of the data table to create"), language: str = typer.Option( "", "--language", help="BCP-47 language code (default: NOTEBOOKLM_HL or en)" ), source_ids: str | None = typer.Option( None, "--source-ids", "-s", help="Comma-separated source IDs" ), confirm: bool = typer.Option(False, "--confirm", "-y", help="Skip confirmation"), profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"), ) -> None: """Create a data table from notebook sources.""" if not confirm: typer.confirm("Create data table?", abort=True) _run_create( notebook_id, "data_table", "data table", profile=profile, source_ids=parse_source_ids(source_ids), description=description, language=language, )