# Task Recon: Implement context object propagation and dependency injection setup

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

---

## Task Summary

Create a `CLIContext` dataclass or TypedDict to provide type-safe access to global options, and set up the dependency injection pattern for services (ConfigManager, OutputFormatter) to be accessed by commands via the context.

### Parent Task Context

**Parent Task:** Implement Root CLI App with Global Options (ID: 11)
**Parent Goal:** Create the main Typer CLI application with global options (--json, --verbose, --tenant, --no-color) and command group registration.
**This Subtask's Role:** After subtasks 11.1 and 11.2 established the main Typer app, global options callback, entry point, and command group registration, this subtask completes the CLI infrastructure by replacing the raw `dict` context with a typed `CLIContext` dataclass and implementing lazy initialization of `ConfigManager` and `OutputFormatter` services for dependency injection into commands.

### Dependencies
- Depends on: 11.1 (Create main Typer app with global options callback) - **DONE**
- Depends on: 11.2 (Set up __main__.py entry point and command group registration) - **DONE**

### Acceptance Criteria
- Create `src/teams_phone/cli/context.py` with:
  - `CLIContext` dataclass containing: `json_output` (bool), `verbose` (bool), `tenant` (Optional[str]), `no_color` (bool), `config_manager` (Optional[ConfigManager]), `output_formatter` (Optional[OutputFormatter])
  - Helper function `get_context(ctx: typer.Context) -> CLIContext` for type-safe context retrieval
- Update `main.py` callback to:
  - Create `CLIContext` instance instead of raw dict
  - Lazily initialize ConfigManager based on options
  - Lazily initialize OutputFormatter with `json_mode` and `no_color` settings
  - Store `CLIContext` in `ctx.obj`
- Add utility function for commands to retrieve typed context with initialized services
- Handle the `--tenant` override by setting it in context for downstream service initialization
- Unit tests verifying CLIContext population and service initialization

---

## Insights from Previous Tasks

### Dependency Task Notes
| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 11.1 | Create main Typer app with global options callback | Context uses `ctx.obj["json"]` key naming (concise); current implementation uses raw dict for ctx.obj |
| 11.2 | Set up __main__.py entry point and command group registration | Sub-apps registered via `app.add_typer()`, all placeholder apps use `no_args_is_help=True` |

### Reusable Patterns from Dependencies
- **Context key naming**: Currently uses `ctx.obj["json"]` not `ctx.obj["json_output"]` - see `src/teams_phone/cli/main.py:53-56`
- **Test fixture for context verification**: Creates a test app with temp command to capture ctx.obj values - see `tests/unit/test_cli_main.py:77-100`
- **CliRunner usage**: `from typer.testing import CliRunner` and `runner.invoke(app, [...])` - see `tests/unit/test_cli_main.py:7-12`
- **OutputFormatter initialization**: Accepts `json_mode` and `no_color` parameters at construction - see `src/teams_phone/infrastructure/output_formatter.py:35-59`
- **ConfigManager initialization**: Accepts optional `config_path` parameter, defaults to `~/.teams-phone/config.toml` - see `src/teams_phone/infrastructure/config_manager.py:30-38`

### Warnings and Gotchas
- **Exit code 2 for no_args_is_help**: When testing with no arguments or just options (no subcommand), Typer returns exit code 2 not 0 - this is expected behavior from Click
- **OutputFormatter requires json_mode and no_color at init time**: The callback must capture these values early enough to pass to OutputFormatter
- **NO_COLOR env var**: OutputFormatter already handles `NO_COLOR` env var internally (`self.no_color = no_color or os.environ.get("NO_COLOR") is not None`), so just pass the flag through
- **Lazy initialization matters**: ConfigManager reads config file from disk; defer initialization until actually needed to avoid unnecessary I/O on `--help`

### Open Items Inherited
- [ ] Context uses raw dict instead of typed dataclass - **This is what we're implementing now**

---

## Architecture Context

### Relevant Design Decisions
- **Layered Monolith Architecture**: CLI layer (`src/teams_phone/cli/`) contains Typer commands that invoke services; services invoke infrastructure
- **Dependency Injection via Context**: Services (ConfigManager, OutputFormatter) are initialized in the CLI callback and passed to commands via `ctx.obj`
- **Type Safety**: Project uses strict mypy mode; a typed context object enables IDE autocomplete and catches type errors at dev time

### Module Structure
```
src/teams_phone/
├── cli/
│   ├── __init__.py      # Exports app
│   ├── main.py          # Main Typer app with callback (to be modified)
│   └── context.py       # NEW - CLIContext dataclass and get_context helper
├── services/
│   ├── tenant_service.py   # Example of service using ConfigManager + AuthService
│   └── auth_service.py     # AuthService for authentication
└── infrastructure/
    ├── config_manager.py   # ConfigManager for config file operations
    └── output_formatter.py # OutputFormatter for CLI output
```

### Conventions to Follow
- **Module docstrings**: Start with triple-quoted docstring explaining module purpose
- **Future annotations**: Use `from __future__ import annotations` at top
- **Type hints**: All functions must have type hints (strict mypy mode)
- **Import style**: Use absolute imports from `teams_phone.*`
- **Dataclass pattern**: Use `@dataclass` decorator from stdlib dataclasses, not Pydantic for simple internal types

---

## Related Code References

### Primary References (Must Read)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/cli/main.py` | 46-56 | Current callback implementation using raw dict for ctx.obj |
| `src/teams_phone/infrastructure/config_manager.py` | 19-38 | ConfigManager class with constructor signature |
| `src/teams_phone/infrastructure/output_formatter.py` | 23-59 | OutputFormatter class with constructor signature |
| `tests/unit/test_cli_main.py` | 70-182 | TestContextPopulation class with fixture pattern for context testing |

### Secondary References (Helpful Context)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/services/tenant_service.py` | 19-41 | Example of service that takes ConfigManager as constructor arg |
| `src/teams_phone/infrastructure/__init__.py` | 1-8 | Infrastructure exports (ConfigManager, OutputFormatter) |
| `src/teams_phone/models/config.py` | 46-68 | Config model structure (loaded by ConfigManager) |
| `tests/unit/test_output_formatter.py` | 20-70 | Test patterns for OutputFormatter init |

### Code Patterns to Follow

```python
# From src/teams_phone/cli/main.py:46-56 - current callback (to be replaced)
@app.callback()
def main(
    ctx: typer.Context,
    json_output: bool = typer.Option(...),
    verbose: bool = typer.Option(...),
    tenant: Optional[str] = typer.Option(...),
    no_color: bool = typer.Option(...),
) -> None:
    ctx.ensure_object(dict)
    ctx.obj["json"] = json_output
    ctx.obj["verbose"] = verbose
    ctx.obj["tenant"] = tenant
    ctx.obj["no_color"] = no_color
```

```python
# From src/teams_phone/infrastructure/output_formatter.py:35-51 - OutputFormatter init
def __init__(
    self,
    console: Console | None = None,
    *,
    json_mode: bool = False,
    no_color: bool = False,
) -> None:
    self.json_mode = json_mode
    self.no_color = no_color or os.environ.get("NO_COLOR") is not None
```

```python
# From src/teams_phone/infrastructure/config_manager.py:30-38 - ConfigManager init
def __init__(self, config_path: str | None = None) -> None:
    path_str = config_path if config_path is not None else DEFAULT_CONFIG_PATH
    self.config_path: Path = Path(path_str).expanduser()
```

---

## Dependencies

### Internal Modules
- `teams_phone.infrastructure.ConfigManager` - for config file operations
- `teams_phone.infrastructure.OutputFormatter` - for CLI output formatting
- `typer` - CLI framework with Context object

### External Packages
- `typer>=0.9.0` - Already in pyproject.toml
- Standard library `dataclasses` - for `@dataclass` decorator

### New Dependencies Needed
- None required

---

## Testing Guidance

### Test File Location
`tests/unit/test_cli_main.py` - extend existing file with CLIContext tests, or create `tests/unit/test_cli_context.py`

### Test Patterns in Use
```python
# From tests/unit/test_cli_main.py:76-100 - fixture pattern for context testing
@pytest.fixture
def test_app_with_command(self) -> typer.Typer:
    """Create a copy of the app with a test command to inspect context."""
    from teams_phone.cli.main import main

    test_app = typer.Typer(name="teams-phone", ...)
    test_app.callback()(main)

    captured: dict[str, object] = {}

    @test_app.command()
    def test_cmd(ctx: typer.Context) -> None:
        """Test command to capture context."""
        captured["json"] = ctx.obj["json"]
        # ... capture other values

    return test_app, captured
```

```python
# From tests/unit/test_output_formatter.py:36-40 - testing with flags
@pytest.mark.unit
def test_no_color_flag(self) -> None:
    """OutputFormatter respects no_color flag."""
    formatter = OutputFormatter(no_color=True)
    assert formatter.no_color is True
```

### Fixtures Available
- `tmp_path` - pytest built-in for temporary directories (useful for config file tests)
- `monkeypatch` - pytest built-in for env var manipulation

### Test Utilities
- `typer.testing.CliRunner` - standard Typer test runner
- `pytest.mark.unit` - marker for unit tests

---

## Risks and Blockers

### Technical Risks
- [ ] **Lazy initialization complexity**: Must avoid initializing ConfigManager during `--help` to prevent unnecessary disk I/O. Solution: Use `Optional` fields in CLIContext and provide a getter that initializes on first access.
- [ ] **Context type compatibility**: Typer's `ctx.obj` is typed as `Any`. The `get_context()` helper must handle the type cast safely.

### Dependency Risks
- None - tasks 11.1 and 11.2 are complete

### Open Questions
- [ ] Should `get_context()` raise an error if called before callback runs, or return a default CLIContext? (Recommendation: raise `RuntimeError` with clear message - indicates programming error)
- [ ] Should `CLIContext` be a dataclass or a TypedDict? (Recommendation: dataclass for better IDE support and default values)

---

## Implementation Recommendations

### Suggested Approach
1. Create `src/teams_phone/cli/context.py` with:
   - `CLIContext` dataclass with all fields (options + optional services)
   - `get_context(ctx: typer.Context) -> CLIContext` helper function
   - Optional: `ensure_config_manager()` and `ensure_output_formatter()` methods for lazy init
2. Update `src/teams_phone/cli/main.py` callback to:
   - Import and use `CLIContext` instead of raw dict
   - Create `CLIContext` instance in callback
   - Initialize OutputFormatter immediately (cheap) with json_mode and no_color
   - Leave ConfigManager as `None` initially (lazy init by commands that need it)
3. Update `src/teams_phone/cli/__init__.py` to export `CLIContext` and `get_context`
4. Add unit tests to `tests/unit/test_cli_main.py` for CLIContext functionality
5. Run `ruff check`, `ruff format`, and `mypy` to ensure code quality

### Files to Create/Modify
| Action | File | Reason |
|--------|------|--------|
| Create | `src/teams_phone/cli/context.py` | CLIContext dataclass and get_context helper |
| Modify | `src/teams_phone/cli/main.py` | Use CLIContext instead of dict, initialize services |
| Modify | `src/teams_phone/cli/__init__.py` | Export CLIContext and get_context |
| Modify | `tests/unit/test_cli_main.py` | Add tests for CLIContext and service initialization |

### Quick Wins
- Start with the CLIContext dataclass definition (simple data container)
- Then add get_context() helper (just type casting)
- Update callback last (once tests for new structure pass)

---

## Cheat Sheet

### Key Imports
```python
# In context.py
from __future__ import annotations

from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Optional

import typer

if TYPE_CHECKING:
    from teams_phone.infrastructure import ConfigManager, OutputFormatter
```

```python
# In main.py - updated imports
from teams_phone.cli.context import CLIContext, get_context
from teams_phone.infrastructure import ConfigManager, OutputFormatter
```

### Key Functions to Use
- `@dataclass` - decorator from stdlib dataclasses
- `field(default=None)` - for Optional fields with None default
- `ctx.ensure_object(CLIContext)` - alternative to dict (may not work, test first)
- `isinstance(ctx.obj, CLIContext)` - type guard in get_context

### CLIContext Dataclass Pattern
```python
from dataclasses import dataclass, field
from typing import Optional

from teams_phone.infrastructure import ConfigManager, OutputFormatter


@dataclass
class CLIContext:
    """Type-safe context for CLI commands.

    Attributes:
        json_output: Output in JSON format.
        verbose: Enable verbose output.
        tenant: Tenant name override (None = use default).
        no_color: Disable colored output.
        config_manager: Lazily initialized ConfigManager.
        output_formatter: Initialized OutputFormatter for output.
    """
    json_output: bool = False
    verbose: bool = False
    tenant: Optional[str] = None
    no_color: bool = False
    config_manager: Optional[ConfigManager] = None
    output_formatter: Optional[OutputFormatter] = None

    def get_output_formatter(self) -> OutputFormatter:
        """Get or create the OutputFormatter."""
        if self.output_formatter is None:
            self.output_formatter = OutputFormatter(
                json_mode=self.json_output,
                no_color=self.no_color,
            )
        return self.output_formatter

    def get_config_manager(self) -> ConfigManager:
        """Get or create the ConfigManager."""
        if self.config_manager is None:
            self.config_manager = ConfigManager()
        return self.config_manager
```

### get_context Helper Pattern
```python
def get_context(ctx: typer.Context) -> CLIContext:
    """Retrieve typed CLIContext from Typer context.

    Args:
        ctx: The Typer/Click context object.

    Returns:
        The CLIContext instance stored in ctx.obj.

    Raises:
        RuntimeError: If ctx.obj is not a CLIContext (programming error).
    """
    if not isinstance(ctx.obj, CLIContext):
        raise RuntimeError(
            "CLIContext not found in context. "
            "This is a programming error - ensure the main callback ran."
        )
    return ctx.obj
```

### Updated Callback Pattern
```python
@app.callback()
def main(
    ctx: typer.Context,
    json_output: bool = typer.Option(...),
    verbose: bool = typer.Option(...),
    tenant: Optional[str] = typer.Option(...),
    no_color: bool = typer.Option(...),
) -> None:
    # Create typed context instead of dict
    cli_ctx = CLIContext(
        json_output=json_output,
        verbose=verbose,
        tenant=tenant,
        no_color=no_color,
    )
    # Initialize OutputFormatter immediately (cheap operation)
    cli_ctx.output_formatter = OutputFormatter(
        json_mode=json_output,
        no_color=no_color,
    )
    # ConfigManager will be lazily initialized when needed
    ctx.obj = cli_ctx
```

---

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