Skip to content

CLI API Reference

User-facing commands. Built with Typer and Rich.

Main App

shh.cli.app

Main CLI application entry point.

default_command(ctx, style=None, translate=None)

Record audio and transcribe. Press Enter to stop.

Source code in shh/cli/app.py
@app.callback(invoke_without_command=True)
def default_command(
    ctx: typer.Context,
    style: Annotated[
        TranscriptionStyle | None,
        typer.Option(
            "--style",
            "-s",
            help="Formatting: neutral (raw), casual, or business",
        ),
    ] = None,
    translate: Annotated[
        str | None,
        typer.Option(
            "--translate",
            "-t",
            help="Translate to language (e.g., English, French)",
        ),
    ] = None,
) -> None:
    """Record audio and transcribe. Press Enter to stop."""
    # If a subcommand was invoked, don't run the default
    if ctx.invoked_subcommand is not None:
        return

    # Run the async record command
    asyncio.run(record_command(style=style, translate=translate))

main()

Main entry point for the CLI application.

Source code in shh/cli/app.py
def main() -> None:
    """Main entry point for the CLI application."""
    app()

setup()

Configure OpenAI API key and settings.

Source code in shh/cli/app.py
@app.command(name="setup")
def setup() -> None:
    """Configure OpenAI API key and settings."""
    setup_command()

Setup Command

shh.cli.commands.setup

Setup command for configuring the shh CLI.

setup_command()

Interactive setup to configure OpenAI API key.

Prompts the user for their API key and saves it to the config file.

Source code in shh/cli/commands/setup.py
def setup_command() -> None:
    """
    Interactive setup to configure OpenAI API key.

    Prompts the user for their API key and saves it to the config file.
    """
    console.print("\n[bold]shh Setup[/bold]", style="cyan")
    console.print("Let's configure your OpenAI API key.\n")

    # Prompt for API key (hidden input for security)
    api_key = typer.prompt(
        "Enter your OpenAI API key",
        hide_input=True,  # Don't show the key as they type
    )

    # Validate it's not empty
    if not api_key or not api_key.strip():
        console.print("[red]Error: API key cannot be empty[/red]")
        raise typer.Exit(code=1)

    # Load existing settings or create new ones
    settings = Settings.load_from_file() or Settings()

    # Update the API key
    settings.openai_api_key = api_key.strip()

    # Save to file
    settings.save_to_file()
    config_path = Settings.get_config_path()

    # Success message with details
    success_panel = Panel(
        f"""[green]Configuration saved successfully![/green]

[bold]Config file:[/bold] {config_path}

[bold]Settings:[/bold]
  • OpenAI API Key: sk-***{api_key[-4:]}
  • Default style: {settings.default_style}
  • Show progress: {settings.show_progress}
  • Whisper model: {settings.whisper_model}

[dim]You can now run 'shh' to start recording![/dim]""",
        title="Setup Complete",
        border_style="green",
    )

    console.print(success_panel)

Config Command

shh.cli.commands.config

Config management commands for the shh CLI.

config_edit()

Open configuration file in $EDITOR.

Source code in shh/cli/commands/config.py
@config_app.command(name="edit")
def config_edit() -> None:
    """Open configuration file in $EDITOR."""
    config_path = Settings.get_config_path()

    if not config_path.exists():
        console.print("[yellow]No configuration file found. Run 'shh setup' first.[/yellow]")
        raise typer.Exit(code=1)

    # Get editor from environment
    editor = os.environ.get("EDITOR") or os.environ.get("VISUAL")

    if not editor:
        console.print("[red]Error: No editor configured.[/red]")
        console.print("[dim]Set the EDITOR environment variable (e.g., export EDITOR=vim)[/dim]")
        raise typer.Exit(code=1)

    # Open in editor
    try:
        subprocess.run([editor, str(config_path)], check=True)  # noqa: S603
        console.print("[green]✓ Configuration file updated[/green]")
    except subprocess.CalledProcessError as e:
        console.print(f"[red]Error: Failed to open editor '{editor}'[/red]")
        raise typer.Exit(code=1) from e
    except FileNotFoundError as e:
        console.print(f"[red]Error: Editor '{editor}' not found[/red]")
        raise typer.Exit(code=1) from e

config_get(key)

Get a single configuration value.

Parameters:

Name Type Description Default
key str

The setting key to retrieve (e.g., 'default_style')

required
Source code in shh/cli/commands/config.py
@config_app.command(name="get")
def config_get(key: str) -> None:
    """Get a single configuration value.

    Args:
        key: The setting key to retrieve (e.g., 'default_style')
    """
    settings = Settings.load_from_file()

    if not settings:
        console.print("[red]No configuration found. Run 'shh setup' first.[/red]")
        raise typer.Exit(code=1)

    # Get the value
    try:
        value = getattr(settings, key)

        # Mask API key
        if key == "openai_api_key" and value:
            value = f"sk-***{value[-4:]}"

        console.print(f"{key}: {value}")
    except AttributeError as e:
        console.print(f"[red]Error: Unknown setting '{key}'[/red]")
        console.print(f"[dim]Valid keys: {', '.join(VALID_KEYS.keys())}, openai_api_key[/dim]")
        raise typer.Exit(code=1) from e

config_reset()

Reset configuration to defaults (keeps API key).

Source code in shh/cli/commands/config.py
@config_app.command(name="reset")
def config_reset() -> None:
    """Reset configuration to defaults (keeps API key)."""
    settings = Settings.load_from_file()

    if not settings:
        console.print("[yellow]No configuration found.[/yellow]")
        raise typer.Exit(code=1)

    # Confirm with user
    confirm = typer.confirm(
        "Reset all settings to defaults? (API key will be preserved)",
        default=False,
    )

    if not confirm:
        console.print("[yellow]Reset cancelled.[/yellow]")
        raise typer.Exit(code=0)

    # Save the API key
    api_key = settings.openai_api_key

    # Create new defaults
    settings = Settings()
    settings.openai_api_key = api_key

    # Save
    settings.save_to_file()

    console.print("[green]✓ Configuration reset to defaults[/green]")
    console.print(f"[dim]API key preserved: sk-***{api_key[-4:] if api_key else 'None'}[/dim]")

config_set(key, value)

Update a configuration setting.

Parameters:

Name Type Description Default
key str

The setting key to update

required
value str

The new value

required
Source code in shh/cli/commands/config.py
@config_app.command(name="set")
def config_set(key: str, value: str) -> None:
    """Update a configuration setting.

    Args:
        key: The setting key to update
        value: The new value
    """
    settings = Settings.load_from_file() or Settings()

    # Validate key
    if key == "openai_api_key":
        console.print("[yellow]Use 'shh setup' to update API key.[/yellow]")
        raise typer.Exit(code=1)

    if key not in VALID_KEYS:
        console.print(f"[red]Error: Unknown setting '{key}'[/red]")
        console.print(f"[dim]Valid keys: {', '.join(VALID_KEYS.keys())}[/dim]")
        raise typer.Exit(code=1)

    # Validate and convert value
    typed_value: TranscriptionStyle | WhisperModel | bool | str | None

    if key == "default_style":
        try:
            typed_value = TranscriptionStyle(value)
        except ValueError as e:
            console.print(f"[red]Error: Invalid style '{value}'[/red]")
            valid_styles = [s.value for s in TranscriptionStyle]
            console.print(f"[dim]Valid styles: {', '.join(valid_styles)}[/dim]")
            raise typer.Exit(code=1) from e
    elif key == "default_translation_language":
        # Support clearing the value with special strings
        typed_value = None if value.lower() in ("none", "null", "") else value
    elif key == "show_progress":
        if value.lower() not in ("true", "false"):
            console.print("[red]Error: show_progress must be 'true' or 'false'[/red]")
            raise typer.Exit(code=1)
        typed_value = value.lower() == "true"
    elif key == "whisper_model":
        try:
            typed_value = WhisperModel(value)
        except ValueError as e:
            console.print(f"[red]Error: Invalid model '{value}'[/red]")
            valid_models = [m.value for m in WhisperModel]
            console.print(f"[dim]Valid models: {', '.join(valid_models)}[/dim]")
            raise typer.Exit(code=1) from e
    else:
        typed_value = value

    # Update setting
    setattr(settings, key, typed_value)
    settings.save_to_file()

    console.print(f"[green]✓ Updated {key} = {typed_value}[/green]")

config_show()

Display current configuration settings.

Source code in shh/cli/commands/config.py
@config_app.command(name="show")
def config_show() -> None:
    """Display current configuration settings."""
    settings = Settings.load_from_file()

    if not settings:
        console.print("[yellow]No configuration found. Run 'shh setup' first.[/yellow]")
        raise typer.Exit(code=1)

    # Create a table for settings
    table = Table(title="Configuration Settings", show_header=True, header_style="bold cyan")
    table.add_column("Setting", style="cyan", no_wrap=True)
    table.add_column("Value", style="green")

    # Mask API key
    api_key_display = (
        f"sk-***{settings.openai_api_key[-4:]}"
        if settings.openai_api_key
        else "[red]Not configured[/red]"
    )

    table.add_row("openai_api_key", api_key_display)
    table.add_row("default_style", str(settings.default_style))
    table.add_row(
        "default_translation_language",
        settings.default_translation_language or "[dim]None[/dim]",
    )
    table.add_row("show_progress", str(settings.show_progress))
    table.add_row("whisper_model", str(settings.whisper_model))
    table.add_row("default_output", ", ".join(settings.default_output))

    config_path = Settings.get_config_path()
    console.print()
    console.print(table)
    console.print(f"\n[dim]Config file: {config_path}[/dim]")

config_wizard()

Interactive configuration wizard.

Source code in shh/cli/commands/config.py
@config_app.command(name="wizard")
def config_wizard() -> None:
    """Interactive configuration wizard."""
    console.print("\n[bold cyan]Configuration Wizard[/bold cyan]\n")

    # Load or create settings
    settings = Settings.load_from_file() or Settings()

    # API Key (mask if exists)
    if settings.openai_api_key:
        console.print(f"[dim]Current API key: sk-***{settings.openai_api_key[-4:]}[/dim]")
        change_key = typer.confirm("Change API key?", default=False)
        if change_key:
            api_key = typer.prompt("OpenAI API key", hide_input=True)
            settings.openai_api_key = api_key
    else:
        api_key = typer.prompt("OpenAI API key", hide_input=True)
        settings.openai_api_key = api_key

    # Default style
    console.print("\n[cyan]Formatting Style[/cyan]")
    console.print("  neutral  - Raw Whisper output")
    console.print("  casual   - Conversational, removes filler words")
    console.print("  business - Professional, formal tone")
    style_input = typer.prompt(
        "Default style",
        default=settings.default_style.value,
        show_default=True,
    )
    try:
        settings.default_style = TranscriptionStyle(style_input)
    except ValueError:
        console.print("[yellow]Invalid style, keeping current value[/yellow]")

    # Default translation language
    console.print("\n[cyan]Translation[/cyan]")
    current_lang = settings.default_translation_language or "None"
    console.print(f"[dim]Current: {current_lang}[/dim]")
    lang_input = typer.prompt(
        "Default translation language (or 'none' to disable)",
        default=current_lang,
        show_default=False,
    )
    if lang_input.lower() in ("none", "null", ""):
        settings.default_translation_language = None
    else:
        settings.default_translation_language = lang_input

    # Show progress
    console.print("\n[cyan]Display Options[/cyan]")
    settings.show_progress = typer.confirm(
        "Show recording progress?",
        default=settings.show_progress,
    )

    # Save
    settings.save_to_file()
    console.print("\n[green]✓ Configuration saved[/green]")
    console.print(f"[dim]Location: {Settings.get_config_path()}[/dim]\n")

Record Command

shh.cli.commands.record

Recording command for the shh CLI.

record_command(style=None, translate=None) async

Record audio, transcribe, and optionally format/translate.

Parameters:

Name Type Description Default
style TranscriptionStyle | None

Formatting style to apply (overrides config default)

None
translate str | None

Target language for translation

None
Source code in shh/cli/commands/record.py
async def record_command(
    style: TranscriptionStyle | None = None,
    translate: str | None = None,
) -> None:
    """
    Record audio, transcribe, and optionally format/translate.

    Args:
        style: Formatting style to apply (overrides config default)
        translate: Target language for translation
    """
    # Load settings
    settings = Settings.load_from_file()
    if not settings or not settings.openai_api_key:
        console.print("[red]Error: No API key found.[/red]")
        console.print("[dim]Run 'shh setup' to configure your OpenAI API key.[/dim]")
        sys.exit(1)

    # Use provided options or fall back to config defaults
    formatting_style = style if style is not None else settings.default_style
    target_language = translate if translate is not None else settings.default_translation_language

    # Step 1: Recording
    console.print()

    try:
        async with AudioRecorder() as recorder:
            # Start waiting for Enter in background
            enter_task = asyncio.create_task(wait_for_enter())

            # Show live progress
            with Live(auto_refresh=False, console=console) as live:
                while not enter_task.done() and not recorder.is_max_duration_reached():
                    elapsed = recorder.elapsed_time()
                    max_duration = recorder._max_duration

                    # Create progress text
                    progress = Text()
                    progress.append("Recording... ", style="bold green")
                    progress.append(f"{elapsed:.1f}s ", style="cyan")
                    progress.append(f"/ {max_duration:.0f}s ", style="dim")
                    progress.append("[Press Enter to stop]", style="dim")

                    live.update(progress)
                    live.refresh()
                    await asyncio.sleep(0.1)

                # Check if max duration reached
                if recorder.is_max_duration_reached():
                    console.print(
                        "\n[yellow]Maximum recording duration reached (5 minutes)[/yellow]"
                    )

            # Cancel Enter task if we hit max duration
            if not enter_task.done():
                enter_task.cancel()
                with contextlib.suppress(asyncio.CancelledError):
                    await enter_task

            # Get recorded audio
            audio_data = recorder.get_audio()

    except KeyboardInterrupt:
        console.print("\n[yellow]Recording cancelled.[/yellow]")
        sys.exit(130)  # Standard exit code for Ctrl+C

    # Check if we got any audio
    if len(audio_data) == 0:
        console.print("[yellow]No audio recorded.[/yellow]")
        sys.exit(1)

    # Step 2: Save to WAV
    console.print("\n[cyan]Saving audio...[/cyan]")
    audio_file_path = save_audio_to_wav(audio_data)

    try:
        # Step 3: Transcribe
        console.print("[cyan]Transcribing with Whisper...[/cyan]")
        transcription = await transcribe_audio(
            audio_file_path=audio_file_path,
            api_key=settings.openai_api_key,
        )

        # Step 4: Format/Translate (if requested)
        if formatting_style != TranscriptionStyle.NEUTRAL or target_language:
            if target_language:
                console.print(f"[cyan]Formatting and translating to {target_language}...[/cyan]")
            else:
                console.print(f"[cyan]Formatting ({formatting_style})...[/cyan]")

            formatted = await format_transcription(
                transcription,
                style=formatting_style,
                api_key=settings.openai_api_key,
                target_language=target_language,
            )
            final_text = formatted.text
        else:
            final_text = transcription

        # Step 5: Copy to clipboard
        clipboard_success = True
        try:
            pyperclip.copy(final_text)
        except Exception as e:
            clipboard_success = False
            console.print(f"[yellow]Warning: Could not copy to clipboard: {e}[/yellow]")

        # Step 6: Display result
        console.print()
        result_panel = Panel(
            final_text,
            title="Transcription" + (" (copied to clipboard)" if clipboard_success else ""),
            border_style="green" if clipboard_success else "yellow",
        )
        console.print(result_panel)
        console.print()

    finally:
        # Cleanup temp file
        audio_file_path.unlink(missing_ok=True)

wait_for_enter() async

Wait for user to press Enter (runs in thread pool).

Source code in shh/cli/commands/record.py
async def wait_for_enter() -> None:
    """Wait for user to press Enter (runs in thread pool)."""
    loop = asyncio.get_running_loop()
    await loop.run_in_executor(None, sys.stdin.readline)