# Task ID: 23

**Title:** Implement Numbers Assign/Unassign CLI Commands

**Status:** pending

**Dependencies:** 17, 21, 22

**Priority:** high

**Description:** Add assign and unassign commands to the numbers CLI with progress display and confirmation prompts.

**Details:**

Extend `src/teams_phone/cli/numbers.py`:
```python
@numbers_app.command()
def assign(
    ctx: typer.Context,
    user: str = typer.Argument(..., help="User UPN, name, or ID"),
    number: str = typer.Argument(..., help="Phone number to assign"),
    location: Optional[str] = typer.Option(None, "--location", "-l", help="Emergency location"),
    number_type: str = typer.Option("callingPlan", "--type"),
    dry_run: bool = typer.Option(False, "--dry-run", help="Preview without changes"),
    no_wait: bool = typer.Option(False, "--no-wait", help="Don't wait for completion"),
):
    """Assign a phone number to a user."""
    # 1. Resolve user
    # 2. Validate location (if provided or required)
    # 3. If dry-run, show preview and exit
    # 4. Call assign_number with progress spinner
    # 5. Display success/failure
    ...

@numbers_app.command()
def unassign(
    ctx: typer.Context,
    number: str = typer.Argument(..., help="Phone number to unassign"),
    force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
    dry_run: bool = typer.Option(False, "--dry-run"),
    no_wait: bool = typer.Option(False, "--no-wait"),
):
    """Unassign a phone number."""
    # Show confirmation unless --force
    ...
```

**Test Strategy:**

CLI tests: assign with/without location, E911 error for Calling Plan, dry-run preview, confirmation prompt for unassign, progress display.

---

## Subtask 23.1 Implementation Notes

### Session: 2026-01-29 - Agent: claude-sonnet-4-5-20250929

**Status:** Implemented

#### What Was Done
- Added `assign` command to `src/teams_phone/cli/numbers.py`
- Created `AssignServices` class to bundle UserService, NumberService, LocationService, and ConfigManager
- Created `_run_assign_async` helper function for multi-service async operations
- Added helper functions: `_format_operation_status()`, `_operation_to_json_dict()`
- Command supports all required parameters: user, number, --location, --type, --dry-run, --no-wait, --force
- User resolution via UserService.resolve_user()
- Location validation for Calling Plan numbers (checks explicit --location, then tenant default_location)
- Dry-run preview showing user, phone number, type, and location
- Confirmation prompt before execution (skipped with --force or --json)
- JSON mode support for all outputs
- Added 12 unit tests covering: argument validation, dry-run, E911 validation, Direct Routing without location, success/failure paths, confirmation cancel, no-wait mode, user not found

#### Files Changed
- `src/teams_phone/cli/numbers.py` - Added 200+ lines for assign command
- `tests/unit/test_cli_numbers.py` - Added 12 new tests (test count: 29 → 41)

#### Key Decisions Made
- **AssignServices pattern**: Created a container class to hold multiple services needed by assign/unassign, keeping the coro_factory pattern consistent with existing _run_async
- **Location validation in CLI**: Validate location early in CLI layer (before calling NumberService) to provide better UX with --location hint, even though NumberService also validates
- **ConfigManager API**: Used `get_default_tenant()` + `get_tenant(name)` instead of non-existent `get_current_tenant()` method

#### Patterns Discovered
- `EmergencyLocation.location_id` is the ID field, not `.id` - see `src/teams_phone/models/location.py:27`
- CLI tests that need to control ConfigManager behavior must patch `get_context` to return a mock CLIContext with mocked `get_config_manager()`
- ANSI escape codes appear in CLI output even with --no-color for phone numbers - tests should check for partial content

#### Open Items for Next Agent
- [ ] Subtask 23.2: Implement unassign command (similar patterns)
- [ ] Subtask 23.3: Add progress display for long-running operations

#### Gotchas and Warnings
- `_run_assign_async` returns `(result, formatter)` tuple - when coroutine returns a tuple, unpack like: `(a, b), _ = _run_assign_async(...)`
- Phone numbers in Rich console output may contain ANSI escape codes even with --no-color
- Import UserService from `teams_phone.services.user_service` directly to avoid circular imports

---

## Subtask 23.2 Implementation Notes

### Session: 2026-01-29 - Agent: claude-sonnet-4-5-20250929

**Status:** Implemented

#### What Was Done
- Added `unassign` command to `src/teams_phone/cli/numbers.py` (~100 lines)
- Created `_unassign_operation_to_json_dict()` helper for JSON output formatting
- Command supports all required parameters: number, --dry-run, --no-wait, --force/-f
- Number lookup via `NumberService.get_number()` to retrieve current assignment
- Validation that number is currently assigned (assignment_status == USER_ASSIGNED)
- Dry-run preview showing phone number, type, and current assignee
- Confirmation prompt before execution (skipped with --force or --json)
- Calls `NumberService.unassign_number()` with appropriate wait parameter
- Success/error output with operation status and remediation guidance
- JSON mode support with structured response
- Added 12 unit tests covering all acceptance criteria

#### Files Changed
- `src/teams_phone/cli/numbers.py` - Added ~100 lines for unassign command at lines 645-770
- `tests/unit/test_cli_numbers.py` - Added 12 new tests (test count: 41 → 53)

#### Key Decisions Made
- **Simpler than assign**: Used `_run_async` (single-service) instead of `_run_assign_async` (multi-service) since unassign only needs NumberService
- **Validation at CLI level**: Check assignment_status before calling unassign_number to provide better error message with current status
- **Separate JSON helper**: Created `_unassign_operation_to_json_dict()` instead of reusing `_operation_to_json_dict()` since unassign has different fields (previous_assignee vs user details)

#### Patterns Followed from 23.1
- Confirmation prompt pattern: `typer.confirm()` with formatter.info() details display
- Dry-run pattern: Show preview and `raise typer.Exit(0)` without executing
- JSON mode check: `if cli_ctx.json_output:` to skip confirmation prompt
- Error handling: Catch `TeamsPhoneError` and format via `formatter.error()`
- Status-based output: Check `result.status` for SUCCEEDED/FAILED/NOT_STARTED

#### Tests Written (12 total)
1. test_unassign_shows_in_help - Command appears in help
2. test_unassign_requires_number_argument - Missing argument error
3. test_unassign_with_confirmation_prompt - Mock typer.confirm cancelled
4. test_unassign_with_force_skips_confirmation - --force flag bypasses prompt
5. test_unassign_on_already_unassigned_number_shows_error - Validation error for unassigned
6. test_unassign_dry_run_preview_output - Preview without changes
7. test_unassign_no_wait_returns_immediately - --no-wait mode shows operation ID
8. test_unassign_json_output_format - JSON mode output structure
9. test_unassign_success_output - Standard success message
10. test_unassign_failed_operation_exits_with_error - Handle failed status
11. test_unassign_number_not_found_shows_error - NotFoundError handling
12. test_unassign_dry_run_json_output - Dry-run JSON output structure

#### Open Items for Next Agent
- [ ] Subtask 23.3: Add progress display for long-running operations (affects both assign and unassign commands)

#### Gotchas and Warnings
- ANSI escape codes in test output: Use partial string matching (e.g., `"user-" in stdout and "123" in stdout`) instead of exact `"user-123"` match
- `_run_async` is sufficient for unassign since it only needs NumberService (unlike assign which needs UserService + LocationService)

---

## Subtask 23.3 Implementation Notes

### Session: 2026-01-29 - Agent: claude-sonnet-4-5-20250929

**Status:** Implemented

#### What Was Done
- Modified `NumberService.assign_number()` to accept `on_progress` callback parameter and pass it to `poll_operation`
- Modified `NumberService.unassign_number()` to accept `on_progress` callback parameter and pass it to `poll_operation`
- Added `_run_with_progress()` helper function in `numbers.py` that wraps async operations with Rich Status spinner
- Integrated spinner display in assign command when `wait=True` (default behavior)
- Integrated spinner display in unassign command when `wait=True` (default behavior)
- Spinner shows initial message ("Assigning phone number..." / "Unassigning phone number...")
- Spinner updates with operation status on each poll callback (e.g., "Waiting for operation... (Running)")
- JSON mode runs silently without spinner (on_progress=None)
- --no-wait mode continues to return immediately without spinner
- Added 4 new unit tests verifying on_progress callback behavior

#### Files Changed
- `src/teams_phone/services/number_service.py` - Added `on_progress` parameter to `assign_number` and `unassign_number` methods
- `src/teams_phone/cli/numbers.py` - Added `_run_with_progress` helper and refactored assign/unassign to use it
- `tests/unit/test_cli_numbers.py` - Added 4 new tests (test count: 53 → 57)

#### Key Decisions Made
- **Rich Status vs Progress bar**: Used `console.status()` with spinner instead of Progress bar since operation count is unknown (polling until completion)
- **Callback pattern**: The `on_progress` callback is called synchronously from within `poll_operation` on each poll iteration, making Status updates thread-safe
- **Architecture refactor**: When `wait=True` (not no_wait), the command now creates its own services within `_run_with_progress` rather than using `_run_assign_async`. This is necessary to pass the `on_progress` callback to the service method.
- **Status text format**: Shows human-readable status via existing `_format_operation_status()` function (e.g., "Running", "Not Started")

#### Tests Written (4 total)
1. test_assign_wait_calls_on_progress_callback - Verify on_progress callback is passed (not None) when waiting
2. test_assign_json_mode_no_progress_callback - Verify on_progress is None in JSON mode
3. test_unassign_wait_calls_on_progress_callback - Verify on_progress callback is passed (not None) when waiting
4. test_unassign_json_mode_no_progress_callback - Verify on_progress is None in JSON mode

#### Patterns Discovered
- `formatter.console.status()` context manager provides spinner with updatable text
- `status.update("new message")` can be called from within the async context since `poll_operation` calls callback synchronously
- Type annotation for coro_factory: `Callable[[Callable[[AsyncOperation], None] | None], Coroutine[Any, Any, AsyncOperation]]`

#### Open Items for Next Agent
- None - Task 23 is now complete with all subtasks implemented

#### Gotchas and Warnings
- When testing progress behavior, capture `on_progress` parameter in mock side_effect rather than checking call_args since the closure reference may be different
- The `wait=True` path creates services within `_run_with_progress` rather than using `_run_assign_async`, so mocking needs to patch at the right level

---
