# Task Recon: Implement Tenants CLI Commands

**Task ID:** 14
**Status:** pending
**Generated:** 2026-01-29T16:00:00Z
**Agent:** claude-opus-4-5-20251101

---

## Task Summary

Create the tenants command group with six CLI commands: `list`, `add`, `remove`, `switch`, `current`, and `test`. This task implements the CLI layer for multi-tenant management, providing user-facing commands that call the already-implemented TenantService (Task 13).

### Dependencies
- Depends on: 8 (OutputFormatter - done), 11 (Root CLI App with Global Options - done), 13 (TenantService - done)

### Acceptance Criteria
- `tenants list` shows all configured tenants in table/JSON format
- `tenants add` creates a new tenant profile (with required fields name, tenant_id, client_id, display_name)
- `tenants remove` deletes a tenant profile with --force option to skip confirmation
- `tenants switch` changes the active tenant
- `tenants current` displays the currently active tenant
- `tenants test` validates connection to a tenant (optional name argument)

---

## Insights from Previous Tasks

### Dependency Task Notes
| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 8 | OutputFormatter | JSON output via plain `print()` bypasses Rich; use `formatter.json()` for JSON mode, `formatter.table()` for table display |
| 11 | Root CLI App | CLIContext replaces raw dict; use `get_context(ctx)` for type-safe access; tenants_app placeholder already exists in `main.py:97-101` |
| 13 | TenantService | All CRUD operations implemented; `switch_tenant()` has auto_login parameter; `test_connection()` never raises - returns ConnectionTestResult |

### Reusable Patterns from Dependencies
- **CLI command structure**: Implemented in `src/teams_phone/cli/auth.py:23-183` - follow this exact pattern for tenants commands
- **Error handling**: `except TeamsPhoneError as e:` with `formatter.error(e.message, remediation=e.remediation)` then `raise typer.Exit(e.exit_code)`
- **JSON output checking**: `if cli_ctx.json_output:` then `formatter.json(...)` else `formatter.info(...)`

### Warnings and Gotchas
- **Exit code 2 for no_args_is_help**: When testing with no arguments, Typer returns exit code 2 not 0
- **OutputFormatter initialized in callback**: Already available via `cli_ctx.output_formatter` or `cli_ctx.get_output_formatter()`
- **ConfigManager lazy init**: Use `cli_ctx.get_config_manager()` to access config - do not assume it's pre-initialized

### Open Items Inherited
- None - all dependency tasks are fully complete

---

## Architecture Context

### Relevant Design Decisions
- **Layered Architecture**: CLI layer (tenants.py) calls Service layer (TenantService) which calls Infrastructure layer (ConfigManager)
- **Command-Service Separation**: CLI commands should not contain business logic; delegate to TenantService
- **Error Propagation**: TeamsPhoneError exceptions propagate from services to CLI where they're caught and formatted

### Module Structure
```
src/teams_phone/cli/
├── __init__.py      # Exports app, CLIContext, get_context
├── main.py          # Contains tenants_app placeholder (lines 97-101) - needs to import from tenants.py
├── context.py       # CLIContext dataclass, get_context helper
├── auth.py          # Reference implementation for CLI commands
└── tenants.py       # NEW FILE - implement tenants commands here
```

### Conventions to Follow
- Use `from __future__ import annotations` at top
- Use `typer.Context` as first parameter for all commands
- Use `get_context(ctx)` to retrieve typed CLIContext
- Follow docstring style from `auth.py` (imperative sentences)
- Use `@pytest.mark.unit` decorator for all unit tests

---

## Related Code References

### Primary References (Must Read)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/cli/auth.py` | 1-184 | Reference implementation for CLI command group - copy this structure exactly |
| `src/teams_phone/cli/main.py` | 97-110 | Placeholder tenants_app and registration - needs to be updated to import from tenants.py |
| `src/teams_phone/services/tenant_service.py` | 1-206 | TenantService with all methods to call |
| `src/teams_phone/cli/context.py` | 1-94 | CLIContext and get_context helper |

### Secondary References (Helpful Context)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/models/tenant.py` | 1-47 | TenantProfile and AuthMethod models |
| `src/teams_phone/models/auth.py` | 81-102 | ConnectionTestResult model for test command |
| `src/teams_phone/exceptions.py` | 1-138 | Exception hierarchy with exit codes |
| `.taskmaster/docs/cli-specification/command-group-4-tenants.md` | 1-177 | Full CLI specification for tenants commands |

### Code Patterns to Follow

```python
# Pattern from auth.py for CLI command structure (auth.py:23-49)
@auth_app.command()
def status(ctx: typer.Context) -> None:
    """Show current authentication status."""
    cli_ctx = get_context(ctx)
    formatter = cli_ctx.get_output_formatter()

    try:
        config_manager = cli_ctx.get_config_manager()
        cache_manager = CacheManager()
        auth_service = AuthService(config_manager, cache_manager)

        # ... service calls ...

        if cli_ctx.json_output:
            formatter.json(data.model_dump(mode="json"))
        else:
            formatter.info(f"Status: {data.status.value}")
    except TeamsPhoneError as e:
        formatter.error(e.message, remediation=e.remediation)
        raise typer.Exit(e.exit_code)
```

```python
# Pattern for table output (from output_formatter.py:61-87)
formatter.table(
    data=[
        {"name": t.name, "display_name": t.display_name, "tenant_id": str(t.tenant_id)}
        for t in tenants
    ],
    columns=["name", "display_name", "tenant_id"],
    title="Configured Tenants",
)
```

---

## Dependencies

### Internal Modules
- `teams_phone.cli.context` - CLIContext, get_context
- `teams_phone.services.tenant_service` - TenantService
- `teams_phone.services.auth_service` - AuthService (needed to create TenantService)
- `teams_phone.infrastructure` - ConfigManager, CacheManager, OutputFormatter
- `teams_phone.models` - TenantProfile, AuthMethod, ConnectionTestResult
- `teams_phone.exceptions` - TeamsPhoneError (base), ValidationError, NotFoundError, ConfigurationError

### External Packages
- `typer` - CLI framework
- `uuid` - UUID type for tenant_id/client_id validation in add command

### New Dependencies Needed
- None required - all necessary packages already installed

---

## Testing Guidance

### Test File Location
`tests/unit/test_cli_tenants.py` (NEW FILE)

### Test Patterns in Use
```python
# Pattern from test_cli_auth.py:20-52
from typer.testing import CliRunner
from unittest.mock import MagicMock, patch

runner = CliRunner()

class TestTenantsListCommand:
    """Tests for tenants list command."""

    @pytest.mark.unit
    def test_list_shows_in_help(self) -> None:
        """list command appears in tenants help."""
        result = runner.invoke(app, ["tenants", "--help"])
        assert result.exit_code == 0
        assert "list" in result.stdout

    @pytest.mark.unit
    def test_list_empty(self) -> None:
        """list shows empty message when no tenants configured."""
        with (
            patch("teams_phone.cli.tenants.CacheManager"),
            patch("teams_phone.cli.tenants.TenantService") as mock_service_class,
        ):
            mock_service = MagicMock()
            mock_service.list_tenants.return_value = []
            mock_service_class.return_value = mock_service

            result = runner.invoke(app, ["tenants", "list"])
            assert result.exit_code == 0
```

### Fixtures Available
- `tmp_path` - pytest built-in for temporary directories
- `runner = CliRunner()` - Typer's test runner (create at module level)

### Test Utilities
- `from unittest.mock import MagicMock, patch` - for mocking services
- `from typer.testing import CliRunner` - for CLI testing
- `import json` - for validating JSON output

---

## Risks and Blockers

### Technical Risks
- [ ] **Interactive prompts in add command**: Typer has `typer.prompt()` and `typer.confirm()` for interactive input; CliRunner needs `input` parameter for testing
- [ ] **UUID validation**: The `add` command needs to validate tenant_id and client_id are valid UUIDs before creating TenantProfile

### Dependency Risks
- [ ] None - all dependencies (8, 11, 13) are marked as done

### Open Questions
- [ ] Should `tenants add` be fully non-interactive (all values as options) or support interactive prompts? CLI spec mentions "interactive wizard" but task details show only Options
- [ ] Should `tenants list` show "Current" status indicator or "Last Used" field as per CLI spec? TenantProfile model doesn't have last_used field
- [ ] Should `tenants remove` prompt for confirmation by default (without --force)? CLI spec says yes, but task details show `force: bool = typer.Option(False)`

---

## Implementation Recommendations

### Suggested Approach
1. Create `src/teams_phone/cli/tenants.py` with `tenants_app = typer.Typer()`
2. Update `src/teams_phone/cli/main.py` to import `tenants_app` from `tenants.py` instead of defining placeholder
3. Implement commands in order: `list` (simplest), `current`, `switch`, `test`, `remove`, `add` (most complex)
4. Create `tests/unit/test_cli_tenants.py` with comprehensive tests for each command

### Files to Create/Modify
| Action | File | Reason |
|--------|------|--------|
| Create | `src/teams_phone/cli/tenants.py` | Main implementation of tenants commands |
| Modify | `src/teams_phone/cli/main.py` | Replace placeholder tenants_app with import from tenants.py |
| Modify | `src/teams_phone/cli/__init__.py` | Export tenants_app if needed |
| Create | `tests/unit/test_cli_tenants.py` | Unit tests for all tenants commands |

### Quick Wins
- Start with `list` command - simplest to implement, proves the pattern works
- Reuse exact structure from `auth.py` commands - minimal refactoring needed

---

## Cheat Sheet

### Key Imports
```python
from __future__ import annotations

from typing import Optional
from uuid import UUID

import typer

from teams_phone.cli.context import get_context
from teams_phone.exceptions import TeamsPhoneError
from teams_phone.infrastructure import CacheManager
from teams_phone.models import AuthMethod, ConnectionTestResult, TenantProfile
from teams_phone.services import AuthService, TenantService
```

### Key Functions to Use
- `get_context(ctx)` - Get typed CLIContext from typer.Context
- `cli_ctx.get_config_manager()` - Get ConfigManager (lazy init)
- `cli_ctx.get_output_formatter()` - Get OutputFormatter (already initialized)
- `TenantService(config_manager, auth_service)` - Create service instance
- `service.list_tenants()` - Returns `list[TenantProfile]`
- `service.get_current_tenant()` - Returns `TenantProfile`, raises ConfigurationError if no default
- `service.switch_tenant(name, auto_login=True)` - Returns `TenantProfile`
- `service.add_tenant(profile)` - Returns `TenantProfile`
- `service.remove_tenant(name)` - Returns None, raises NotFoundError
- `service.test_connection(name)` - Returns `ConnectionTestResult` (never raises)

### Command Signatures (from task details)
```python
tenants_app = typer.Typer(name="tenants", help="Manage tenant profiles.")

@tenants_app.command("list")
def list_tenants(ctx: typer.Context): ...

@tenants_app.command()
def add(
    ctx: typer.Context,
    name: str = typer.Argument(...),
    tenant_id: str = typer.Argument(...),
    client_id: str = typer.Argument(...),
    display_name: str = typer.Option(...),
    auth_method: AuthMethod = typer.Option(AuthMethod.CERTIFICATE),
    cert_path: Optional[str] = typer.Option(None),
    default_location: Optional[str] = typer.Option(None),
): ...

@tenants_app.command()
def remove(ctx: typer.Context, name: str = typer.Argument(...), force: bool = typer.Option(False, "--force")): ...

@tenants_app.command()
def switch(ctx: typer.Context, name: str = typer.Argument(...)): ...

@tenants_app.command()
def current(ctx: typer.Context): ...

@tenants_app.command()
def test(ctx: typer.Context, name: Optional[str] = typer.Argument(None)): ...
```

### Common Patterns
- Error handling: See `src/teams_phone/cli/auth.py:47-49`
- JSON output: See `src/teams_phone/cli/auth.py:37-46`
- Table output: See `src/teams_phone/infrastructure/output_formatter.py:61-87`
- Confirmation prompt: `typer.confirm("Are you sure?", abort=True)` (for remove without --force)

---

*Generated by task-recon (claude-opus-4-5-20251101) | Ready for implementation*
