# CLI Slash Command Hook Specification v2

## Overview

Allow slash commands to be invoked as CLI flags for scripting and automation.

```bash
kollab --sub list                # Execute /sub list and exit
kollab --sub list --stay         # Execute /sub list, then interactive mode
```

## Design Principles

1. **Exit by default** - CLI commands are tools, they exit when done (POSIX-style)
2. **Single source of truth** - One CommandDefinition, works everywhere
3. **Type safe** - Dataclasses, not dicts
4. **Secure** - Validation, sanitization, permission integration
5. **Fast** - Event-based synchronization, no blind sleeps
6. **Discoverable** - Clear errors, help integration

## Requirements

1. CLI invocation is opt-in via `cli_hidden=False` (safety for existing commands)
2. Commands exit by default, `--stay` to continue to interactive mode
3. Full permission system integration
4. Type-safe registration
5. Reserved namespace protection (core commands can't be overridden)
6. Non-TTY detection and output sanitization

## Architecture

### Single Source of Truth

Commands are registered once via `CommandDefinition`. CLI invocation uses existing metadata.

```python
from kollabor.events.models import CommandDefinition

command = CommandDefinition(
    name="sub",
    aliases=["subagent", "sa"],
    description="Manage agent sessions",
    handler=self.handle_sub,
    plugin_name="agent_orchestrator",
    cli_hidden=False,  # Opt-in to CLI invocation (default: True/hidden)
    requires_interactive=False,  # NEW: Needs user input prompts
)
```

No separate `get_cli_commands()` method needed - the command registry is the source of truth.

### CLI Parsing Flow

```python
# 1. Discover plugins (async, can be cached)
plugin_classes = await discover_plugins()

# 2. Instantiate plugins (async initialization)
plugins = await instantiate_plugins(plugin_classes)

# 3. Build command registry (from plugin registrations)
command_registry = build_registry(plugins)

# 4. Parse CLI args with command registry context
args = parse_arguments(command_registry, argv)

# 5. Execute CLI command if provided
if args.cli_command:
    await execute_cli_command(args.cli_command)
    if not args.stay:
        sys.exit(result.exit_code)
```

### Reserved Namespace

Core commands cannot be overridden by plugins:

```python
RESERVED_COMMANDS = {
    'help', 'version', 'config', 'status', 'permissions',
    'profile', 'agent', 'skill', 'model', 'cd'
}
```

Plugins attempting to register these names will fail with clear error.

## Implementation

### Phase 1: Command Registration (No Changes)

Plugins register commands as they do today via `CommandDefinition`.

Add two new optional fields:
- `cli_hidden: bool = True` - Exclude from CLI (default: hidden, opt-in with False)
- `requires_interactive: bool = False` - Needs user input (shows helpful error)

### Phase 2: CLI Argument Parsing

**File: `kollabor/cli.py`**

```python
import sys
import argparse
import logging
from typing import Optional
from kollabor.commands.registry import SlashCommandRegistry

logger = logging.getLogger(__name__)

# Reserved core commands
RESERVED_COMMANDS = {
    'help', 'version', 'config', 'status', 'permissions',
    'profile', 'agent', 'skill', 'model', 'cd'
}


def build_cli_help_text(command_registry: SlashCommandRegistry) -> str:
    """Build help text showing CLI-invocable commands.

    Args:
        command_registry: Registry with all registered commands

    Returns:
        Formatted help text for epilog
    """
    # Get all non-hidden commands
    cli_commands = []
    for cmd_def in command_registry.get_all_commands():
        if not cmd_def.cli_hidden:
            cli_commands.append(cmd_def)

    if not cli_commands:
        return ""

    # Group by category
    lines = ["\nslash commands (use as --command args...):"]

    # Limit to 50 commands to prevent OOM
    shown = cli_commands[:50]
    for cmd in shown:
        # Build flag list
        if cmd.aliases:
            flags = f"  --{cmd.name}, --{', --'.join(cmd.aliases[:2])}"
        else:
            flags = f"  --{cmd.name}"

        # Format help
        desc = cmd.description[:60] + "..." if len(cmd.description) > 60 else cmd.description
        if len(flags) > 24:
            lines.append(flags)
            lines.append(f"{'':24}{desc}")
        else:
            lines.append(f"{flags:24}{desc}")

    if len(cli_commands) > 50:
        lines.append(f"\n  ... and {len(cli_commands) - 50} more commands")
        lines.append("  Run 'kollab --list-commands' to see all")

    return "\n".join(lines)


def parse_arguments(
    command_registry: Optional[SlashCommandRegistry] = None,
    argv: list[str] = None
) -> argparse.Namespace:
    """Parse CLI arguments with command registry for slash command support.

    Args:
        command_registry: Registry of registered commands (optional for early parsing)
        argv: Argument list (default: sys.argv[1:])

    Returns:
        Parsed arguments namespace
    """
    # Build help epilog if registry available
    cli_help = ""
    if command_registry:
        cli_help = build_cli_help_text(command_registry)

    epilog = f"""{cli_help}

examples:
  kollab                              Start interactive mode
  kollab "analyze this code"          Interactive with initial message
  kollab --sub list                   List active agents and exit
  kollab --sub capture all            Capture all agent output
  kollab --sub list --stay            List agents, stay interactive
  kollab --permissions trust          Set trust mode and exit"""

    parser = argparse.ArgumentParser(
        prog="kollab",
        description="Terminal-based LLM chat interface",
        epilog=epilog,
        formatter_class=argparse.RawDescriptionHelpFormatter
    )

    # ... existing args (--pipe, --timeout, etc.) ...

    parser.add_argument(
        '--stay',
        action='store_true',
        help='Stay in interactive mode after CLI command executes'
    )

    parser.add_argument(
        '--list-commands',
        action='store_true',
        help='List all available commands and exit'
    )

    # Parse known args, capture unknown as potential commands
    args, unknown = parser.parse_known_args(argv)

    # Handle --list-commands early
    if args.list_commands:
        if command_registry:
            print_command_list(command_registry)
        else:
            print("Command registry not available yet")
        sys.exit(0)

    # Process unknown args as CLI commands
    args.cli_command = None

    if unknown and command_registry:
        if unknown[0].startswith('--') and len(unknown[0]) > 2:
            cmd_name = unknown[0][2:]  # Strip '--'

            # Validate command name (prevent injection)
            if not cmd_name.replace('-', '').replace('_', '').isalnum():
                parser.error(f"Invalid command name: {cmd_name}")

            # Look up in registry (canonical name or alias)
            cmd_def = command_registry.get_command(cmd_name)

            if cmd_def:
                if cmd_def.cli_hidden:
                    parser.error(
                        f"Command /{cmd_name} is not available from command line.\n"
                        f"Start kollabor in interactive mode and use /{cmd_name} instead."
                    )

                # Sanitize arguments (quote them for safety)
                import shlex
                sanitized_args = [shlex.quote(arg) for arg in unknown[1:]]

                args.cli_command = {
                    'name': cmd_def.name,  # Use canonical name
                    'args': sanitized_args,
                    'raw': f"/{cmd_def.name} {' '.join(sanitized_args)}".strip(),
                    'requires_interactive': cmd_def.requires_interactive,
                    'command_def': cmd_def
                }
            else:
                # Unknown command - show format users should type
                available = [c.name for c in command_registry.get_all_commands()
                            if not c.cli_hidden]
                available_flags = [f"--{c}" for c in sorted(available[:10])]
                parser.error(
                    f"Unknown command: --{cmd_name}\n"
                    f"Available commands: {', '.join(available_flags)}\n"
                    f"Run 'kollab --list-commands' to see all"
                )
        else:
            parser.error(f"Unrecognized arguments: {' '.join(unknown)}")
    elif unknown:
        # Registry not available yet (early parse) - store for later
        args._unknown = unknown

    return args


def print_command_list(command_registry: SlashCommandRegistry):
    """Print formatted list of all commands."""
    from collections import defaultdict

    # Group by category
    by_category = defaultdict(list)
    for cmd in command_registry.get_all_commands():
        if not cmd.cli_hidden:
            by_category[cmd.category.value].append(cmd)

    print("\nAvailable Commands:\n")
    for category in sorted(by_category.keys()):
        print(f"{category.upper()}:")
        for cmd in sorted(by_category[category], key=lambda c: c.name):
            aliases_str = f" ({', '.join(cmd.aliases)})" if cmd.aliases else ""
            print(f"  /{cmd.name}{aliases_str:20} - {cmd.description}")
        print()
```

### Phase 3: Application Execution

**File: `kollabor/application.py`**

```python
async def start(self, initial_message: str | None = None) -> None:
    """Start application in interactive or CLI mode."""

    # ... existing startup (banner, plugins, etc.) ...

    # Wait for command system to be ready (event-based, not sleep!)
    await self._wait_for_command_system_ready()

    # Execute CLI command if provided
    cli_command = getattr(self.args, 'cli_command', None)
    if cli_command:
        exit_code = await self._execute_cli_command(cli_command)

        # Check if we should stay in interactive mode
        if not self.args.stay:
            logger.info(f"CLI command complete, exiting with code {exit_code}")
            sys.exit(exit_code)

        logger.info("CLI command complete, continuing to interactive mode")

    # ... continue with interactive mode ...


async def _wait_for_command_system_ready(self):
    """Wait for command system to be initialized.

    Uses event-based synchronization, not blind sleep.
    """
    max_wait = 5.0  # Max 5 seconds
    interval = 0.01
    elapsed = 0.0

    while elapsed < max_wait:
        if (self.input_handler and
            self.input_handler.command_executor and
            hasattr(self.input_handler.command_executor, 'registry') and
            self.input_handler.command_registry.get_registry_stats()['total_commands'] > 0):
            logger.debug(f"Command system ready after {elapsed:.2f}s")
            return

        await asyncio.sleep(interval)
        elapsed += interval

    raise RuntimeError("Command system failed to initialize within 5 seconds")


async def _execute_cli_command(self, cli_command: dict) -> int:
    """Execute a CLI slash command with security and permissions.

    Args:
        cli_command: Dict with 'name', 'args', 'raw', 'requires_interactive'

    Returns:
        Exit code: 0 = success, 1 = failure, 2 = error

    State Management:
        When using --stay flag, the application enters interactive mode after
        the CLI command completes. Any state changes made by the command
        (permissions, config, etc.) persist into the interactive session.

        Example: `kollab --permissions trust --stay` leaves trust mode active.

        Commands that should not persist state should set `cli_hidden=True`
        and document interactive-only usage.
    """
    from kollabor.events.models import CommandResult

    try:
        # Check if command requires interactive mode
        if cli_command.get('requires_interactive') and not self.args.stay:
            self._print_error(
                f"Command /{cli_command['name']} requires interactive mode.\n"
                f"Start kollabor: kollab\n"
                f"Then run: /{cli_command['name']} {' '.join(cli_command['args'])}"
            )
            return 2

        # Parse the command
        command = self.input_handler.slash_parser.parse_command(cli_command['raw'])
        if not command:
            self._print_error(f"Failed to parse command: {cli_command['raw']}")
            return 2

        # Execute through command executor (includes permission checks!)
        result = await self.input_handler.command_executor.execute_command(
            command, self.event_bus
        )

        # Display result with sanitized output
        if result and result.message:
            self._print_command_result(result)

        # Determine exit code
        if not result:
            return 1
        if not result.success:
            return 1
        return 0

    except KeyboardInterrupt:
        logger.info("CLI command interrupted by user")
        return 130  # Standard SIGINT exit code
    except Exception as e:
        logger.error(f"CLI command execution failed: {e}", exc_info=True)
        self._print_error(f"Error: {e}")
        return 1


def _print_command_result(self, result: 'CommandResult'):
    """Print command result with sanitized output.

    Strips ANSI escape sequences if not a TTY.
    """
    import sys
    import re

    message = result.message

    # Strip ANSI codes if output is redirected
    if not sys.stdout.isatty():
        # Remove all ANSI escape sequences
        ansi_escape = re.compile(r'\x1b\[[0-9;]*[mKH]|\x1b\][^\x07]*\x07')
        message = ansi_escape.sub('', message)

    # Print with appropriate color if TTY
    if sys.stdout.isatty():
        if result.display_type == "error":
            print(f"\033[31m{message}\033[0m")
        elif result.display_type == "warning":
            print(f"\033[33m{message}\033[0m")
        elif result.display_type == "success":
            print(f"\033[32m{message}\033[0m")
        else:
            print(message)
    else:
        print(message)


def _print_error(self, message: str):
    """Print error message to stderr."""
    import sys
    if sys.stderr.isatty():
        print(f"\033[31mError:\033[0m {message}", file=sys.stderr)
    else:
        print(f"Error: {message}", file=sys.stderr)
```

### Phase 4: Security Validation

**File: `kollabor/commands/registry.py`**

```python
class SlashCommandRegistry:
    """Registry for slash commands with namespace protection."""

    def __init__(self):
        self._commands: dict[str, CommandDefinition] = {}
        self._core_commands: dict[str, CommandDefinition] = {}  # Internal core
        self._reserved = RESERVED_COMMANDS.copy()

    def _register_core_command(self, command_def: CommandDefinition) -> None:
        """Register a core system command (internal use only)."""
        # Core commands can use reserved names
        self._core_commands[command_def.name] = command_def
        for alias in command_def.aliases:
            self._core_commands[alias] = command_def

    def register_command(self, command_def: CommandDefinition) -> None:
        """Register a command with validation.

        Args:
            command_def: Command definition to register

        Raises:
            ValueError: If command conflicts with reserved namespace
        """
        # Check reserved namespace (no exceptions - core is core)
        if command_def.name in self._reserved:
            raise ValueError(
                f"Command '/{command_def.name}' is reserved and cannot be "
                f"overridden by plugin '{command_def.plugin_name}'"
            )

        # Core commands use separate internal registry
        if command_def.plugin_name == "system":
            self._register_core_command(command_def)
            return

        # Check for conflicts
        if command_def.name in self._commands:
            existing = self._commands[command_def.name]
            raise ValueError(
                f"Command '/{command_def.name}' already registered by "
                f"'{existing.plugin_name}', cannot register from '{command_def.plugin_name}'"
            )

        # Register canonical name
        self._commands[command_def.name] = command_def

        # Register aliases (with defensive copy)
        for alias in command_def.aliases:
            if alias in self._commands:
                existing = self._commands[alias]
                raise ValueError(
                    f"Alias '/{alias}' conflicts with command from '{existing.plugin_name}'"
                )
            # Create alias reference (not copy - same object is fine)
            self._commands[alias] = command_def

        logger.debug(f"Registered command: /{command_def.name} from {command_def.plugin_name}")
```

### Phase 5: Update CommandDefinition

**File: `kollabor/events/models.py`**

```python
from dataclasses import dataclass, field
from typing import Callable, Optional, List

@dataclass
class CommandDefinition:
    """Command definition with CLI support."""
    name: str
    description: str
    handler: Callable
    plugin_name: str
    category: 'CommandCategory' = field(default='CommandCategory.CUSTOM')
    mode: 'CommandMode' = field(default='CommandMode.INSTANT')
    aliases: List[str] = field(default_factory=list)
    subcommands: List['SubcommandInfo'] = field(default_factory=list)
    enabled: bool = True
    icon: Optional[str] = None

    # CLI-specific fields
    cli_hidden: bool = True  # Opt-in: False=allow CLI, True=CLI-hidden (default)
    requires_interactive: bool = False  # If True, needs user input/prompts
```

## Usage Examples

```bash
# Basic command execution (exits after)
kollab --sub list
kollab --permissions trust
kollab --sub capture all

# Stay in interactive mode after command
kollab --sub list --stay

# Commands requiring interaction show helpful error
kollab --profile create
# Error: Command /profile create requires interactive mode.
# Start kollabor: kollab
# Then run: /profile create

# Fullscreen commands work directly
kollab --matrix

# Scripting with exit codes
kollab --sub stop all && echo "All stopped" || echo "Failed"

# Pipe mode (strips ANSI automatically)
kollab --sub list > agents.txt

# List all available commands
kollab --list-commands
```

## Edge Cases

### State Persistence with --stay

When using `--stay`, command state persists into interactive mode:

```bash
kollab --permissions trust --stay   # Trust mode stays active
kollab --profile set mymodel --stay # Profile stays changed
```

This is intentional - the command executed and its effects remain. Commands
that should NOT persist should set `cli_hidden=True` and document as
interactive-only.

### Pipe Mode + CLI Command

```bash
kollab --pipe --sub list
```

Works as expected. `--pipe` is a hint to suppress UI elements, CLI commands already do this.

### Commands Needing Interactive Input

```bash
kollab --profile create
```

Returns helpful error:
```
Error: Command /profile create requires interactive mode.
Start kollabor: kollab
Then run: /profile create
```

Set `requires_interactive=True` in CommandDefinition to enable this check.

### Fullscreen Commands

```bash
kollab --matrix
```

Initializes minimally, enters alternate buffer, runs plugin, exits cleanly on quit.

### Non-TTY Output

```bash
kollab --sub list > file.txt
kollab --sub list | grep agent
```

ANSI escape sequences are automatically stripped when stdout is not a TTY.

### Signal Handling

Ctrl+C during CLI command execution:
- Catches KeyboardInterrupt
- Returns exit code 130 (standard SIGINT)
- Cleanup runs normally

## Exit Codes

- `0` - Command succeeded
- `1` - Command failed or execution error
- `2` - Parse error or invalid usage
- `130` - Interrupted by SIGINT (Ctrl+C)

## Files to Modify

1. `kollabor/cli.py` - Add parsing logic with validation
2. `kollabor/events/models.py` - Add `cli_hidden=True` and `requires_interactive` to CommandDefinition
3. `kollabor/application.py` - Add `_execute_cli_command()` with security and state docs
4. `kollabor/commands/registry.py` - Add namespace protection + `_core_commands` registry
5. `plugins/agent_orchestrator/plugin.py` - Set `cli_hidden=False` to enable CLI
6. `plugins/fullscreen/matrix_plugin.py` - Set `cli_hidden=False` to enable --matrix

## Acceptance Criteria

1. ✓ `kollab --sub list` executes `/sub list` and exits
2. ✓ `kollab --sub create name "task"` handles quoted args correctly
3. ✓ `kollab --sub list --stay` executes and enters interactive mode
4. ✓ `kollab --matrix` opens fullscreen in alt buffer
5. ✓ Unknown commands show helpful error with available commands
6. ✓ Aliases work (`--sa` = `--sub`)
7. ✓ Commands use existing CommandDefinition (single source of truth)
8. ✓ Exit codes: 0 success, 1 failure, 2 parse error, 130 interrupt
9. ✓ Commands requiring input show helpful error
10. ✓ ANSI codes stripped when output redirected
11. ✓ Reserved commands protected from override
12. ✓ Command conflicts detected and rejected
13. ✓ Event-based synchronization (no blind sleep)
14. ✓ Permission system integration via command executor

## Testing

```bash
# Test basic execution
kollab --sub list
# Expected: Shows agent list, exits with code 0

# Test with args
kollab --sub capture all
# Expected: Captures all agent output, exits

# Test --stay flag
kollab --sub list --stay
# Expected: Shows list, enters interactive mode

# Test unknown command
kollab --notacommand foo
# Expected: Error with available commands, exit code 2

# Test alias
kollab --sa list
# Expected: Same as --sub list

# Test fullscreen
kollab --matrix
# Expected: Opens matrix in alt buffer, q to exit

# Test exit codes
kollab --sub status nonexistent; echo $?
# Expected: Error message, exit code 1

# Test requires_interactive
kollab --profile create
# Expected: Helpful error message

# Test non-TTY
kollab --sub list > output.txt
cat output.txt
# Expected: No ANSI codes, clean text

# Test list commands
kollab --list-commands
# Expected: Formatted list by category

# Test SIGINT
kollab --sub capture all
# Press Ctrl+C
# Expected: Graceful exit with code 130
```

## Security Considerations

1. **Input Validation**: Command names validated with regex, args quoted with `shlex.quote()`
2. **Permission Integration**: All commands execute through `command_executor`, which checks permissions
3. **Namespace Protection**: Core commands cannot be overridden (no plugin exceptions)
4. **Conflict Detection**: Duplicate registrations fail fast with clear error
5. **Output Sanitization**: ANSI codes stripped in non-TTY, prevents terminal injection
6. **Reserved Words**: System commands use separate `_core_commands` registry

## Performance

1. **Event-based sync**: No blind `sleep()`, waits for actual readiness
2. **Registry caching**: Command list built once during startup
3. **Help text limits**: Max 50 commands shown, prevents OOM
4. **Validation once**: Command name validation happens during parse, not execution
5. **Async initialization**: Plugin discovery can happen in parallel
