# Task Recon: Implement Users CLI Commands

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

---

## Task Summary

Create the users command group with `list`, `show`, and `search` commands that provide a CLI interface for the UserService methods (listing, searching, and retrieving user voice configurations from Graph API).

### Dependencies
- Task 8 (OutputFormatter) - **Done**: Provides table/JSON output formatting
- Task 11 (Root CLI App with Global Options) - **Done**: Provides app structure and CLIContext
- Task 17 (UserService) - **Done**: Provides async user operations
- Task 39 (Validation Checkpoint 1) - **Done**: Auth/tenant infrastructure validated

### Acceptance Criteria
From task details and CLI specification:
- Create `src/teams_phone/cli/users.py` with users_app Typer group
- Implement `list` command with filters: `--licensed/--unlicensed`, `--assigned/--unassigned`, `--account-type`, `--limit`, `--all`
- Implement `show` command with user identifier argument (UPN, name, or ID)
- Implement `search` command with query argument and `--field`, `--limit` options
- Support `--json` output for all commands
- Validate mutually exclusive options (licensed/unlicensed, assigned/unassigned, limit/all)
- Display correct exit codes for errors

---

## Insights from Previous Tasks

### Dependency Task Notes
| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 8 | OutputFormatter | Use `formatter.table()` for tables, `formatter.json()` for JSON mode. JSON bypasses Rich entirely. |
| 11 | Root CLI App | Use `get_context(ctx)` for type-safe CLIContext access. `cli_ctx.json_output` flag controls output mode. |
| 17 | UserService | **Async methods require `asyncio.run()`**. Import from `teams_phone.services.user_service` directly (not via package) to avoid circular import. |

### Reusable Patterns from Dependencies
- CLI command structure: See `tenants.py:48-99` for list command pattern
- Service factory helper: See `tenants.py:30-45` for `_create_tenant_service()` pattern
- JSON output conditional: See `tenants.py:65-95` for if/else json_output pattern
- Error handling: See `tenants.py:96-98` for TeamsPhoneError catch with exit code

### Warnings and Gotchas
- **Circular import avoidance**: Import UserService from `teams_phone.services.user_service` directly, NOT from `teams_phone.services`
- **Async operations**: UserService methods are `async`. CLI commands are sync. Must use `asyncio.run()` to bridge
- **GraphClient context manager**: UserService needs GraphClient which requires async context manager (`async with GraphClient(...) as client`)
- **Exit code 2 for no_args_is_help**: Typer returns exit code 2 when no_args_is_help triggers, not 0

### Open Items Inherited
- [ ] Phone number search filter needs verification against real Graph API (from Task 17)
- [ ] UserService NOT exported from `teams_phone.services` package (import directly)

---

## Architecture Context

### Relevant Design Decisions
- **Layered architecture**: CLI -> Services -> Infrastructure (Graph API)
- **Sync CLI, Async Services**: CLI commands are synchronous but call async UserService methods
- **Pydantic strict mode**: UserConfiguration model validates API responses strictly
- **Exception-to-exit-code mapping**: Each exception type has a specific exit code (2-8)

### Module Structure
```
src/teams_phone/
├── cli/
│   ├── main.py         # Root app, users_app placeholder (MODIFY)
│   ├── users.py        # New file with users commands (CREATE)
│   ├── context.py      # CLIContext and get_context helper
│   ├── tenants.py      # Reference implementation
│   └── auth.py         # Reference implementation
├── services/
│   └── user_service.py # UserService (async methods)
├── infrastructure/
│   └── graph_client.py # GraphClient (async context manager)
└── models/
    └── user.py         # UserConfiguration, AccountType
```

### Conventions to Follow
- `from __future__ import annotations` at top of file
- Docstrings for all commands (becomes help text)
- `@pytest.mark.unit` marker for unit tests
- Error handling pattern: try/except TeamsPhoneError with formatter.error() and typer.Exit()

---

## Related Code References

### Primary References (Must Read)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/cli/tenants.py` | 1-100 | Reference CLI command implementation with list/show patterns |
| `src/teams_phone/cli/main.py` | 74-78, 100 | Current users_app placeholder - needs replacement |
| `src/teams_phone/services/user_service.py` | 37-98 | list_users() method signature and parameters |
| `src/teams_phone/services/user_service.py` | 100-121 | get_user() method |
| `src/teams_phone/services/user_service.py` | 122-173 | search_users() method |
| `src/teams_phone/services/user_service.py` | 175-249 | resolve_user() method for show command |

### Secondary References (Helpful Context)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/models/user.py` | 80-119 | UserConfiguration model structure |
| `src/teams_phone/models/user.py` | 14-30 | AccountType enum values |
| `src/teams_phone/cli/context.py` | 76-93 | get_context() helper function |
| `src/teams_phone/infrastructure/output_formatter.py` | 61-88 | table() method usage |
| `.taskmaster/docs/cli-specification/command-group-1-users.md` | 1-129 | Full CLI specification |

### Code Patterns to Follow

```python
# Pattern: Service factory with async support (tenants.py:30-45 adapted)
import asyncio
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from teams_phone.infrastructure import OutputFormatter

def _create_user_service_and_run(
    ctx: typer.Context,
) -> tuple[Callable[..., Any], OutputFormatter]:
    """Create async runner and output formatter."""
    cli_ctx = get_context(ctx)
    formatter = cli_ctx.get_output_formatter()
    config_manager = cli_ctx.get_config_manager()
    cache_manager = CacheManager()
    auth_service = AuthService(config_manager, cache_manager)

    # Need to run async operations
    async def run_async(coro):
        async with GraphClient(auth_service) as graph_client:
            service = UserService(graph_client)
            return await coro(service)

    return lambda coro: asyncio.run(run_async(coro)), formatter
```

```python
# Pattern: List command with filters (tenants.py:48-98)
@users_app.command("list")
def list_users(
    ctx: typer.Context,
    licensed: Optional[bool] = typer.Option(None, "--licensed/--unlicensed"),
) -> None:
    """List users with voice configuration."""
    cli_ctx = get_context(ctx)
    formatter = cli_ctx.get_output_formatter()

    try:
        # Call service...
        if cli_ctx.json_output:
            formatter.json([...])
        else:
            formatter.table([...], columns=[...], title="...")
    except TeamsPhoneError as e:
        formatter.error(e.message, remediation=e.remediation)
        raise typer.Exit(e.exit_code)
```

```python
# Pattern: Boolean flag pairs validation (from CLI spec)
if licensed is not None and unlicensed is not None:
    if licensed == unlicensed:  # Both True or both specified
        raise typer.BadParameter("Cannot specify both --licensed and --unlicensed")
```

---

## Dependencies

### Internal Modules
- `teams_phone.cli.context` - get_context(), CLIContext
- `teams_phone.services.user_service` - UserService (import directly!)
- `teams_phone.services` - AuthService
- `teams_phone.infrastructure` - CacheManager, GraphClient, OutputFormatter
- `teams_phone.models` - UserConfiguration, AccountType
- `teams_phone.exceptions` - TeamsPhoneError, NotFoundError, ValidationError

### External Packages
- `typer` - CLI framework (already in use)
- `asyncio` - For running async UserService methods (stdlib)

### New Dependencies Needed
- None required

---

## Testing Guidance

### Test File Location
`tests/unit/test_cli_users.py` (CREATE)

### Test Patterns in Use
```python
# From tests/unit/test_cli_tenants.py:40-50
from typer.testing import CliRunner
from teams_phone.cli.main import app

runner = CliRunner()

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

```python
# Mock pattern for async services (from test_user_service.py)
from unittest.mock import MagicMock, patch, AsyncMock

@pytest.mark.unit
def test_list_users_empty(self) -> None:
    """list shows empty message when no users."""
    with (
        patch("teams_phone.cli.users.CacheManager"),
        patch("teams_phone.cli.users.AuthService"),
        patch("teams_phone.cli.users.GraphClient") as mock_gc_class,
        patch("teams_phone.cli.users.UserService") as mock_us_class,
    ):
        mock_gc = AsyncMock()
        mock_gc.__aenter__.return_value = mock_gc
        mock_gc_class.return_value = mock_gc

        mock_service = MagicMock()
        mock_service.list_users = AsyncMock(return_value=[])
        mock_us_class.return_value = mock_service

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

### Fixtures Available
- None specific to users CLI yet - create as needed

### Test Utilities
- `tests/conftest.py` - pytest markers (unit, integration, contract)
- `typer.testing.CliRunner` - CLI invocation testing
- `unittest.mock.AsyncMock` - For mocking async methods

---

## Risks and Blockers

### Technical Risks
- [x] **Async bridging complexity**: UserService is async but CLI is sync. Need careful async/await handling with `asyncio.run()`. Mitigate by following pattern in Implementation Recommendations.

### Dependency Risks
- [x] **Circular import**: UserService cannot be imported from package. Import directly from module. Pattern documented above.

### Open Questions
- [ ] Should `--all` for users list have a warning about potentially slow operation on large tenants? (CLI spec mentions "may be slow")
- [ ] Does `--field` option for search need all three values (name, upn, phone) or is auto-detection sufficient?

---

## Implementation Recommendations

### Suggested Approach
1. Create `src/teams_phone/cli/users.py` with imports and users_app Typer instance
2. Implement helper function to create UserService and run async operations
3. Implement `list` command with all filter options
4. Add validation for mutually exclusive options
5. Implement `show` command with user resolution
6. Implement `search` command with query and field filtering
7. Update `src/teams_phone/cli/main.py` to import users_app from users.py
8. Create comprehensive unit tests

### Files to Create/Modify
| Action | File | Reason |
|--------|------|--------|
| Create | `src/teams_phone/cli/users.py` | Main implementation with users_app and commands |
| Modify | `src/teams_phone/cli/main.py` | Import users_app from users.py instead of inline definition |
| Modify | `src/teams_phone/cli/__init__.py` | Export users_app (optional, for testing) |
| Create | `tests/unit/test_cli_users.py` | Comprehensive unit tests for all commands |

### Quick Wins
- Start with `list` command (most straightforward)
- Copy/adapt structure from `tenants.py` for consistency
- Implement JSON output first (simpler than table formatting)

---

## Cheat Sheet

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

import asyncio
from typing import Optional

import typer

from teams_phone.cli.context import get_context
from teams_phone.exceptions import TeamsPhoneError
from teams_phone.infrastructure import CacheManager, GraphClient
from teams_phone.models import AccountType, UserConfiguration
from teams_phone.services import AuthService
from teams_phone.services.user_service import UserService  # Direct import!
```

### Key Functions to Use
- `get_context(ctx)` - Get typed CLIContext from Typer context
- `cli_ctx.get_output_formatter()` - Get OutputFormatter
- `cli_ctx.get_config_manager()` - Get ConfigManager (lazy init)
- `formatter.table(data, columns, title)` - Display as Rich table
- `formatter.json(data)` - Output JSON to stdout
- `service.list_users(licensed=, assigned=, account_type=, max_items=)` - List users
- `service.resolve_user(identifier)` - Resolve user by UPN/name/ID
- `service.search_users(query, max_results=)` - Search users

### Common Patterns
- Async service call: Use `asyncio.run()` with `async with GraphClient() as client:` pattern
- Error handling: See `tenants.py:96-98` for try/except TeamsPhoneError pattern
- Boolean flag pairs: Use `typer.Option(None, "--licensed/--unlicensed")` syntax
- Exit codes: `raise typer.Exit(e.exit_code)` after error display

### Output Fields for Table Display
From CLI spec:
- `list`: Display Name, UPN, Account Type, Phone Number, License Status, Voice Enabled, Location
- `show`: Full user details with all policies and assignment sources
- `search`: Same as list

### AccountType Enum Values
- `AccountType.USER` = "user"
- `AccountType.RESOURCE_ACCOUNT` = "resourceAccount"
- `AccountType.GUEST` = "guest"

---

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