Skip to content

CLI API

Command-line interface implementation.


cli

Main CLI entrypoint and command definitions.

cli

Axium CLI - Main command-line interface.

This module provides the Typer-based CLI for Axium, including daemon management, environment controls, command execution with prefix wrapping, HUD status display, and interactive palette.

The CLI automatically bootstraps configuration on first run and loads any installed Spokes to extend functionality.

Example
$ axium daemon start
$ axium env set prod
$ axium run aws s3 ls
$ axium hud

HelpfulGroup

Bases: TyperGroup

Custom Typer group that shows full help on invalid commands.

When a user mistypes a command, instead of just showing an error, displays the full command list to guide them to the correct command.

This improves CLI usability by making Axium self-documenting and reducing friction when users make typos.

Source code in axium/core/cli.py
class HelpfulGroup(TyperGroup):
    """
    Custom Typer group that shows full help on invalid commands.

    When a user mistypes a command, instead of just showing an error,
    displays the full command list to guide them to the correct command.

    This improves CLI usability by making Axium self-documenting and
    reducing friction when users make typos.
    """

    def resolve_command(
        self, ctx: click.Context, args: list[str]
    ) -> tuple[str | None, click.Command | None, list[str]]:
        """
        Override resolve_command to show help on command not found.

        This is called by Click to resolve which command to run.
        If the command is not found, we print an error, show help, and exit.

        Args:
            ctx: Click context containing app state and configuration
            args: Command-line arguments to resolve

        Returns:
            Tuple of (command_name, command_object, remaining_args)

        Raises:
            click.exceptions.UsageError: For errors other than command not found
        """
        try:
            return super().resolve_command(ctx, args)
        except click.exceptions.UsageError as e:
            # Check if it's a "No such command" error
            if "No such command" in str(e):
                # Print error message
                click.echo(f"Error: {e.format_message()}", err=True)
                click.echo()  # Blank line for readability
                # Show full help
                click.echo(ctx.get_help())
                ctx.exit(1)
            # Re-raise other usage errors
            raise

resolve_command(ctx, args)

Override resolve_command to show help on command not found.

This is called by Click to resolve which command to run. If the command is not found, we print an error, show help, and exit.

Parameters:

Name Type Description Default
ctx Context

Click context containing app state and configuration

required
args list[str]

Command-line arguments to resolve

required

Returns:

Type Description
tuple[str | None, Command | None, list[str]]

Tuple of (command_name, command_object, remaining_args)

Raises:

Type Description
UsageError

For errors other than command not found

Source code in axium/core/cli.py
def resolve_command(
    self, ctx: click.Context, args: list[str]
) -> tuple[str | None, click.Command | None, list[str]]:
    """
    Override resolve_command to show help on command not found.

    This is called by Click to resolve which command to run.
    If the command is not found, we print an error, show help, and exit.

    Args:
        ctx: Click context containing app state and configuration
        args: Command-line arguments to resolve

    Returns:
        Tuple of (command_name, command_object, remaining_args)

    Raises:
        click.exceptions.UsageError: For errors other than command not found
    """
    try:
        return super().resolve_command(ctx, args)
    except click.exceptions.UsageError as e:
        # Check if it's a "No such command" error
        if "No such command" in str(e):
            # Print error message
            click.echo(f"Error: {e.format_message()}", err=True)
            click.echo()  # Blank line for readability
            # Show full help
            click.echo(ctx.get_help())
            ctx.exit(1)
        # Re-raise other usage errors
        raise

is_verbose()

Check if verbose mode is enabled.

Returns:

Type Description
bool

True if --verbose flag was passed, False otherwise

Example
from axium.core.cli import is_verbose

if is_verbose():
    print("Detailed operation info...")
Source code in axium/core/cli.py
def is_verbose() -> bool:
    """
    Check if verbose mode is enabled.

    Returns:
        True if --verbose flag was passed, False otherwise

    Example:
        ```python
        from axium.core.cli import is_verbose

        if is_verbose():
            print("Detailed operation info...")
        ```
    """
    return _verbose

info()

Display Axium information and tagline.

Example
$ axium info
Axium core  everything revolves around you.
Source code in axium/core/cli.py
@app.command()
def info() -> None:
    """
    Display Axium information and tagline.

    Example:
        ```bash
        $ axium info
        Axium core — everything revolves around you.
        ```
    """
    print("Axium core — everything revolves around you.")

help()

Show unified help with all commands grouped by category.

Displays all available commands from core, spokes, and gears in a structured, categorized format.

Example
$ axium help
Axium: structure for your terminal

Core Commands:
  bootstrap          Initialize or update Axium configuration
  info               Display Axium information and tagline
  ...

Daemon & System:
  daemon start       Start Axium daemon
  daemon stop        Stop Axium daemon
  ...
Note

This replaces the need to run --help on each command group. For detailed help on a specific command, use: axium --help

Source code in axium/core/cli.py
@app.command()
def help() -> None:
    """
    Show unified help with all commands grouped by category.

    Displays all available commands from core, spokes, and gears
    in a structured, categorized format.

    Example:
        ```bash
        $ axium help
        Axium: structure for your terminal

        Core Commands:
          bootstrap          Initialize or update Axium configuration
          info               Display Axium information and tagline
          ...

        Daemon & System:
          daemon start       Start Axium daemon
          daemon stop        Stop Axium daemon
          ...
        ```

    Note:
        This replaces the need to run --help on each command group.
        For detailed help on a specific command, use:
        axium <command> --help
    """
    from axium.core.help import get_grouped_commands, format_help_output

    grouped = get_grouped_commands()
    output = format_help_output(grouped)
    print(output)

discover()

Show installed components and configuration.

Displays: - Configured environments - Installed spokes - Installed gears - Active wrappers

Useful for understanding what's available in your Axium installation.

Example
$ axium discover
Axium Discovery

Environments (3):
   dev
   staging
   prod

Installed Spokes (2):
   aws-spoke
   k8s-spoke

Installed Gears (1):
   ansible-gear

Active Wrappers: 5
Note

Use 'axium help' to see all available commands.

Source code in axium/core/cli.py
@app.command()
def discover() -> None:
    """
    Show installed components and configuration.

    Displays:
    - Configured environments
    - Installed spokes
    - Installed gears
    - Active wrappers

    Useful for understanding what's available in your Axium installation.

    Example:
        ```bash
        $ axium discover
        Axium Discovery

        Environments (3):
          • dev
          • staging
          • prod

        Installed Spokes (2):
          • aws-spoke
          • k8s-spoke

        Installed Gears (1):
          • ansible-gear

        Active Wrappers: 5
        ```

    Note:
        Use 'axium help' to see all available commands.
    """
    from axium.core.help import get_discovery_info, format_discovery_output

    info = get_discovery_info()
    output = format_discovery_output(info)
    print(output)

doctor()

Check Axium configuration and system health.

Runs diagnostics on: - Config directory structure - Daemon connectivity - Environment definitions - Spoke/gear loading - File permissions - Shell integration

Provides actionable fix suggestions for any issues found.

Example
$ axium doctor
Axium Health Check

 Config directory: /Users/user/.config/axium
 Daemon running: PID 12345
 IPC socket: Accessible
 Environments: 3 configured
 Spokes: 2 installed, 2 active
 Gears: 1 installed
 Shell integration: Not detected
   Add to ~/.bashrc: source ~/.config/axium/bash/init.sh

Overall: 6/7 checks passed
Note

Run this command if you're experiencing issues with Axium. All checks are read-only and safe to run repeatedly.

Source code in axium/core/cli.py
@app.command()
def doctor() -> None:
    """
    Check Axium configuration and system health.

    Runs diagnostics on:
    - Config directory structure
    - Daemon connectivity
    - Environment definitions
    - Spoke/gear loading
    - File permissions
    - Shell integration

    Provides actionable fix suggestions for any issues found.

    Example:
        ```bash
        $ axium doctor
        Axium Health Check

        ✓ Config directory: /Users/user/.config/axium
        ✓ Daemon running: PID 12345
        ✓ IPC socket: Accessible
        ✓ Environments: 3 configured
        ✓ Spokes: 2 installed, 2 active
        ✓ Gears: 1 installed
        ✗ Shell integration: Not detected
          → Add to ~/.bashrc: source ~/.config/axium/bash/init.sh

        Overall: 6/7 checks passed
        ```

    Note:
        Run this command if you're experiencing issues with Axium.
        All checks are read-only and safe to run repeatedly.
    """
    from axium.core.doctor import run_all_checks, format_doctor_output

    checks = run_all_checks()
    output = format_doctor_output(checks)
    print(output)

    # Exit with error code if any checks failed
    failed = sum(1 for c in checks if not c.passed)
    if failed > 0:
        raise typer.Exit(code=1)

completions_list_cmd(shell=typer.Option('zsh', '--shell', help='Shell type (zsh, bash, fish)'), prefix=typer.Argument('', help='Prefix to filter completions'))

List matching completions for shell integration.

Used by shell completion functions to get matching commands. Returns one command per line for easy parsing by shell scripts.

The completion cache is automatically regenerated when: - Spokes are loaded, reloaded, or unloaded - Gears are loaded or unloaded - Daemon configuration is reloaded

Parameters:

Name Type Description Default
shell str

Shell type (zsh, bash, fish) - currently all use same format

Option('zsh', '--shell', help='Shell type (zsh, bash, fish)')
prefix str

Command prefix to match (e.g., "env", "daemon s")

Argument('', help='Prefix to filter completions')
Example
$ axium completions list ""
bootstrap
daemon logs
daemon reload
daemon start
...

$ axium completions list "env"
env get
env list
env set
env show

$ axium completions list --shell zsh "daemon s"
daemon start
daemon status
Performance

Target response time: <50ms including cache load and filtering

Usage in shell
# In zsh completion function:
completions=$(axium completions list "$word" 2>/dev/null)
Note

If the cache doesn't exist, returns empty list. Cache is regenerated automatically by the daemon on command structure changes.

Source code in axium/core/cli.py
@completions_app.command("list")
def completions_list_cmd(
    shell: str = typer.Option("zsh", "--shell", help="Shell type (zsh, bash, fish)"),
    prefix: str = typer.Argument("", help="Prefix to filter completions"),
) -> None:
    """
    List matching completions for shell integration.

    Used by shell completion functions to get matching commands.
    Returns one command per line for easy parsing by shell scripts.

    The completion cache is automatically regenerated when:
    - Spokes are loaded, reloaded, or unloaded
    - Gears are loaded or unloaded
    - Daemon configuration is reloaded

    Args:
        shell: Shell type (zsh, bash, fish) - currently all use same format
        prefix: Command prefix to match (e.g., "env", "daemon s")

    Example:
        ```bash
        $ axium completions list ""
        bootstrap
        daemon logs
        daemon reload
        daemon start
        ...

        $ axium completions list "env"
        env get
        env list
        env set
        env show

        $ axium completions list --shell zsh "daemon s"
        daemon start
        daemon status
        ```

    Performance:
        Target response time: <50ms including cache load and filtering

    Usage in shell:
        ```zsh
        # In zsh completion function:
        completions=$(axium completions list "$word" 2>/dev/null)
        ```

    Note:
        If the cache doesn't exist, returns empty list. Cache is regenerated
        automatically by the daemon on command structure changes.
    """
    from axium.core.completions import get_completions

    matches = get_completions(prefix)
    for cmd in matches:
        print(cmd)

completions_install_cmd(shell=typer.Option(None, '--shell', help='Shell type (auto-detect if not specified)'))

Print shell completion setup instructions.

Outputs the completion script to add to your shell configuration file. Auto-detects shell from $SHELL environment variable if not specified.

Parameters:

Name Type Description Default
shell str

Shell type (zsh, bash, fish). Auto-detected if omitted.

Option(None, '--shell', help='Shell type (auto-detect if not specified)')
Example
$ axium completions install
# Add this to ~/.zshrc:
_axium_completion() { ... }
compdef _axium_completion axium

$ axium completions install --shell bash
# Add this to ~/.bashrc:
_axium_completion() { ... }
complete -F _axium_completion axium
Supported Shells
  • zsh: Fast completion using compadd
  • bash: Completion using complete -F
  • fish: Completion using complete -c
Note

After adding the script to your shell config, reload it with: - zsh: source ~/.zshrc - bash: source ~/.bashrc - fish: Completions work immediately after file creation

Source code in axium/core/cli.py
@completions_app.command("install")
def completions_install_cmd(
    shell: str = typer.Option(
        None, "--shell", help="Shell type (auto-detect if not specified)"
    ),
) -> None:
    """
    Print shell completion setup instructions.

    Outputs the completion script to add to your shell configuration file.
    Auto-detects shell from $SHELL environment variable if not specified.

    Args:
        shell: Shell type (zsh, bash, fish). Auto-detected if omitted.

    Example:
        ```bash
        $ axium completions install
        # Add this to ~/.zshrc:
        _axium_completion() { ... }
        compdef _axium_completion axium

        $ axium completions install --shell bash
        # Add this to ~/.bashrc:
        _axium_completion() { ... }
        complete -F _axium_completion axium
        ```

    Supported Shells:
        - zsh: Fast completion using compadd
        - bash: Completion using complete -F
        - fish: Completion using complete -c

    Note:
        After adding the script to your shell config, reload it with:
        - zsh: source ~/.zshrc
        - bash: source ~/.bashrc
        - fish: Completions work immediately after file creation
    """
    import os
    import sys
    from pathlib import Path

    # Auto-detect shell if not specified
    if not shell:
        shell_path = os.getenv("SHELL", "/bin/zsh")
        shell = Path(shell_path).name

    if shell == "zsh":
        print("# Axium shell completion for zsh")
        print("# Add this to ~/.zshrc:")
        print()
        print("_axium_completion() {")
        print("  local prefix current")
        print('  current="${words[CURRENT]}"')
        print("  ")
        print("  # Build prefix from all axium arguments up to current position")
        print("  if [[ $CURRENT -eq 2 ]]; then")
        print("    # Completing first argument after 'axium'")
        print('    prefix="$current"')
        print("  else")
        print("    # Completing subsequent arguments")
        print("    # Join all previous words (2 to CURRENT-1) with spaces")
        print('    prefix="${(j: :)words[2,CURRENT-1]}"')
        print(
            "    # Add trailing space if current word is empty (user pressed space before tab)"
        )
        print(
            '    [[ -z "$current" ]] && prefix="$prefix " || prefix="$prefix $current"'
        )
        print("  fi")
        print("  ")
        print("  local -a completions")
        print(
            '  completions=($(axium completions list --shell zsh "$prefix" 2>/dev/null))'
        )
        print('  compadd -Q -- "${completions[@]}"')
        print("}")
        print("compdef _axium_completion axium")
        print()
        print("# Then run: source ~/.zshrc")

    elif shell == "bash":
        print("# Axium shell completion for bash")
        print("# Add this to ~/.bashrc:")
        print()
        print("_axium_completion() {")
        print("  local cur prev words cword")
        print("  _init_completion || return")
        print('  COMPREPLY=($(axium completions list --shell bash "$cur" 2>/dev/null))')
        print("}")
        print("complete -F _axium_completion axium")
        print()
        print("# Then run: source ~/.bashrc")

    elif shell == "fish":
        print("# Axium shell completion for fish")
        print("# Add this to ~/.config/fish/completions/axium.fish:")
        print()
        print(
            "complete -c axium -f -a '(axium completions list --shell fish (commandline -ct) 2>/dev/null)'"
        )
        print()
        print("# Completions will be available immediately")

    else:
        print(f"✗ Unsupported shell: {shell}", file=sys.stderr)
        print("  Supported shells: zsh, bash, fish", file=sys.stderr)
        raise typer.Exit(1)

completions_refresh_cmd()

Regenerate completion cache immediately.

Forces regeneration of ~/.config/axium/completions.json from the current command registry. Normally the cache is regenerated automatically when spokes load/reload, so manual refresh is rarely needed.

Use this command after: - Manually editing spoke files - Installing spokes outside of axium spoke install - Debugging completion issues

Example
$ axium completions refresh
 Regenerated completion cache (42 commands)

$ axium completions refresh
 Failed to regenerate completion cache
Performance

Typically completes in <20ms with 100+ commands

Note

This command requires all spokes to be loaded, so it may trigger spoke loading if not already done. The daemon performs automatic regeneration, so manual refresh is usually unnecessary.

Source code in axium/core/cli.py
@completions_app.command("refresh")
def completions_refresh_cmd() -> None:
    """
    Regenerate completion cache immediately.

    Forces regeneration of ~/.config/axium/completions.json from the
    current command registry. Normally the cache is regenerated automatically
    when spokes load/reload, so manual refresh is rarely needed.

    Use this command after:
    - Manually editing spoke files
    - Installing spokes outside of `axium spoke install`
    - Debugging completion issues

    Example:
        ```bash
        $ axium completions refresh
        ✓ Regenerated completion cache (42 commands)

        $ axium completions refresh
        ✗ Failed to regenerate completion cache
        ```

    Performance:
        Typically completes in <20ms with 100+ commands

    Note:
        This command requires all spokes to be loaded, so it may trigger
        spoke loading if not already done. The daemon performs automatic
        regeneration, so manual refresh is usually unnecessary.
    """
    import sys

    from axium.core.completions import generate_completion_cache, load_completion_cache

    success = generate_completion_cache()
    if success:
        if is_verbose():
            count = len(load_completion_cache())
            print(f"✓ Regenerated completion cache ({count} commands)")
    else:
        print("✗ Failed to regenerate completion cache", file=sys.stderr)
        raise typer.Exit(1)

wrapper_list_cmd()

List all currently wrapped commands.

Shows commands that have prefix rules configured in prefixes.yaml. These commands will be intercepted by shell wrapper functions that call 'axium run' to apply environment-specific prefixes.

Example
$ axium wrapper list
aws
terraform

$ axium wrapper list | wc -l
2
Note

This reads from ~/.config/axium/state_cache.json which is automatically updated when: - Daemon starts - axium daemon reload is run - Prefix rules in prefixes.yaml change

Source code in axium/core/cli.py
@wrapper_app.command("list")
def wrapper_list_cmd() -> None:
    """
    List all currently wrapped commands.

    Shows commands that have prefix rules configured in prefixes.yaml.
    These commands will be intercepted by shell wrapper functions that
    call 'axium run' to apply environment-specific prefixes.

    Example:
        ```bash
        $ axium wrapper list
        aws
        terraform

        $ axium wrapper list | wc -l
        2
        ```

    Note:
        This reads from ~/.config/axium/state_cache.json which is
        automatically updated when:
        - Daemon starts
        - `axium daemon reload` is run
        - Prefix rules in prefixes.yaml change
    """
    import json
    from pathlib import Path

    cache_file = Path.home() / ".config" / "axium" / "state_cache.json"

    if not cache_file.exists():
        return

    try:
        data = json.loads(cache_file.read_text())
        commands = data.get("prefixed_commands", [])
        for cmd in commands:
            print(cmd)
    except Exception:
        # Silently fail if cache is invalid
        pass

wrapper_refresh_cmd()

Print shell commands to refresh wrappers in current shell.

Outputs bash/zsh code that clears old wrapper functions and regenerates them from the latest state_cache.json. Run this after modifying prefixes.yaml and reloading the daemon.

Example
# After editing prefixes.yaml:
$ axium daemon reload
$ eval "$(axium wrapper refresh)"
# Wrappers now reflect new prefix rules

# Or in one line:
$ axium daemon reload && eval "$(axium wrapper refresh)"
Usage

The output must be eval'd to take effect in your current shell. Simply running this command will only print the code, not execute it.

What it does
  1. Unsets all previously wrapped command functions
  2. Reads latest wrapped commands from state_cache.json
  3. Creates new wrapper functions for each command
Note

This requires Axium shell integration to be loaded (i.e., bash/init.sh must be sourced in your shell config).

Source code in axium/core/cli.py
@wrapper_app.command("refresh")
def wrapper_refresh_cmd() -> None:
    """
    Print shell commands to refresh wrappers in current shell.

    Outputs bash/zsh code that clears old wrapper functions and
    regenerates them from the latest state_cache.json. Run this
    after modifying prefixes.yaml and reloading the daemon.

    Example:
        ```bash
        # After editing prefixes.yaml:
        $ axium daemon reload
        $ eval "$(axium wrapper refresh)"
        # Wrappers now reflect new prefix rules

        # Or in one line:
        $ axium daemon reload && eval "$(axium wrapper refresh)"
        ```

    Usage:
        The output must be eval'd to take effect in your current shell.
        Simply running this command will only print the code, not execute it.

    What it does:
        1. Unsets all previously wrapped command functions
        2. Reads latest wrapped commands from state_cache.json
        3. Creates new wrapper functions for each command

    Note:
        This requires Axium shell integration to be loaded
        (i.e., bash/init.sh must be sourced in your shell config).
    """
    print("axium_refresh_wrappers 2>/dev/null")

wrapper_clear_cmd()

Print shell commands to remove all wrapper functions.

Outputs bash/zsh code that unsets all wrapper functions, restoring original command behavior. Useful for debugging or temporarily disabling Axium's command wrapping.

Example
$ eval "$(axium wrapper clear)"
# All wrapper functions removed

$ aws --version
# Now runs /usr/local/bin/aws directly, not through axium run
Usage

The output must be eval'd to take effect in your current shell.

What it does

Unsets all tracked wrapper functions (from $AXIUM_WRAPPED_COMMANDS), allowing you to call the original commands directly.

Note

To re-enable wrappers after clearing, run: $ eval "$(axium wrapper refresh)"

Source code in axium/core/cli.py
@wrapper_app.command("clear")
def wrapper_clear_cmd() -> None:
    """
    Print shell commands to remove all wrapper functions.

    Outputs bash/zsh code that unsets all wrapper functions, restoring
    original command behavior. Useful for debugging or temporarily
    disabling Axium's command wrapping.

    Example:
        ```bash
        $ eval "$(axium wrapper clear)"
        # All wrapper functions removed

        $ aws --version
        # Now runs /usr/local/bin/aws directly, not through axium run
        ```

    Usage:
        The output must be eval'd to take effect in your current shell.

    What it does:
        Unsets all tracked wrapper functions (from $AXIUM_WRAPPED_COMMANDS),
        allowing you to call the original commands directly.

    Note:
        To re-enable wrappers after clearing, run:
        $ eval "$(axium wrapper refresh)"
    """
    print("axium_clear_wrappers 2>/dev/null")

bootstrap(force=typer.Option(False, '--force', '-f', help='Force update shell integration scripts (bash/init.sh, tmux/init.sh)'))

Initialize or update Axium configuration.

Creates ~/.config/axium/ with default configuration files if missing. Use --force to update shell integration scripts even if they exist.

Silent on success (use --verbose for details), shows errors only.

Example
$ axium bootstrap             # Silent success
$ echo $?                      # 0

$ axium bootstrap --verbose
 Axium config initialized at ~/.config/axium

$ axium bootstrap --force -v
 Shell integration scripts updated
Note

User config files (envs.yaml, prefixes.yaml, state.json) are never overwritten, even with --force.

Source code in axium/core/cli.py
@app.command()
def bootstrap(
    force: bool = typer.Option(
        False,
        "--force",
        "-f",
        help="Force update shell integration scripts (bash/init.sh, tmux/init.sh)",
    ),
) -> None:
    """
    Initialize or update Axium configuration.

    Creates ~/.config/axium/ with default configuration files if missing.
    Use --force to update shell integration scripts even if they exist.

    Silent on success (use --verbose for details), shows errors only.

    Example:
        ```bash
        $ axium bootstrap             # Silent success
        $ echo $?                      # 0

        $ axium bootstrap --verbose
        ✓ Axium config initialized at ~/.config/axium

        $ axium bootstrap --force -v
        ✓ Shell integration scripts updated
        ```

    Note:
        User config files (envs.yaml, prefixes.yaml, state.json) are
        never overwritten, even with --force.
    """
    from axium.core import bootstrap as bootstrap_module

    if force:
        # Force update init scripts
        bootstrap_module.update_init_scripts()
        if is_verbose():
            print("✓ Shell integration scripts updated")
            print(f"  - {bootstrap_module.BASH_INIT_PATH}")
            print(f"  - {bootstrap_module.TMUX_INIT_PATH}")
    else:
        # Normal bootstrap
        initialized = bootstrap_module.ensure_axium_config()
        if is_verbose():
            if initialized:
                print(f"✓ Axium config initialized at {bootstrap_module.CONF_DIR}")
                print(
                    "\nTo enable shell integration, add to your ~/.bashrc or ~/.zshrc:"
                )
                print(f"  source {bootstrap_module.BASH_INIT_PATH}")
            else:
                print(f"✓ Axium config already exists at {bootstrap_module.CONF_DIR}")

spoke_new(name=typer.Argument(..., help='Name of the new spoke (lowercase, no spaces)'), description=typer.Option(None, '--description', '-d', help='Short description of the spoke'), version=typer.Option('0.1.0', '--version', '-v', help='Initial version'), interactive=typer.Option(True, '--interactive/--no-interactive', help='Prompt for values interactively'))

Create a new Spoke with scaffolded structure.

Generates a new Spoke directory in ~/.config/axium/spokes// with: - spoke.yaml manifest - main.py with register() template - Basic directory structure

Example
$ axium spoke new my-spoke
$ axium spoke new aws --description "AWS operations" --version 1.0.0
Source code in axium/core/cli.py
@spoke_app.command("new")
def spoke_new(
    name: str = typer.Argument(
        ..., help="Name of the new spoke (lowercase, no spaces)"
    ),
    description: str = typer.Option(
        None, "--description", "-d", help="Short description of the spoke"
    ),
    version: str = typer.Option("0.1.0", "--version", "-v", help="Initial version"),
    interactive: bool = typer.Option(
        True, "--interactive/--no-interactive", help="Prompt for values interactively"
    ),
) -> None:
    """
    Create a new Spoke with scaffolded structure.

    Generates a new Spoke directory in ~/.config/axium/spokes/<name>/ with:
    - spoke.yaml manifest
    - main.py with register() template
    - Basic directory structure

    Example:
        ```bash
        $ axium spoke new my-spoke
        $ axium spoke new aws --description "AWS operations" --version 1.0.0
        ```
    """
    from axium.core.spokes import create_spoke

    try:
        spoke_path = create_spoke(
            name=name,
            description=description,
            version=version,
            interactive=interactive,
        )
        if is_verbose():
            print(f"\n✓ Created new Spoke: {name}")
            print(f"  Location: {spoke_path}")
            print(f"\nNext steps:")
            print(f"  1. Edit {spoke_path / 'main.py'} to add commands")
            print(f"  2. Run: axium spoke reload {name}")
    except Exception as e:
        import sys

        print(f"✗ Failed to create spoke: {e}", file=sys.stderr)
        raise typer.Exit(code=1)

spoke_install(source=typer.Argument(..., help='Source path (local path, git URL, or registry name)'), editable=typer.Option(False, '--editable', '-e', help='Install as symlink for development'), name=typer.Option(None, '--name', '-n', help='Override spoke name'))

Install a Spoke from source.

Supports: - Local paths: /path/to/my-spoke or ~/spokes/my-spoke - Git URLs: git+https://github.com/user/spoke.git (future) - Registry: my-spoke (future)

Installation modes: - Normal (copy): Copies spoke to ~/.config/axium/spokes// - Editable (symlink): Creates symlink for live development

Example
$ axium spoke install ~/dev/my-spoke
$ axium spoke install ~/dev/my-spoke --editable
$ axium spoke install ~/dev/my-spoke --name custom-name
Source code in axium/core/cli.py
@spoke_app.command("install")
def spoke_install(
    source: str = typer.Argument(
        ..., help="Source path (local path, git URL, or registry name)"
    ),
    editable: bool = typer.Option(
        False, "--editable", "-e", help="Install as symlink for development"
    ),
    name: str = typer.Option(None, "--name", "-n", help="Override spoke name"),
) -> None:
    """
    Install a Spoke from source.

    Supports:
    - Local paths: /path/to/my-spoke or ~/spokes/my-spoke
    - Git URLs: git+https://github.com/user/spoke.git (future)
    - Registry: my-spoke (future)

    Installation modes:
    - Normal (copy): Copies spoke to ~/.config/axium/spokes/<name>/
    - Editable (symlink): Creates symlink for live development

    Example:
        ```bash
        $ axium spoke install ~/dev/my-spoke
        $ axium spoke install ~/dev/my-spoke --editable
        $ axium spoke install ~/dev/my-spoke --name custom-name
        ```
    """
    from axium.core.spokes import install_spoke
    from axium.core.api import spoke_reload_one

    try:
        metadata = install_spoke(source=source, editable=editable, name=name)
        if is_verbose():
            print(f"✓ Installed: {metadata.name} v{metadata.version}")
            print(f"  Mode: {metadata.install_mode}")
            print(f"  Source: {metadata.source}")
            print(f"  Loading spoke...")

        # Automatically reload the spoke to make it immediately available
        reload_success = spoke_reload_one(metadata.name)
        if reload_success:
            if is_verbose():
                print(f"✓ Spoke loaded and ready to use")
        else:
            if is_verbose():
                print(
                    f"⚠ Spoke installed but failed to load. Try: axium spoke reload {metadata.name}"
                )
    except Exception as e:
        import sys

        print(f"✗ Installation failed: {e}", file=sys.stderr)
        raise typer.Exit(code=1)

spoke_reinstall(name=typer.Argument(..., help='Spoke name to reinstall'))

Reinstall a spoke from its original source.

Preserves the installation mode (copy/symlink) and reinstalls from the original source location. Useful for updating spokes or fixing installation issues.

Example
$ axium spoke reinstall creds
 Reinstalled: creds v0.1.0
 Spoke loaded and ready to use

$ axium spoke reinstall aws
 Reinstalled: aws v2.0.0 (upgraded from v1.5.0)
 Spoke loaded and ready to use
Source code in axium/core/cli.py
@spoke_app.command("reinstall")
def spoke_reinstall(
    name: str = typer.Argument(..., help="Spoke name to reinstall"),
) -> None:
    """
    Reinstall a spoke from its original source.

    Preserves the installation mode (copy/symlink) and reinstalls from
    the original source location. Useful for updating spokes or fixing
    installation issues.

    Example:
        ```bash
        $ axium spoke reinstall creds
        ✓ Reinstalled: creds v0.1.0
        ✓ Spoke loaded and ready to use

        $ axium spoke reinstall aws
        ✓ Reinstalled: aws v2.0.0 (upgraded from v1.5.0)
        ✓ Spoke loaded and ready to use
        ```
    """
    from axium.core.spokes import get_spoke_metadata, install_spoke
    from axium.core.api import spoke_reload_one
    import shutil

    # Get current spoke metadata
    spokes = get_spoke_metadata()
    spoke_meta = next((s for s in spokes if s.name == name), None)

    if not spoke_meta:
        print(f"✗ Spoke '{name}' is not installed")
        raise typer.Exit(1)

    # Extract source path from metadata
    source = spoke_meta.source
    if source.startswith("local:"):
        source = source[6:]  # Remove "local:" prefix

    install_mode = spoke_meta.install_mode
    old_version = spoke_meta.version

    try:
        # Remove old installation
        from axium.core.spokes import SPOKES_DIR

        spoke_path = SPOKES_DIR / name
        if spoke_path.exists():
            if spoke_path.is_symlink():
                spoke_path.unlink()
            else:
                shutil.rmtree(spoke_path)

        # Reinstall with same mode
        editable = install_mode == "symlink"
        metadata = install_spoke(source=source, editable=editable, name=name)

        if is_verbose():
            if metadata.version != old_version:
                print(
                    f"✓ Reinstalled: {metadata.name} v{metadata.version} (upgraded from v{old_version})"
                )
            else:
                print(f"✓ Reinstalled: {metadata.name} v{metadata.version}")
            print(f"  Mode: {metadata.install_mode}")
            print(f"  Source: {metadata.source}")
            print(f"  Loading spoke...")

        # Automatically reload the spoke
        reload_success = spoke_reload_one(metadata.name)
        if reload_success:
            if is_verbose():
                print(f"✓ Spoke loaded and ready to use")
        else:
            if is_verbose():
                print(
                    f"⚠ Spoke reinstalled but failed to load. Try: axium spoke reload {metadata.name}"
                )

    except Exception as e:
        import sys

        print(f"✗ Reinstall failed: {e}", file=sys.stderr)
        raise typer.Exit(code=1)

spoke_list()

List all installed Spokes with metadata.

Displays: - Spoke name and version - Description - Installation mode (copy/symlink) - Load status (active/error/not-loaded) - Last loaded timestamp

Example
$ axium spoke list
Installed Spokes:

aws (v1.0.0) [active]
  AWS operations and utilities
  Mode: copy | Last loaded: 2025-01-15 14:30:00

k8s (v0.2.0) [not-loaded]
  Kubernetes cluster management
  Mode: symlink | Installed: 2025-01-10 09:15:00
Source code in axium/core/cli.py
@spoke_app.command("list")
def spoke_list() -> None:
    """
    List all installed Spokes with metadata.

    Displays:
    - Spoke name and version
    - Description
    - Installation mode (copy/symlink)
    - Load status (active/error/not-loaded)
    - Last loaded timestamp

    Example:
        ```bash
        $ axium spoke list
        Installed Spokes:

        aws (v1.0.0) [active]
          AWS operations and utilities
          Mode: copy | Last loaded: 2025-01-15 14:30:00

        k8s (v0.2.0) [not-loaded]
          Kubernetes cluster management
          Mode: symlink | Installed: 2025-01-10 09:15:00
        ```
    """
    from axium.core.spokes import get_spoke_metadata

    spokes = get_spoke_metadata()

    if not spokes:
        print("No Spokes installed.")
        print("\nTry: axium spoke new <name>")
        return

    print("Installed Spokes:\n")

    for spoke in spokes:
        status_icon = (
            "✓"
            if spoke.status == "active"
            else ("✗" if spoke.status == "error" else "○")
        )
        print(f"{status_icon} {spoke.name} (v{spoke.version}) [{spoke.status}]")
        print(f"  {spoke.description}")
        print(f"  Mode: {spoke.install_mode}", end="")

        if spoke.last_loaded:
            print(f" | Last loaded: {spoke.last_loaded}")
        else:
            print(f" | Installed: {spoke.installed_at}")

        print()

spoke_reload(name=typer.Argument(None, help='Spoke name to reload (omit to reload all)'))

Reload spoke(s) without restarting daemon.

Dynamically reloads spoke code and re-registers commands. Useful during development to test changes immediately.

Example
$ axium spoke reload aws       # Reload specific spoke
 Reloaded spoke: aws

$ axium spoke reload           # Reload all spokes
 Reloaded 3 spokes: creds, aws, system
Source code in axium/core/cli.py
@spoke_app.command("reload")
def spoke_reload(
    name: str = typer.Argument(None, help="Spoke name to reload (omit to reload all)"),
) -> None:
    """
    Reload spoke(s) without restarting daemon.

    Dynamically reloads spoke code and re-registers commands.
    Useful during development to test changes immediately.

    Example:
        ```bash
        $ axium spoke reload aws       # Reload specific spoke
        ✓ Reloaded spoke: aws

        $ axium spoke reload           # Reload all spokes
        ✓ Reloaded 3 spokes: creds, aws, system
        ```
    """
    from axium.core.api import spoke_reload_all, spoke_reload_one

    if name:
        # Reload specific spoke
        ok = spoke_reload_one(name)
        if ok:
            if is_verbose():
                print(f"✓ Reloaded spoke: {name}")
            raise typer.Exit(code=0)
        else:
            import sys

            print(f"✗ Failed to reload spoke: {name}", file=sys.stderr)
            print(
                "  Ensure the daemon is running and the spoke exists", file=sys.stderr
            )
            raise typer.Exit(code=1)
    else:
        # Reload all spokes
        reloaded = spoke_reload_all()
        if len(reloaded) > 0:
            if is_verbose():
                print(
                    f"✓ Reloaded {len(reloaded)} spoke{'s' if len(reloaded) != 1 else ''}: {', '.join(reloaded)}"
                )
            raise typer.Exit(code=0)
        else:
            import sys

            print("✗ Failed to reload spokes", file=sys.stderr)
            print("  Ensure the daemon is running", file=sys.stderr)
            raise typer.Exit(code=1)

perms_list()

List all spokes with their effective permissions.

Displays a table showing: - Spoke name - exec permission (✓ or ✗) - notify permission (✓ or ✗) - net permission (✓ or ✗) - fs_read count - fs_write count

Example
$ axium perms list
Spoke Permissions:

creds      exec   notify   net  fs_read: 2  fs_write: 0
system     exec   notify   net  fs_read: 0  fs_write: 0
Source code in axium/core/cli.py
@perms_app.command("list")
def perms_list() -> None:
    """
    List all spokes with their effective permissions.

    Displays a table showing:
    - Spoke name
    - exec permission (✓ or ✗)
    - notify permission (✓ or ✗)
    - net permission (✓ or ✗)
    - fs_read count
    - fs_write count

    Example:
        ```bash
        $ axium perms list
        Spoke Permissions:

        creds     ✓ exec  ✓ notify  ✗ net  fs_read: 2  fs_write: 0
        system    ✗ exec  ✗ notify  ✗ net  fs_read: 0  fs_write: 0
        ```
    """
    from axium.core.spokes import get_spoke_metadata

    try:
        # Get all installed spokes
        spokes = get_spoke_metadata()

        if not spokes:
            print("No spokes installed.")
            return

        print("Spoke Permissions:\n")

        # Query daemon for each spoke's permissions
        for spoke in spokes:
            try:
                resp = send_request_sync(
                    {"cmd": "get_permissions", "spoke": spoke.name}
                )

                if not resp.get("ok"):
                    print(f"{spoke.name:<12} (not loaded)")
                    continue

                perms = resp["permissions"]

                # Format permission indicators
                exec_icon = "✓" if perms.get("exec") else "✗"
                notify_icon = "✓" if perms.get("notify") else "✗"
                net_icon = "✓" if perms.get("net") else "✗"
                fs_read_count = len(perms.get("fs_read", []))
                fs_write_count = len(perms.get("fs_write", []))

                # Highlight overridden fields with color (future: use palette)
                print(
                    f"{spoke.name:<12} {exec_icon} exec  {notify_icon} notify  {net_icon} net  "
                    f"fs_read: {fs_read_count}  fs_write: {fs_write_count}"
                )

            except Exception as e:
                print(f"{spoke.name:<12} (error: {e})")

    except Exception as e:
        print(f"✗ Failed to list permissions: {e}")
        raise typer.Exit(code=1)

perms_show(spoke=typer.Argument(..., help='Spoke name'))

Show detailed permissions for a spoke.

Displays: - All permission fields with values - Source annotation (base/override/default) - Full list of fs_read and fs_write patterns

Example
$ axium perms show creds
Permissions for: creds

exec: true (from override)
notify: true (from base)
net: false (from base)
fs_read:
  - ~/.aws/credentials (from base)
  - /opt/company/creds.json (from override)
fs_write: []
Source code in axium/core/cli.py
@perms_app.command("show")
def perms_show(spoke: str = typer.Argument(..., help="Spoke name")) -> None:
    """
    Show detailed permissions for a spoke.

    Displays:
    - All permission fields with values
    - Source annotation (base/override/default)
    - Full list of fs_read and fs_write patterns

    Example:
        ```bash
        $ axium perms show creds
        Permissions for: creds

        exec: true (from override)
        notify: true (from base)
        net: false (from base)
        fs_read:
          - ~/.aws/credentials (from base)
          - /opt/company/creds.json (from override)
        fs_write: []
        ```
    """
    try:
        resp = send_request_sync({"cmd": "get_permissions", "spoke": spoke})

        if not resp.get("ok"):
            print(f"✗ {resp.get('error', 'unknown error')}")
            raise typer.Exit(code=1)

        perms = resp["permissions"]
        sources = resp.get("sources", {})

        print(f"Permissions for: {spoke}\n")

        # Boolean permissions
        for field in ["exec", "notify", "net"]:
            value = perms.get(field, False)
            source = sources.get(field, "default")
            print(f"{field}: {value} (from {source})")

        # List permissions
        print(f"fs_read:")
        fs_read = perms.get("fs_read", [])
        if fs_read:
            for path in fs_read:
                source = sources.get("fs_read", "base")
                print(f"  - {path} (from {source})")
        else:
            print("  (none)")

        print(f"fs_write:")
        fs_write = perms.get("fs_write", [])
        if fs_write:
            for path in fs_write:
                source = sources.get("fs_write", "base")
                print(f"  - {path} (from {source})")
        else:
            print("  (none)")

    except Exception as e:
        print(f"✗ Failed to get permissions: {e}")
        raise typer.Exit(code=1)

perms_edit(spoke=typer.Argument(..., help='Spoke name'))

Edit user permission overrides for a spoke.

Opens ~/.config/axium/permissions.yaml in $EDITOR. If file doesn't exist, prints example configuration to create.

Example
$ axium perms edit creds
# Opens editor at permissions.yaml

$ axium perms edit newspoke
# Prints example if file doesn't exist
Source code in axium/core/cli.py
@perms_app.command("edit")
def perms_edit(spoke: str = typer.Argument(..., help="Spoke name")) -> None:
    """
    Edit user permission overrides for a spoke.

    Opens ~/.config/axium/permissions.yaml in $EDITOR.
    If file doesn't exist, prints example configuration to create.

    Example:
        ```bash
        $ axium perms edit creds
        # Opens editor at permissions.yaml

        $ axium perms edit newspoke
        # Prints example if file doesn't exist
        ```
    """
    import os
    import subprocess
    from pathlib import Path

    perms_path = Path.home() / ".config" / "axium" / "permissions.yaml"

    if not perms_path.exists():
        # Print example
        print(f"Permissions file not found: {perms_path}")
        print("\nCreate it with this template:\n")
        print("---")
        print(f"{spoke}:")
        print("  exec: true")
        print("  notify: true")
        print("  fs_read:")
        print("    - ~/path/to/file")
        print("  fs_write: []")
        print("---")
        print(f"\nThen run: $EDITOR {perms_path}")
        return

    # Open in editor
    editor = os.getenv("EDITOR", "vi")
    try:
        subprocess.run([editor, str(perms_path)], check=True)
        print(f"\n✓ Edited {perms_path}")
        print(f"\nReload spoke for changes to take effect:")
        print(f"  axium spoke reload {spoke}")
    except subprocess.CalledProcessError:
        print(f"✗ Failed to open editor: {editor}")
        raise typer.Exit(code=1)
    except FileNotFoundError:
        print(f"✗ Editor not found: {editor}")
        print(f"\nSet EDITOR environment variable or edit manually:")
        print(f"  {perms_path}")
        raise typer.Exit(code=1)

gear_list()

List all installed Gears with metadata.

Displays: - Gear name and version - Description - Installation mode (copy/symlink) - Load status (active/error/not-loaded) - Last loaded timestamp

Example
$ axium gear list
Installed Gears:

ansible (v1.0.0) [active]
  Ansible playbook automation with environment context
  Mode: copy | Last loaded: 2025-01-15 14:30:00

terraform (v0.2.0) [not-loaded]
  Terraform workspace management
  Mode: symlink | Installed: 2025-01-10 09:15:00
Source code in axium/core/cli.py
@gear_app.command("list")
def gear_list() -> None:
    """
    List all installed Gears with metadata.

    Displays:
    - Gear name and version
    - Description
    - Installation mode (copy/symlink)
    - Load status (active/error/not-loaded)
    - Last loaded timestamp

    Example:
        ```bash
        $ axium gear list
        Installed Gears:

        ansible (v1.0.0) [active]
          Ansible playbook automation with environment context
          Mode: copy | Last loaded: 2025-01-15 14:30:00

        terraform (v0.2.0) [not-loaded]
          Terraform workspace management
          Mode: symlink | Installed: 2025-01-10 09:15:00
        ```
    """
    from axium.core.gears import get_gear_metadata

    gears = get_gear_metadata()

    if not gears:
        print("No Gears installed.")
        print("\nGears are privileged extensions in ~/.config/axium/gears/")
        return

    print("Installed Gears:\n")

    for gear in gears:
        status_icon = (
            "✓" if gear.status == "active" else ("✗" if gear.status == "error" else "○")
        )
        print(f"{status_icon} {gear.name} (v{gear.version}) [{gear.status}]")
        if gear.description:
            print(f"  {gear.description}")
        print(f"  Mode: {gear.install_mode}", end="")

        if gear.last_loaded:
            print(f" | Last loaded: {gear.last_loaded}")
        else:
            print(f" | Installed: {gear.installed_at}")

        print()

gear_install(source=typer.Argument(..., help='Source path (local directory)'), editable=typer.Option(False, '--editable', '-e', help='Install as symlink for development'), name=typer.Option(None, '--name', '-n', help='Override gear name'))

Install a Gear from source.

Supports local directory paths. Gears are privileged extensions installed to ~/.config/axium/gears//.

Installation modes: - Normal (copy): Copies gear files - Editable (symlink): Creates symlink for live development

Example
$ axium gear install ~/dev/my-gear
$ axium gear install ~/dev/my-gear --editable
$ axium gear install ~/dev/my-gear --name custom-name
Source code in axium/core/cli.py
@gear_app.command("install")
def gear_install(
    source: str = typer.Argument(..., help="Source path (local directory)"),
    editable: bool = typer.Option(
        False, "--editable", "-e", help="Install as symlink for development"
    ),
    name: str = typer.Option(None, "--name", "-n", help="Override gear name"),
) -> None:
    """
    Install a Gear from source.

    Supports local directory paths. Gears are privileged extensions
    installed to ~/.config/axium/gears/<name>/.

    Installation modes:
    - Normal (copy): Copies gear files
    - Editable (symlink): Creates symlink for live development

    Example:
        ```bash
        $ axium gear install ~/dev/my-gear
        $ axium gear install ~/dev/my-gear --editable
        $ axium gear install ~/dev/my-gear --name custom-name
        ```
    """
    from axium.core.api import gear_install as api_gear_install, gear_reload_one

    try:
        metadata = api_gear_install(source=source, editable=editable, name=name)
        if metadata:
            if is_verbose():
                print(f"✓ Installed: {metadata['name']} v{metadata['version']}")
                print(f"  Mode: {metadata['install_mode']}")
                print(f"  Source: {metadata['source']}")
                print(f"  Loading gear...")

            # Automatically reload the gear to make it immediately available
            reload_success = gear_reload_one(metadata["name"])
            if reload_success:
                if is_verbose():
                    print(f"✓ Gear loaded and ready to use")
            else:
                if is_verbose():
                    print(
                        f"⚠ Gear installed but failed to load. Try: axium gear reload {metadata['name']}"
                    )
        else:
            import sys

            print("✗ Installation failed", file=sys.stderr)
            raise typer.Exit(code=1)
    except Exception as e:
        import sys

        print(f"✗ Installation failed: {e}", file=sys.stderr)
        raise typer.Exit(code=1)

gear_reinstall(name=typer.Argument(..., help='Gear name to reinstall'))

Reinstall a gear from its original source.

Preserves the installation mode (copy/symlink) and reinstalls from the original source location. Useful for updating gears or fixing installation issues.

Example
$ axium gear reinstall web
 Reinstalled: web v0.1.0
 Gear loaded and ready to use

$ axium gear reinstall ansible
 Reinstalled: ansible v1.1.0 (upgraded from v1.0.0)
 Gear loaded and ready to use
Source code in axium/core/cli.py
@gear_app.command("reinstall")
def gear_reinstall(
    name: str = typer.Argument(..., help="Gear name to reinstall"),
) -> None:
    """
    Reinstall a gear from its original source.

    Preserves the installation mode (copy/symlink) and reinstalls from
    the original source location. Useful for updating gears or fixing
    installation issues.

    Example:
        ```bash
        $ axium gear reinstall web
        ✓ Reinstalled: web v0.1.0
        ✓ Gear loaded and ready to use

        $ axium gear reinstall ansible
        ✓ Reinstalled: ansible v1.1.0 (upgraded from v1.0.0)
        ✓ Gear loaded and ready to use
        ```
    """
    from axium.core.gears import get_gear_metadata
    from axium.core.api import gear_install as api_gear_install, gear_reload_one
    import shutil

    # Get current gear metadata
    gears = get_gear_metadata()
    gear_meta = next((g for g in gears if g.name == name), None)

    if not gear_meta:
        print(f"✗ Gear '{name}' is not installed")
        raise typer.Exit(1)

    # Extract source path from metadata
    source = gear_meta.source
    if source.startswith("local:"):
        source = source[6:]  # Remove "local:" prefix

    install_mode = gear_meta.install_mode
    old_version = gear_meta.version

    try:
        # Remove old installation
        from axium.core.gears import GEARS_DIR

        gear_path = GEARS_DIR / name
        if gear_path.exists():
            if gear_path.is_symlink():
                gear_path.unlink()
            else:
                shutil.rmtree(gear_path)

        # Reinstall with same mode
        editable = install_mode == "symlink"
        metadata = api_gear_install(source=source, editable=editable, name=name)

        if metadata:
            if is_verbose():
                if metadata["version"] != old_version:
                    print(
                        f"✓ Reinstalled: {metadata['name']} v{metadata['version']} (upgraded from v{old_version})"
                    )
                else:
                    print(f"✓ Reinstalled: {metadata['name']} v{metadata['version']}")
                print(f"  Mode: {metadata['install_mode']}")
                print(f"  Source: {metadata['source']}")
                print(f"  Loading gear...")

            # Automatically reload the gear
            reload_success = gear_reload_one(metadata["name"])
            if reload_success:
                if is_verbose():
                    print(f"✓ Gear loaded and ready to use")
            else:
                if is_verbose():
                    print(
                        f"⚠ Gear reinstalled but failed to load. Try: axium gear reload {metadata['name']}"
                    )
        else:
            import sys

            print("✗ Reinstall failed", file=sys.stderr)
            raise typer.Exit(code=1)

    except Exception as e:
        import sys

        print(f"✗ Reinstall failed: {e}", file=sys.stderr)
        raise typer.Exit(code=1)

gear_uninstall(name=typer.Argument(..., help='Gear name to uninstall'), force=typer.Option(False, '--force', '-f', help='Skip confirmation'))

Uninstall a Gear.

Removes the gear directory and cleans up metadata and permission overrides.

Example
$ axium gear uninstall my-gear
Uninstall gear 'my-gear'? [y/N]: y
 Uninstalled gear: my-gear

$ axium gear uninstall my-gear --force
 Uninstalled gear: my-gear
Source code in axium/core/cli.py
@gear_app.command("uninstall")
def gear_uninstall(
    name: str = typer.Argument(..., help="Gear name to uninstall"),
    force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
) -> None:
    """
    Uninstall a Gear.

    Removes the gear directory and cleans up metadata and permission overrides.

    Example:
        ```bash
        $ axium gear uninstall my-gear
        Uninstall gear 'my-gear'? [y/N]: y
        ✓ Uninstalled gear: my-gear

        $ axium gear uninstall my-gear --force
        ✓ Uninstalled gear: my-gear
        ```
    """
    from axium.core.api import gear_uninstall as api_gear_uninstall

    try:
        ok = api_gear_uninstall(name, force=force)
        if ok:
            if is_verbose():
                print(f"✓ Uninstalled gear: {name}")
            raise typer.Exit(code=0)
        else:
            import sys

            print(f"✗ Failed to uninstall gear: {name}", file=sys.stderr)
            raise typer.Exit(code=1)
    except Exception as e:
        import sys

        print(f"✗ Uninstall failed: {e}", file=sys.stderr)
        raise typer.Exit(code=1)

gear_reload(name=typer.Argument(None, help='Gear name to reload (omit to reload all)'))

Reload gear(s) without restarting daemon.

Dynamically reloads gear code and re-registers commands. Useful during development to test changes immediately.

Example
$ axium gear reload ansible      # Reload specific gear
 Reloaded gear: ansible

$ axium gear reload              # Reload all gears
 Reloaded 2 gears: ansible, axium-gear-web
Source code in axium/core/cli.py
@gear_app.command("reload")
def gear_reload(
    name: str = typer.Argument(None, help="Gear name to reload (omit to reload all)"),
) -> None:
    """
    Reload gear(s) without restarting daemon.

    Dynamically reloads gear code and re-registers commands.
    Useful during development to test changes immediately.

    Example:
        ```bash
        $ axium gear reload ansible      # Reload specific gear
        ✓ Reloaded gear: ansible

        $ axium gear reload              # Reload all gears
        ✓ Reloaded 2 gears: ansible, axium-gear-web
        ```
    """
    from axium.core.api import gear_reload_all, gear_reload_one

    if name:
        # Reload specific gear
        ok = gear_reload_one(name)
        if ok:
            if is_verbose():
                print(f"✓ Reloaded gear: {name}")
            raise typer.Exit(code=0)
        else:
            import sys

            print(f"✗ Failed to reload gear: {name}", file=sys.stderr)
            print("  Ensure the gear exists and is properly installed", file=sys.stderr)
            raise typer.Exit(code=1)
    else:
        # Reload all gears
        reloaded = gear_reload_all()
        if len(reloaded) > 0:
            if is_verbose():
                print(
                    f"✓ Reloaded {len(reloaded)} gear{'s' if len(reloaded) != 1 else ''}: {', '.join(reloaded)}"
                )
            raise typer.Exit(code=0)
        else:
            import sys

            print("✗ Failed to reload gears", file=sys.stderr)
            print("  No gears installed or all reloads failed", file=sys.stderr)
            raise typer.Exit(code=1)

gear_perms_show(gear=typer.Argument(..., help='Gear name'))

Show effective permissions for a gear.

Displays base permissions from gear.yaml merged with user overrides from overrides/permissions/.yaml.

Example
$ axium gear perms-show ansible
Gear: ansible

Permissions:
  exec:  (base)
  notify:   net:  (override)
  ipc: 3 actions
    - tmux_split_run (base)
    - read_file (base)
    - write_file (base)
  fs_read: 2 patterns
    - ~/.ssh/config (override)
    - ~/ansible/** (base)
  fs_write: 1 pattern
    - ~/ansible/logs/** (base)
Source code in axium/core/cli.py
@gear_app.command("perms-show")
def gear_perms_show(gear: str = typer.Argument(..., help="Gear name")) -> None:
    """
    Show effective permissions for a gear.

    Displays base permissions from gear.yaml merged with user overrides
    from overrides/permissions/<gear>.yaml.

    Example:
        ```bash
        $ axium gear perms-show ansible
        Gear: ansible

        Permissions:
          exec: ✓ (base)
          notify: ✗
          net: ✓ (override)
          ipc: 3 actions
            - tmux_split_run (base)
            - read_file (base)
            - write_file (base)
          fs_read: 2 patterns
            - ~/.ssh/config (override)
            - ~/ansible/** (base)
          fs_write: 1 pattern
            - ~/ansible/logs/** (base)
        ```
    """
    from axium.core.gears import GEARS_DIR, get_effective_gear_permissions

    gear_path = GEARS_DIR / gear
    gear_yaml = gear_path / "gear.yaml"

    if not gear_yaml.exists():
        print(f"✗ Gear not found: {gear}")
        print(f"  Expected: {gear_yaml}")
        raise typer.Exit(code=1)

    try:
        perms = get_effective_gear_permissions(gear, gear_yaml)
    except Exception as e:
        print(f"✗ Failed to load permissions: {e}")
        raise typer.Exit(code=1)

    print(f"Gear: {gear}\n")
    print("Permissions:")

    # Helper to show source
    def src(field):
        s = perms.get_source(field)
        return f" ({s})" if s else ""

    print(f"  exec: {'✓' if perms.exec else '✗'}{src('exec')}")
    print(f"  notify: {'✓' if perms.notify else '✗'}{src('notify')}")
    print(f"  net: {'✓' if perms.net else '✗'}{src('net')}")

    if perms.ipc:
        print(
            f"  ipc: {len(perms.ipc)} action{'s' if len(perms.ipc) != 1 else ''}{src('ipc')}"
        )
        for action in perms.ipc:
            print(f"    - {action}")
    else:
        print(f"  ipc: none")

    if perms.fs_read:
        print(
            f"  fs_read: {len(perms.fs_read)} pattern{'s' if len(perms.fs_read) != 1 else ''}{src('fs_read')}"
        )
        for pattern in perms.fs_read:
            print(f"    - {pattern}")
    else:
        print(f"  fs_read: none")

    if perms.fs_write:
        print(
            f"  fs_write: {len(perms.fs_write)} pattern{'s' if len(perms.fs_write) != 1 else ''}{src('fs_write')}"
        )
        for pattern in perms.fs_write:
            print(f"    - {pattern}")
    else:
        print(f"  fs_write: none")

gear_perms_edit(gear=typer.Argument(..., help='Gear name'))

Edit user permission overrides for a gear.

Opens ~/.config/axium/overrides/permissions/.yaml in $EDITOR. If file doesn't exist, creates it with template from gear's base permissions.

Example
$ axium gear perms-edit ansible
# Opens editor at overrides/permissions/ansible.yaml

$ axium gear perms-edit newgear
# Creates template file and opens editor
Source code in axium/core/cli.py
@gear_app.command("perms-edit")
def gear_perms_edit(gear: str = typer.Argument(..., help="Gear name")) -> None:
    """
    Edit user permission overrides for a gear.

    Opens ~/.config/axium/overrides/permissions/<gear>.yaml in $EDITOR.
    If file doesn't exist, creates it with template from gear's base permissions.

    Example:
        ```bash
        $ axium gear perms-edit ansible
        # Opens editor at overrides/permissions/ansible.yaml

        $ axium gear perms-edit newgear
        # Creates template file and opens editor
        ```
    """
    import os
    import subprocess
    from pathlib import Path
    from axium.core.gears import GEARS_DIR

    overrides_dir = Path.home() / ".config" / "axium" / "overrides" / "permissions"
    overrides_dir.mkdir(parents=True, exist_ok=True)

    override_file = overrides_dir / f"{gear}.yaml"
    gear_yaml = GEARS_DIR / gear / "gear.yaml"

    # Create template if doesn't exist
    if not override_file.exists():
        if not gear_yaml.exists():
            print(f"✗ Gear not found: {gear}")
            print(f"  Expected: {gear_yaml}")
            raise typer.Exit(code=1)

        # Create template
        override_file.write_text(
            f"""# Permission overrides for gear: {gear}
# See base permissions in: {gear_yaml}
#
# Uncomment and modify as needed:
# exec: true
# notify: false
# net: true
# ipc:
#   - tmux_split_run
#   - read_file
#   - write_file
# fs_read:
#   - ~/some/path/**
# fs_write:
#   - ~/some/other/path/**
"""
        )
        print(f"✓ Created template: {override_file}")

    # Open in editor
    editor = os.getenv("EDITOR", "vi")
    try:
        subprocess.run([editor, str(override_file)], check=True)
        print(f"\n✓ Edited {override_file}")
        print(f"\nReload daemon for changes to take effect:")
        print(f"  axium daemon reload")
    except subprocess.CalledProcessError:
        print(f"✗ Failed to open editor: {editor}")
        raise typer.Exit(code=1)
    except FileNotFoundError:
        print(f"✗ Editor not found: {editor}")
        print(f"\nSet EDITOR environment variable or edit manually:")
        print(f"  {override_file}")
        raise typer.Exit(code=1)

notify_drain()

Print and clear queued notifications.

Retrieves all notifications from the daemon's queue and clears it. Useful for checking notifications emitted by spokes.

Example
$ axium notify drain
Queued Notifications (2):

[2025-10-07 16:30:00] creds: Credentials expired
  Run: aws sso login or 'axium creds refresh'

[2025-10-07 16:31:15] system: Update available
  Run: axium update
Source code in axium/core/cli.py
@notify_app.command("drain")
def notify_drain() -> None:
    """
    Print and clear queued notifications.

    Retrieves all notifications from the daemon's queue and clears it.
    Useful for checking notifications emitted by spokes.

    Example:
        ```bash
        $ axium notify drain
        Queued Notifications (2):

        [2025-10-07 16:30:00] creds: Credentials expired
          Run: aws sso login or 'axium creds refresh'

        [2025-10-07 16:31:15] system: Update available
          Run: axium update
        ```
    """
    try:
        resp = send_request_sync({"cmd": "notify_drain"})

        if not resp.get("ok"):
            print(f"✗ {resp.get('error', 'unknown error')}")
            raise typer.Exit(code=1)

        notifications = resp.get("notifications", [])

        if not notifications:
            print("No queued notifications.")
            return

        print(f"Queued Notifications ({len(notifications)}):\n")

        for notif in notifications:
            timestamp = notif.get("timestamp", "")
            spoke = notif.get("spoke", "unknown")
            title = notif.get("title", "")
            body = notif.get("body", "")

            # Format timestamp (remove Z, just show date/time)
            ts_display = timestamp.replace("T", " ").replace("Z", "").split(".")[0]

            print(f"[{ts_display}] {spoke}: {title}")
            if body:
                print(f"  {body}")
            print()

    except Exception as e:
        print(f"✗ Failed to drain notifications: {e}")
        raise typer.Exit(code=1)

notify_send(title=typer.Option(..., '--title', '-t', help='Notification title'), body=typer.Option('', '--body', '-b', help='Notification body'), level=typer.Option('info', '--level', '-l', help='Notification level'))

Send test notification via daemon.

Sends notification using "core" fake spoke identity for testing. Requires notify permission (granted by default for core).

Example
$ axium notify send --title "Test" --body "Hello world"
(silent on success)

$ axium notify send -t "Alert" -b "Something happened" -l warning
(silent on success)
Source code in axium/core/cli.py
@notify_app.command("send")
def notify_send(
    title: str = typer.Option(..., "--title", "-t", help="Notification title"),
    body: str = typer.Option("", "--body", "-b", help="Notification body"),
    level: str = typer.Option("info", "--level", "-l", help="Notification level"),
) -> None:
    """
    Send test notification via daemon.

    Sends notification using "core" fake spoke identity for testing.
    Requires notify permission (granted by default for core).

    Example:
        ```bash
        $ axium notify send --title "Test" --body "Hello world"
        (silent on success)

        $ axium notify send -t "Alert" -b "Something happened" -l warning
        (silent on success)
        ```
    """
    from axium.core.api import notify_send_cli
    from axium.core.cli_util import run_and_exit_ok

    run_and_exit_ok(lambda: notify_send_cli("core", title, body, level))

notify_list()

List queued notifications without clearing them.

Non-destructive read of notification queue. Use 'axium notify drain' to list and clear in one operation.

Example
$ axium notify list
Queued Notifications (2):

[2025-10-13 17:30:00] warning - creds: Credentials expired
  Run: aws sso login

[2025-10-13 17:31:15] info - system: Update available
  Run: axium update
Source code in axium/core/cli.py
@notify_app.command("list")
def notify_list() -> None:
    """
    List queued notifications without clearing them.

    Non-destructive read of notification queue. Use 'axium notify drain'
    to list and clear in one operation.

    Example:
        ```bash
        $ axium notify list
        Queued Notifications (2):

        [2025-10-13 17:30:00] warning - creds: Credentials expired
          Run: aws sso login

        [2025-10-13 17:31:15] info - system: Update available
          Run: axium update
        ```
    """
    from axium.core.api import drain_notifications

    # Note: Currently daemon only supports drain (removes after read)
    # This command drains but we could add a non-destructive peek in future
    notifications = drain_notifications()

    if not notifications:
        print("No queued notifications.")
        return

    print(f"Queued Notifications ({len(notifications)}):\n")

    for notif in notifications:
        timestamp = notif.get("timestamp", "")
        spoke = notif.get("spoke", "unknown")
        title = notif.get("title", "")
        body = notif.get("body", "")
        level = notif.get("level", "info")

        # Format timestamp
        ts_display = timestamp.replace("T", " ").replace("Z", "").split(".")[0]

        print(f"[{ts_display}] {level} - {spoke}: {title}")
        if body:
            print(f"  {body}")
        print()

notify_clear()

Clear all queued notifications.

Silent on success (follows silent-core pattern). Use --verbose to see confirmation message.

Example
$ axium notify clear
$ echo $?
0

$ axium notify clear --verbose
 Cleared notification queue
Source code in axium/core/cli.py
@notify_app.command("clear")
def notify_clear() -> None:
    """
    Clear all queued notifications.

    Silent on success (follows silent-core pattern).
    Use --verbose to see confirmation message.

    Example:
        ```bash
        $ axium notify clear
        $ echo $?
        0

        $ axium notify clear --verbose
        ✓ Cleared notification queue
        ```
    """
    from axium.core.api import clear_notifications

    success = clear_notifications()
    if success:
        if is_verbose():
            print("✓ Cleared notification queue")
    else:
        import sys

        print("✗ Failed to clear notifications", file=sys.stderr)
        raise typer.Exit(code=1)

config_path(spoke=typer.Argument(..., help="Spoke name (e.g., 'creds')"))

Show configuration file paths for a spoke.

Displays the base config path (bundled with spoke) and override path (in ~/.config/axium/overrides/). Indicates which files exist.

Example
$ axium config path creds
Base config (bundled):
  ~/.config/axium/spokes/creds/creds.yaml 
Override config (user editable):
  ~/.config/axium/overrides/creds.yaml  (not found)

To create override: axium config edit creds
Source code in axium/core/cli.py
@config_app.command("path")
def config_path(
    spoke: str = typer.Argument(..., help="Spoke name (e.g., 'creds')")
) -> None:
    """
    Show configuration file paths for a spoke.

    Displays the base config path (bundled with spoke) and override path
    (in ~/.config/axium/overrides/). Indicates which files exist.

    Example:
        ```bash
        $ axium config path creds
        Base config (bundled):
          ~/.config/axium/spokes/creds/creds.yaml ✓

        Override config (user editable):
          ~/.config/axium/overrides/creds.yaml ✗ (not found)

        To create override: axium config edit creds
        ```
    """
    from axium.core.config import get_config_paths

    try:
        base_path, override_path = get_config_paths(spoke)

        print("Base config (bundled):")
        if base_path.exists():
            print(f"  {base_path} ✓")
        else:
            print(f"  {base_path} ✗ (not found)")

        print("\nOverride config (user editable):")
        if override_path.exists():
            print(f"  {override_path} ✓")
        else:
            print(f"  {override_path} ✗ (not found)")

        if not override_path.exists():
            print(f"\nTo create override: axium config edit {spoke}")

    except Exception as e:
        print(f"✗ Error: {e}")
        raise typer.Exit(code=1)

config_show(spoke=typer.Argument(..., help="Spoke name (e.g., 'creds')"), key=typer.Option(None, '--key', '-k', help="Show specific key path (e.g., 'check.path')"), redact=typer.Option(False, '--redact', help='Mask sensitive values (passwords, tokens, keys)'))

Display merged configuration for a spoke via daemon.

Shows the final merged configuration (base + override + environment) loaded and cached by the daemon. Uses IPC to query the daemon.

Example
$ axium config show creds
Merged Configuration (creds):
{
  "check": {
    "type": "mtime",
    "path": "/home/user/.aws/credentials"
  }
}

$ axium config show creds --key check.path
Merged Configuration (creds, key: check.path):
/home/user/.aws/credentials

$ axium config show creds --redact
Merged Configuration (creds):
{
  "api_key": "***REDACTED***",
  "check": { ... }
}
Source code in axium/core/cli.py
@config_app.command("show")
def config_show(
    spoke: str = typer.Argument(..., help="Spoke name (e.g., 'creds')"),
    key: str
    | None = typer.Option(
        None, "--key", "-k", help="Show specific key path (e.g., 'check.path')"
    ),
    redact: bool = typer.Option(
        False, "--redact", help="Mask sensitive values (passwords, tokens, keys)"
    ),
) -> None:
    """
    Display merged configuration for a spoke via daemon.

    Shows the final merged configuration (base + override + environment)
    loaded and cached by the daemon. Uses IPC to query the daemon.

    Example:
        ```bash
        $ axium config show creds
        Merged Configuration (creds):
        {
          "check": {
            "type": "mtime",
            "path": "/home/user/.aws/credentials"
          }
        }

        $ axium config show creds --key check.path
        Merged Configuration (creds, key: check.path):
        /home/user/.aws/credentials

        $ axium config show creds --redact
        Merged Configuration (creds):
        {
          "api_key": "***REDACTED***",
          "check": { ... }
        }
        ```
    """
    import json

    from axium.core.config import redact_secrets
    from axium.core.ipc import send_request_sync

    try:
        # Send IPC request to daemon
        resp = send_request_sync(
            {"cmd": "get_config", "spoke": spoke, "key": key if key else None}
        )

        if not resp.get("ok"):
            error = resp.get("error", "unknown error")
            print(f"✗ {error}")
            raise typer.Exit(code=1)

        config_data = resp.get("config")

        # Apply redaction if requested
        if redact and isinstance(config_data, dict):
            config_data = redact_secrets(config_data)

        # Format output
        if key:
            # Specific key value
            print(f"Merged Configuration ({spoke}, key: {key}):")
            if isinstance(config_data, (dict, list)):
                print(json.dumps(config_data, indent=2))
            else:
                print(config_data)
        else:
            # Full config
            print(f"Merged Configuration ({spoke}):")
            print(json.dumps(config_data, indent=2))

    except ConnectionError:
        print("✗ Daemon not running. Start with: axium daemon start")
        raise typer.Exit(code=1)
    except Exception as e:
        print(f"✗ Error: {e}")
        raise typer.Exit(code=1)

config_edit(spoke=typer.Argument(..., help="Spoke name (e.g., 'creds')"))

Open override configuration in editor.

Opens ~/.config/axium/overrides/.yaml in $EDITOR (default: vim). Creates the file and parent directory if they don't exist.

The override file starts empty - add only the values you want to override from the base configuration.

Example
$ axium config edit creds
# Opens ~/.config/axium/overrides/creds.yaml in $EDITOR

# Example override (only customize what you need):
default:
  auto_refresh: true  # Override this value
  check:
    max_age: 43200    # Override just this nested value
Note

Set EDITOR environment variable to use your preferred editor. Falls back to vim if EDITOR is not set.

Source code in axium/core/cli.py
@config_app.command("edit")
def config_edit(
    spoke: str = typer.Argument(..., help="Spoke name (e.g., 'creds')")
) -> None:
    """
    Open override configuration in editor.

    Opens ~/.config/axium/overrides/<spoke>.yaml in $EDITOR (default: vim).
    Creates the file and parent directory if they don't exist.

    The override file starts empty - add only the values you want to override
    from the base configuration.

    Example:
        ```bash
        $ axium config edit creds
        # Opens ~/.config/axium/overrides/creds.yaml in $EDITOR

        # Example override (only customize what you need):
        default:
          auto_refresh: true  # Override this value
          check:
            max_age: 43200    # Override just this nested value
        ```

    Note:
        Set EDITOR environment variable to use your preferred editor.
        Falls back to vim if EDITOR is not set.
    """
    import os
    import subprocess
    from pathlib import Path

    from axium.core.config import create_override_template

    try:
        # Get override path
        override_path = (
            Path.home() / ".config" / "axium" / "overrides" / f"{spoke}.yaml"
        )

        # Create parent directory if needed
        override_path.parent.mkdir(parents=True, exist_ok=True)

        # If file doesn't exist, create minimal template
        if not override_path.exists():
            template = create_override_template(spoke)
            override_path.write_text(template)
            print(f"Created: {override_path}")

        # Open in editor
        editor = os.getenv("EDITOR", "vim")
        subprocess.run([editor, str(override_path)])

        print(f"\nOverride saved: {override_path}")
        print("Changes take effect on next spoke reload or daemon restart.")

    except Exception as e:
        print(f"✗ Error editing config: {e}")
        raise typer.Exit(code=1)

daemon_start(debug=typer.Option(False, '--debug', help='Run in foreground with logs to stdout'))

Start the Axium daemon process.

The daemon manages state, handles IPC requests, and coordinates events. By default, starts as a background process with logs to ~/.config/axium/axiumd.log.

Parameters:

Name Type Description Default
debug bool

If True, run in foreground with logs to stdout (for development)

Option(False, '--debug', help='Run in foreground with logs to stdout')
Example
$ axium daemon start
(silent on success)

$ axium daemon start --debug
[INFO] Axium daemon starting (debug=True)
...
Source code in axium/core/cli.py
@daemon_app.command("start")
def daemon_start(
    debug: bool = typer.Option(
        False, "--debug", help="Run in foreground with logs to stdout"
    )
) -> None:
    """
    Start the Axium daemon process.

    The daemon manages state, handles IPC requests, and coordinates events.
    By default, starts as a background process with logs to ~/.config/axium/axiumd.log.

    Args:
        debug: If True, run in foreground with logs to stdout (for development)

    Example:
        ```bash
        $ axium daemon start
        (silent on success)

        $ axium daemon start --debug
        [INFO] Axium daemon starting (debug=True)
        ...
        ```
    """
    from axium.core.api import daemon_start as api_daemon_start
    from axium.core.cli_util import run_and_exit_ok

    # Debug mode runs in foreground and prints, don't use silent pattern
    if debug:
        run_and_exit_ok(lambda: api_daemon_start(debug=True))
    else:
        run_and_exit_ok(api_daemon_start)

daemon_stop()

Stop the Axium daemon process.

Sends a stop command via IPC. If daemon is unresponsive, attempts to send SIGTERM to the PID from ~/.config/axium/axiumd.pid.

Example
$ axium daemon stop
(silent on success, exits 1 on failure)
Source code in axium/core/cli.py
@daemon_app.command("stop")
def daemon_stop() -> None:
    """
    Stop the Axium daemon process.

    Sends a stop command via IPC. If daemon is unresponsive, attempts
    to send SIGTERM to the PID from ~/.config/axium/axiumd.pid.

    Example:
        ```bash
        $ axium daemon stop
        (silent on success, exits 1 on failure)
        ```
    """
    from axium.core.api import daemon_stop as api_daemon_stop
    from axium.core.cli_util import run_and_exit_ok

    run_and_exit_ok(api_daemon_stop)

daemon_status()

Check daemon status and liveness.

Displays PID, socket path, and whether the daemon responds to ping.

Example
$ axium daemon status
{'pid': 12345, 'socket': '/tmp/axiumd.sock', 'socket_exists': True, 'alive': True}
Source code in axium/core/cli.py
@daemon_app.command("status")
def daemon_status() -> None:
    """
    Check daemon status and liveness.

    Displays PID, socket path, and whether the daemon responds to ping.

    Example:
        ```bash
        $ axium daemon status
        {'pid': 12345, 'socket': '/tmp/axiumd.sock', 'socket_exists': True, 'alive': True}
        ```
    """
    import asyncio

    from axium.core.api import daemon_status as api_daemon_status

    try:
        stat = api_daemon_status()
        if stat:
            print(stat)
        else:
            print("✗ Daemon not responding")
            print("  Start with: axium daemon start")
            raise typer.Exit(1)
    except typer.Exit:
        raise
    except asyncio.TimeoutError:
        print("✗ Daemon not responding (timeout after 2s)")
        print("  Check if daemon is running: axium daemon logs")
        raise typer.Exit(1)
    except (FileNotFoundError, ConnectionRefusedError):
        print("✗ Daemon not running")
        print("  Start with: axium daemon start")
        raise typer.Exit(1)
    except Exception as e:
        print(f"✗ Failed to get daemon status: {type(e).__name__}")
        print(f"  {e}")
        raise typer.Exit(1)

daemon_logs(follow=typer.Option(False, '--follow', '-f', help='Follow log output (tail -f)'), lines=typer.Option(50, '--lines', '-n', help='Number of lines to show'))

View daemon logs from ~/.config/axium/axiumd.log.

Parameters:

Name Type Description Default
follow bool

If True, continuously tail the log file (like tail -f)

Option(False, '--follow', '-f', help='Follow log output (tail -f)')
lines int

Number of lines to display (default: 50)

Option(50, '--lines', '-n', help='Number of lines to show')
Example
$ axium daemon logs
[Shows last 50 lines]

$ axium daemon logs -f
[Follows log output, Ctrl+C to exit]

$ axium daemon logs -n 100
[Shows last 100 lines]

Raises:

Type Description
Exit

If log file doesn't exist or can't be read

Source code in axium/core/cli.py
@daemon_app.command("logs")
def daemon_logs(
    follow: bool = typer.Option(
        False, "--follow", "-f", help="Follow log output (tail -f)"
    ),
    lines: int = typer.Option(50, "--lines", "-n", help="Number of lines to show"),
) -> None:
    """
    View daemon logs from ~/.config/axium/axiumd.log.

    Args:
        follow: If True, continuously tail the log file (like tail -f)
        lines: Number of lines to display (default: 50)

    Example:
        ```bash
        $ axium daemon logs
        [Shows last 50 lines]

        $ axium daemon logs -f
        [Follows log output, Ctrl+C to exit]

        $ axium daemon logs -n 100
        [Shows last 100 lines]
        ```

    Raises:
        typer.Exit: If log file doesn't exist or can't be read
    """
    import subprocess
    from pathlib import Path

    log_path = Path.home() / ".config" / "axium" / "axiumd.log"

    if not log_path.exists():
        print("axium: no log file found at", log_path)
        raise typer.Exit(1)

    try:
        if follow:
            subprocess.run(["tail", "-f", str(log_path)])
        else:
            subprocess.run(["tail", "-n", str(lines), str(log_path)])
    except KeyboardInterrupt:
        pass
    except Exception as e:
        print(f"axium: error reading logs: {e}")
        raise typer.Exit(1)

daemon_reload()

Reload daemon state from disk.

Forces the daemon to re-read state.json and refresh its internal state. Useful after manually editing configuration files.

Example
$ axium daemon reload
(silent on success, exits 1 on failure)
Note

Requires daemon to be running. Does not reload Spokes (restart required).

Source code in axium/core/cli.py
@daemon_app.command("reload")
def daemon_reload() -> None:
    """
    Reload daemon state from disk.

    Forces the daemon to re-read state.json and refresh its internal state.
    Useful after manually editing configuration files.

    Example:
        ```bash
        $ axium daemon reload
        (silent on success, exits 1 on failure)
        ```

    Note:
        Requires daemon to be running. Does not reload Spokes (restart required).
    """
    from axium.core.api import reload_daemon
    from axium.core.cli_util import run_and_exit_ok

    run_and_exit_ok(reload_daemon)

daemon_restart()

Restart the Axium daemon process.

Stops the daemon gracefully, waits for clean shutdown, then starts it again. This is equivalent to running 'axium daemon stop' followed by 'axium daemon start', but handles the timing automatically.

Use this when you need to: - Apply configuration changes that require a full restart - Reset daemon state completely - Recover from daemon issues

Example
$ axium daemon restart
(silent on success, exits 1 on failure)

$ axium daemon restart --verbose
 Daemon restarted successfully
Note

This will reset daemon uptime and reload all spokes/gears. For config-only changes, use 'axium daemon reload' instead.

Source code in axium/core/cli.py
@daemon_app.command("restart")
def daemon_restart() -> None:
    """
    Restart the Axium daemon process.

    Stops the daemon gracefully, waits for clean shutdown, then starts it again.
    This is equivalent to running 'axium daemon stop' followed by 'axium daemon start',
    but handles the timing automatically.

    Use this when you need to:
    - Apply configuration changes that require a full restart
    - Reset daemon state completely
    - Recover from daemon issues

    Example:
        ```bash
        $ axium daemon restart
        (silent on success, exits 1 on failure)

        $ axium daemon restart --verbose
        ✓ Daemon restarted successfully
        ```

    Note:
        This will reset daemon uptime and reload all spokes/gears.
        For config-only changes, use 'axium daemon reload' instead.
    """
    from axium.core.api import daemon_restart as api_daemon_restart
    from axium.core.cli_util import run_and_exit_ok

    run_and_exit_ok(api_daemon_restart)

env_set(name)

Set the active environment.

Updates daemon state and triggers env_change events for Spokes to react. The environment must exist in ~/.config/axium/envs.yaml.

In tmux: Sets environment for current pane and exports AXIUM_ENV to pane. Outside tmux: Sets global environment.

Parameters:

Name Type Description Default
name str | None

Environment name from envs.yaml (e.g., "prod", "dev", "staging")

required
Example
$ axium env set prod
(silent on success)

$ axium env set prod  # in tmux pane %1
(silent on success)
Note

Requires daemon to be running. Emits env_change event to all Spokes. In tmux, also calls 'tmux setenv -t AXIUM_ENV '.

Source code in axium/core/cli.py
@env_app.command("set")
def env_set(name: str | None) -> None:
    """
    Set the active environment.

    Updates daemon state and triggers env_change events for Spokes to react.
    The environment must exist in ~/.config/axium/envs.yaml.

    In tmux: Sets environment for current pane and exports AXIUM_ENV to pane.
    Outside tmux: Sets global environment.

    Args:
        name: Environment name from envs.yaml (e.g., "prod", "dev", "staging")

    Example:
        ```bash
        $ axium env set prod
        (silent on success)

        $ axium env set prod  # in tmux pane %1
        (silent on success)
        ```

    Note:
        Requires daemon to be running. Emits env_change event to all Spokes.
        In tmux, also calls 'tmux setenv -t <pane> AXIUM_ENV <env>'.
    """
    import os
    import subprocess
    import sys

    from axium.core import env as env_module
    from axium.core.api import set_active_env

    # Pre-validate environment name for immediate feedback
    is_valid, error = env_module.validate_env_name(name)
    if not is_valid:
        print(f"axium: {error}", file=sys.stderr)
        raise typer.Exit(1)

    pane_id = os.getenv("TMUX_PANE")

    # Set environment via API
    ok = set_active_env(name, pane_id=pane_id)

    # If successful and in tmux, update tmux pane variable
    if ok and pane_id:
        try:
            subprocess.run(
                ["tmux", "setenv", "-t", pane_id, "AXIUM_ENV", name],
                check=True,
                capture_output=True,
            )
        except Exception:
            pass  # Non-fatal if tmux setenv fails

    raise typer.Exit(code=0 if ok else 1)

env_get(pane=typer.Option(None, '--pane', help='Get environment for specific pane ID (e.g., %1)'))

Get the active environment name.

In tmux: Queries daemon for current pane's environment. Outside tmux: Queries daemon for global active_env.

Parameters:

Name Type Description Default
pane str | None

Optional pane ID to query. If None, uses TMUX_PANE or global.

Option(None, '--pane', help='Get environment for specific pane ID (e.g., %1)')
Example
$ axium env get
prod

$ axium env get --pane %2
builder

$ axium env get  # in tmux pane %1
root
Note

Requires daemon to be running. Returns None if no environment is set.

Source code in axium/core/cli.py
@env_app.command("get")
def env_get(
    pane: str
    | None = typer.Option(
        None, "--pane", help="Get environment for specific pane ID (e.g., %1)"
    )
) -> None:
    """
    Get the active environment name.

    In tmux: Queries daemon for current pane's environment.
    Outside tmux: Queries daemon for global active_env.

    Args:
        pane: Optional pane ID to query. If None, uses TMUX_PANE or global.

    Example:
        ```bash
        $ axium env get
        prod

        $ axium env get --pane %2
        builder

        $ axium env get  # in tmux pane %1
        root
        ```

    Note:
        Requires daemon to be running. Returns None if no environment is set.
    """
    import os

    from axium.core.api import get_active_env, get_pane_env

    # Determine which pane to query
    pane_id = pane or os.getenv("TMUX_PANE")

    if pane_id:
        # Query pane-specific environment
        env_name = get_pane_env(pane_id)
    else:
        # Query global environment
        env_name = get_active_env()

    if env_name:
        print(env_name)
    else:
        print("✗ No environment set or daemon not running")
        print("  Start daemon: axium daemon start")
        print("  Set environment: axium env set <name>")
        raise typer.Exit(1)

env_list(show_panes=typer.Option(False, '--panes', help='Show per-pane environment mappings'))

List all available environments from envs.yaml.

Displays all defined environments with their property keys. The active environment is marked with *.

Parameters:

Name Type Description Default
show_panes bool

If True, also displays pane-to-environment mappings from daemon

Option(False, '--panes', help='Show per-pane environment mappings')
Example
$ axium env list
Available environments:
  * prod (prefix, aws_profile, region, color)
    dev (prefix, aws_profile, region, color)
    staging (prefix, region)

$ axium env list --panes
Available environments:
  * prod (prefix, aws_profile, region, color)
    dev (prefix, aws_profile, region, color)

Pane Mappings:
  %1  root
  %2  builder
  %3  prod
Note

Reads directly from ~/.config/axium/envs.yaml (no daemon required). --panes requires daemon to be running.

Source code in axium/core/cli.py
@env_app.command("list")
def env_list(
    show_panes: bool = typer.Option(
        False, "--panes", help="Show per-pane environment mappings"
    )
) -> None:
    """
    List all available environments from envs.yaml.

    Displays all defined environments with their property keys.
    The active environment is marked with *.

    Args:
        show_panes: If True, also displays pane-to-environment mappings from daemon

    Example:
        ```bash
        $ axium env list
        Available environments:
          * prod (prefix, aws_profile, region, color)
            dev (prefix, aws_profile, region, color)
            staging (prefix, region)

        $ axium env list --panes
        Available environments:
          * prod (prefix, aws_profile, region, color)
            dev (prefix, aws_profile, region, color)

        Pane Mappings:
          %1 → root
          %2 → builder
          %3 → prod
        ```

    Note:
        Reads directly from ~/.config/axium/envs.yaml (no daemon required).
        --panes requires daemon to be running.
    """
    from axium.core import env

    envs = env.load_envs()
    if not envs:
        print("No environments found in ~/.config/axium/envs.yaml")
        return

    active = env.get_active_env_name()
    print("Available environments:")
    for name, props in envs.items():
        marker = "*" if name == active else " "
        keys = ", ".join(props.keys())
        print(f"  {marker} {name} ({keys})")

    # Show pane mappings if requested
    if show_panes:
        try:
            resp = send_request_sync({"cmd": "get_state"})
            state = resp.get("state", {})
            panes = state.get("panes", {})

            if panes:
                print("\nPane Mappings:")
                for pane_id, env_name in sorted(panes.items()):
                    print(f"  {pane_id}{env_name}")
            else:
                print("\nNo pane mappings (no panes have set environments)")
        except Exception as e:
            print(f"\naxium: cannot fetch pane mappings ({e}). Try: axium daemon start")

env_show(name=typer.Argument(None))

Show properties for an environment.

Displays all key-value pairs for the specified environment. If no name is provided, shows the active environment (checking pane-specific environment in tmux, then falling back to global).

Parameters:

Name Type Description Default
name str | None

Optional environment name. If None, uses active environment.

Argument(None)
Example
$ axium env show prod
Environment: prod
  prefix: enva-prod
  aws_profile: production
  region: us-east-1
  color: red

$ axium env show
Environment: dev (pane %1)
  prefix: enva-dev
  region: eu-west-1

$ axium env show  # outside tmux
Environment: root (global)
  prefix: enva-root
  region: eu-west-1
Note

Reads from ~/.config/axium/envs.yaml for properties. Queries daemon for active environment (pane-specific or global).

Source code in axium/core/cli.py
@env_app.command("show")
def env_show(name: str | None = typer.Argument(None)) -> None:
    """
    Show properties for an environment.

    Displays all key-value pairs for the specified environment.
    If no name is provided, shows the active environment (checking pane-specific
    environment in tmux, then falling back to global).

    Args:
        name: Optional environment name. If None, uses active environment.

    Example:
        ```bash
        $ axium env show prod
        Environment: prod
          prefix: enva-prod
          aws_profile: production
          region: us-east-1
          color: red

        $ axium env show
        Environment: dev (pane %1)
          prefix: enva-dev
          region: eu-west-1

        $ axium env show  # outside tmux
        Environment: root (global)
          prefix: enva-root
          region: eu-west-1
        ```

    Note:
        Reads from ~/.config/axium/envs.yaml for properties.
        Queries daemon for active environment (pane-specific or global).
    """
    import os

    from axium.core import env
    from axium.core.api import get_active_env, get_pane_env

    env_source = None  # Track where we got the env from

    if name is None:
        # First try pane-specific environment (if in tmux)
        pane_id = os.getenv("TMUX_PANE")
        if pane_id:
            try:
                name = get_pane_env(pane_id)
                if name:
                    env_source = f"pane {pane_id}"
            except Exception:
                pass

        # Fall back to global active environment
        if not name:
            try:
                name = get_active_env()
                if name:
                    env_source = "global"
            except Exception:
                pass

        # Fall back to reading from state.json directly (no daemon)
        if not name:
            name = env.get_active_env_name()
            if name:
                env_source = "global"

        if not name:
            print("No active environment set. Use: axium env set <name>")
            return

    envs = env.load_envs()
    env_data = envs.get(name)

    if not env_data:
        print(f"Environment '{name}' not found in envs.yaml")
        return

    # Display environment name with source
    if env_source:
        print(f"Environment: {name} ({env_source})")
    else:
        print(f"Environment: {name}")

    for key, value in env_data.items():
        print(f"  {key}: {value}")

run_cmd(ctx, command)

Execute a command with registered prefix rules applied.

This is the core of Axium's command wrapping. If the command has a prefix rule in prefixes.yaml, the prefix is applied based on the current context (environment, tmux pane, etc.).

Parameters:

Name Type Description Default
ctx Context

Typer context for accessing extra arguments

required
command str

Command to execute (e.g., "aws", "terraform")

required
Example
$ axium run aws s3 ls
# If active env is 'prod' with prefix 'enva-prod':
# Actually executes: enva-prod aws s3 ls

$ axium run terraform plan
# With prefix rules, becomes: enva-prod terraform plan

$ axium run aws s3 cp --recursive mydir s3://bucket/
# External flags like --recursive are passed through transparently
Note
  • Falls back to unwrapped command if daemon unreachable
  • Falls back to unwrapped if prefix command not found
  • Exits with same return code as the executed command
  • Context includes: TMUX_PANE, active_env
  • All arguments after command are passed through without parsing
Source code in axium/core/cli.py
@app.command(
    "run", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}
)
def run_cmd(ctx: typer.Context, command: str) -> None:
    """
    Execute a command with registered prefix rules applied.

    This is the core of Axium's command wrapping. If the command has a prefix
    rule in prefixes.yaml, the prefix is applied based on the current context
    (environment, tmux pane, etc.).

    Args:
        ctx: Typer context for accessing extra arguments
        command: Command to execute (e.g., "aws", "terraform")

    Example:
        ```bash
        $ axium run aws s3 ls
        # If active env is 'prod' with prefix 'enva-prod':
        # Actually executes: enva-prod aws s3 ls

        $ axium run terraform plan
        # With prefix rules, becomes: enva-prod terraform plan

        $ axium run aws s3 cp --recursive mydir s3://bucket/
        # External flags like --recursive are passed through transparently
        ```

    Note:
        - Falls back to unwrapped command if daemon unreachable
        - Falls back to unwrapped if prefix command not found
        - Exits with same return code as the executed command
        - Context includes: TMUX_PANE, active_env
        - All arguments after command are passed through without parsing
    """
    import os
    import subprocess
    import sys

    from axium.core.api import get_active_env, get_pane_env

    # Collect all remaining arguments from context
    args = list(ctx.args)

    # Check if this should route to a spoke command instead
    # e.g., "axium run aws whoami" -> "axium aws whoami"
    if args:
        import logging

        logger = logging.getLogger("axium.cli")

        try:
            # Search through registered_groups (spokes/gears are added as groups)
            for group_info in app.registered_groups:
                if group_info.name == command:
                    # Found matching spoke/gear group
                    typer_group = group_info.typer_instance

                    # Check if first arg is a registered subcommand
                    subcommand_names = [
                        cmd.name for cmd in typer_group.registered_commands
                    ]
                    if args[0] in subcommand_names:
                        # Route to spoke/gear command
                        logger.debug(
                            f"Routing 'axium run {command} {args[0]}' to spoke command"
                        )

                        # Invoke the spoke command directly
                        # We need to call the typer app with the subcommand args
                        sys.argv = [command] + args  # Set argv for typer parsing
                        typer_group(args, standalone_mode=True)
                        # If we get here without exception, it worked
                        return

                    # First arg is not a spoke subcommand, fall through to prefix application
                    logger.debug(
                        f"Arg '{args[0]}' not in spoke subcommands: {subcommand_names}"
                    )
                    break

        except SystemExit:
            # Typer uses SystemExit for normal exits - re-raise it
            raise
        except Exception as e:
            # Not a spoke command or failed lookup - continue to prefix application
            logger.debug(f"Spoke routing failed: {e}")

    # Build context for prefix expansion
    pane_id = os.getenv("TMUX_PANE")
    context = {
        "pane": pane_id,
        "env": None,
    }

    # Get current environment (pane-specific first, then global)
    if pane_id:
        try:
            # Try pane-specific environment first
            context["env"] = get_pane_env(pane_id)
        except Exception:
            pass

    # Fall back to global active environment if no pane env
    if not context["env"]:
        try:
            context["env"] = get_active_env()
        except Exception:
            pass

    # Ask daemon to apply prefix rules
    try:
        resp = send_request_sync(
            {
                "cmd": "apply_prefixes",
                "command": command,
                "args": args,
                "context": context,
            }
        )
        if resp.get("ok"):
            final_cmd = resp.get("command", [command] + args)
            env_vars = resp.get("env_vars", {})

            # Execute with modified environment
            env = os.environ.copy()
            env.update(env_vars)

            # Try direct execution first
            try:
                result = subprocess.run(final_cmd, env=env)
                sys.exit(result.returncode)
            except FileNotFoundError:
                # Prefix command might be a shell function, try through interactive shell
                import logging
                import shlex

                prefix_cmd = final_cmd[0]
                logger = logging.getLogger("axium.cli")
                logger.debug(
                    f"Prefix command '{prefix_cmd}' not found as binary, trying shell execution"
                )

                # Get user's shell
                user_shell = os.environ.get("SHELL", "/bin/bash")

                # Build shell command string
                shell_cmd = " ".join(shlex.quote(arg) for arg in final_cmd)

                # Execute through interactive shell to load functions
                result = subprocess.run(
                    [user_shell, "-ic", shell_cmd],
                    env=env,
                    stderr=subprocess.PIPE,
                    text=True,
                )

                # Filter out shell initialization warnings from stderr
                if result.stderr:
                    # Only print stderr lines that aren't shell init warnings
                    for line in result.stderr.splitlines():
                        if not any(
                            skip in line
                            for skip in [
                                "command not found: complete",
                                "command not found: axium_tmux_enable_palette",
                            ]
                        ):
                            print(line, file=sys.stderr)

                sys.exit(result.returncode)
        else:
            # Error from daemon, fall back to unwrapped
            result = subprocess.run([command] + args)
            sys.exit(result.returncode)
    except FileNotFoundError:
        # Command not found even after fallbacks
        print(f"axium: command not found: {command}", file=sys.stderr)
        sys.exit(127)
    except Exception as e:
        # Daemon not reachable or other error, fall back to unwrapped
        import logging

        logger = logging.getLogger("axium.cli")
        logger.debug(f"Error applying prefixes: {e}, falling back to unwrapped command")
        try:
            result = subprocess.run([command] + args)
            sys.exit(result.returncode)
        except FileNotFoundError:
            print(f"axium: command not found: {command}", file=sys.stderr)
            sys.exit(127)

hud_callback(ctx, pane=typer.Option(None, '--pane', help='Show HUD for specific pane ID (e.g., %1)'))

Display HUD status line for tmux/shell prompts.

Generates a one-line status display showing environment, uptime, and other context. Designed to be called from tmux status line or shell prompt.

Uses fast path (cached HUD from daemon) when --pane is specified for instant response times suitable for frequent tmux refreshes.

Parameters:

Name Type Description Default
pane str | None

Optional pane ID to show pane-specific environment context

Option(None, '--pane', help='Show HUD for specific pane ID (e.g., %1)')
Example
$ axium hud
[axium] env:prod  uptime:2h15m

$ axium hud --pane %1
[axium] pane:%1  env:root  uptime:2h15m

$ axium hud  # daemon not running
[axium] inactive

Usage in tmux.conf:

set -g status-right '#(axium hud --pane #D)'

Note

Gracefully handles daemon being down (shows "inactive"). Output format suitable for tmux status-right or PS1. In tmux, #D expands to the pane ID (e.g., %1). Fast path bypasses spoke providers for instant response.

Source code in axium/core/cli.py
@hud_app.callback(invoke_without_command=True)
def hud_callback(
    ctx: typer.Context,
    pane: str
    | None = typer.Option(
        None, "--pane", help="Show HUD for specific pane ID (e.g., %1)"
    ),
) -> None:
    """
    Display HUD status line for tmux/shell prompts.

    Generates a one-line status display showing environment, uptime, and other
    context. Designed to be called from tmux status line or shell prompt.

    Uses fast path (cached HUD from daemon) when --pane is specified for
    instant response times suitable for frequent tmux refreshes.

    Args:
        pane: Optional pane ID to show pane-specific environment context

    Example:
        ```bash
        $ axium hud
        [axium] env:prod  uptime:2h15m

        $ axium hud --pane %1
        [axium] pane:%1  env:root  uptime:2h15m

        $ axium hud  # daemon not running
        [axium] inactive
        ```

    Usage in tmux.conf:
        ```
        set -g status-right '#(axium hud --pane #D)'
        ```

    Note:
        Gracefully handles daemon being down (shows "inactive").
        Output format suitable for tmux status-right or PS1.
        In tmux, #D expands to the pane ID (e.g., %1).
        Fast path bypasses spoke providers for instant response.
    """
    # Only run if no subcommand invoked
    if ctx.invoked_subcommand is not None:
        return

    import os
    import tempfile
    from pathlib import Path

    # Auto-detect pane if not specified and in tmux
    pane_id = pane or os.getenv("TMUX_PANE")

    if pane_id:
        # Fast path: Use cached HUD from daemon for instant response
        # Check if daemon socket exists (more reliable than PID file)
        socket_path = Path(tempfile.gettempdir()) / "axiumd.sock"

        if not socket_path.exists():
            print("[axium] inactive")
            return

        try:
            # Use 500ms timeout to allow for IPC overhead + HUD rendering
            # Cached HUD returns in ~50-150ms, first-time render ~200-350ms
            resp = send_request_sync({"cmd": "get_hud", "pane": pane_id}, timeout=0.5)
            if resp.get("ok"):
                print(resp.get("hud", "[axium] inactive"))
            else:
                print("[axium] inactive")
        except Exception:
            # Daemon not responding or error - show inactive
            print("[axium] inactive")
    else:
        # Global mode: Use legacy hud.main() (includes spoke providers)
        from axium.core.hud import main as hud_main

        print(hud_main())

hud_reload_cmd()

Reload HUD configuration and refresh all panes.

Forces the daemon to reload hud.yaml and regenerate HUD cache for all active panes. Use this after modifying hud.yaml to apply changes.

Example
$ axium hud reload
 HUD reloaded
Note

Requires daemon to be running. Returns exit code 1 if reload fails.

Source code in axium/core/cli.py
@hud_app.command("reload")
def hud_reload_cmd() -> None:
    """
    Reload HUD configuration and refresh all panes.

    Forces the daemon to reload hud.yaml and regenerate HUD cache for all
    active panes. Use this after modifying hud.yaml to apply changes.

    Example:
        ```bash
        $ axium hud reload
        ✓ HUD reloaded
        ```

    Note:
        Requires daemon to be running. Returns exit code 1 if reload fails.
    """
    try:
        resp = send_request_sync({"cmd": "reload"})
        if resp.get("ok"):
            print("✓ HUD reloaded")
        else:
            print("✗ Failed to reload HUD")
            raise typer.Exit(1)
    except Exception as e:
        print(f"✗ Failed to reload HUD: {e}")
        raise typer.Exit(1)

hud_theme_cmd(action=typer.Argument(..., help='Action: list, show, set <name>'), theme_name=typer.Argument(None, help="Theme name for 'set' action"))

Manage HUD color themes.

Themes provide customizable color schemes for HUD output. Themes are disabled by default and can be enabled by setting a theme in hud.yaml.

Available themes
  • classic: Standard ANSI colors (white, cyan, yellow, red, green)
  • teal: Axium brand colors (#00B7C7 teal, soft white)
  • dim: Dimmed colors for low-contrast terminals
  • inverted: Dark colors for light backgrounds
  • mono: No colors (plain text)
  • soft: Pastel colors for gentle aesthetics

Parameters:

Name Type Description Default
action str

Action to perform (list, show, set)

Argument(..., help='Action: list, show, set <name>')
theme_name str | None

Theme name when using 'set' action

Argument(None, help="Theme name for 'set' action")
Example
$ axium hud theme list
Available themes:
  - classic
  - dim
  - inverted
  - mono
  - soft
  - teal

$ axium hud theme show
Theme: classic
Enabled: false

$ axium hud theme set teal
Theme set to 'teal'
Note

Themes are opt-in to preserve Axium's silent design philosophy. Edit ~/.config/axium/hud.yaml to manually enable/disable themes.

Source code in axium/core/cli.py
@hud_app.command("theme")
def hud_theme_cmd(
    action: str = typer.Argument(..., help="Action: list, show, set <name>"),
    theme_name: str | None = typer.Argument(None, help="Theme name for 'set' action"),
) -> None:
    """
    Manage HUD color themes.

    Themes provide customizable color schemes for HUD output. Themes are
    disabled by default and can be enabled by setting a theme in hud.yaml.

    Available themes:
        - classic: Standard ANSI colors (white, cyan, yellow, red, green)
        - teal: Axium brand colors (#00B7C7 teal, soft white)
        - dim: Dimmed colors for low-contrast terminals
        - inverted: Dark colors for light backgrounds
        - mono: No colors (plain text)
        - soft: Pastel colors for gentle aesthetics

    Args:
        action: Action to perform (list, show, set)
        theme_name: Theme name when using 'set' action

    Example:
        ```bash
        $ axium hud theme list
        Available themes:
          - classic
          - dim
          - inverted
          - mono
          - soft
          - teal

        $ axium hud theme show
        Theme: classic
        Enabled: false

        $ axium hud theme set teal
        Theme set to 'teal'
        ```

    Note:
        Themes are opt-in to preserve Axium's silent design philosophy.
        Edit ~/.config/axium/hud.yaml to manually enable/disable themes.
    """
    from axium.core.hud_themes import list_themes

    if action == "list":
        print("Available themes:")
        for theme in list_themes():
            print(f"  - {theme}")
    elif action == "show":
        # Read current theme from hud.yaml
        try:
            import yaml
            from pathlib import Path

            hud_yaml = Path.home() / ".config" / "axium" / "hud.yaml"
            if not hud_yaml.exists():
                print("hud.yaml not found - run 'axium bootstrap' first")
                raise typer.Exit(1)

            with open(hud_yaml) as f:
                config = yaml.safe_load(f)

            theme_config = config.get("style", {}).get("theme", {})
            theme_name = theme_config.get("name", "classic")
            theme_enabled = theme_config.get("enabled", False)

            print(f"Theme: {theme_name}")
            print(f"Enabled: {str(theme_enabled).lower()}")
        except Exception as e:
            print(f"Failed to read theme config: {e}")
            raise typer.Exit(1)
    elif action == "set":
        if not theme_name:
            print("Theme name required for 'set' action")
            print("Usage: axium hud theme set <name>")
            raise typer.Exit(1)

        # Validate theme name
        if theme_name not in list_themes():
            print(f"Invalid theme: {theme_name}")
            print(f"Available themes: {', '.join(list_themes())}")
            raise typer.Exit(1)

        # Update hud.yaml
        try:
            import yaml
            from pathlib import Path

            hud_yaml = Path.home() / ".config" / "axium" / "hud.yaml"
            if not hud_yaml.exists():
                print("hud.yaml not found - run 'axium bootstrap' first")
                raise typer.Exit(1)

            with open(hud_yaml) as f:
                config = yaml.safe_load(f)

            # Update theme config
            if "style" not in config:
                config["style"] = {}
            if "theme" not in config["style"]:
                config["style"]["theme"] = {}

            config["style"]["theme"]["name"] = theme_name
            config["style"]["theme"]["enabled"] = True  # Enable when setting

            with open(hud_yaml, "w") as f:
                yaml.safe_dump(config, f, default_flow_style=False)

            print(f"Theme set to '{theme_name}'")

            # Reload daemon to apply changes immediately
            from axium.core.ipc import send_request_sync

            try:
                send_request_sync({"cmd": "reload"})
            except Exception:
                pass  # Daemon may not be running, that's ok
        except Exception as e:
            print(f"Failed to update theme: {e}")
            raise typer.Exit(1)
    else:
        print(f"Unknown action: {action}")
        print("Valid actions: list, show, set <name>")
        raise typer.Exit(1)

palette_cmd(reload=typer.Option(False, '--reload', help='Reload Spokes and refresh command list'))

Launch the interactive palette TUI.

Opens a curses-based menu for quick access to all Axium commands (core and Spokes). Commands are discovered dynamically from the command registry. Navigate with arrow keys or j/k, select with Enter, quit with q/Esc.

Parameters:

Name Type Description Default
reload bool

Force reload of Spokes and refresh command registry

Option(False, '--reload', help='Reload Spokes and refresh command list')
Example
$ axium palette
[Opens interactive menu with all available commands]

$ axium palette --reload
[Reloads Spokes and opens menu with refreshed command list]
Key Bindings
↑/k: Move up
↓/j: Move down
Enter: Execute selected command
q/Esc: Quit
Note

Requires terminal with curses support. Commands execute in foreground, press Enter after completion to return to palette.

Source code in axium/core/cli.py
@app.command("palette")
def palette_cmd(
    reload: bool = typer.Option(
        False, "--reload", help="Reload Spokes and refresh command list"
    )
) -> None:
    """
    Launch the interactive palette TUI.

    Opens a curses-based menu for quick access to all Axium commands (core and Spokes).
    Commands are discovered dynamically from the command registry.
    Navigate with arrow keys or j/k, select with Enter, quit with q/Esc.

    Args:
        reload: Force reload of Spokes and refresh command registry

    Example:
        ```bash
        $ axium palette
        [Opens interactive menu with all available commands]

        $ axium palette --reload
        [Reloads Spokes and opens menu with refreshed command list]
        ```

    Key Bindings:
        ```
        ↑/k: Move up
        ↓/j: Move down
        Enter: Execute selected command
        q/Esc: Quit
        ```

    Note:
        Requires terminal with curses support. Commands execute in foreground,
        press Enter after completion to return to palette.
    """
    import curses

    from axium.core import palette as pal
    from axium.core import registry

    if reload:
        # Clear registry and reload spokes
        registry.clear_registry()
        load_spokes(app)
        registry.introspect_typer_app(app, source="core")

    curses.wrapper(pal.main)

main()

CLI entrypoint.

Called when axium is invoked from the command line (via setup.py entry point). Initializes Typer app and processes commands.

Source code in axium/core/cli.py
def main() -> None:
    """
    CLI entrypoint.

    Called when axium is invoked from the command line (via setup.py entry point).
    Initializes Typer app and processes commands.
    """
    import sys

    # Intercept Typer's legacy completion flags for backward compatibility
    # Redirect users to new `axium completions install` system
    if "--install-completion" in sys.argv or "--show-completion" in sys.argv:
        sys.argv = ["axium", "completions", "install"]

    # Load spokes before dispatching command
    # This must happen here (not in callback) so Typer can find spoke commands
    if not _autoload_done:
        _autoload()

    app()