# Subtask 4: Non-Interactive Mode

**Status**: COMPLETED
**PR**: [#20](https://github.com/donghaozhang/video-agent-skill/pull/20)
**Parent**: [plan-unix-style-migration.md](plan-unix-style-migration.md)
**Depends on**: Subtask 1 (exit codes) - uses stderr pattern
**Estimated Time**: 30 minutes

## Implementation Summary
- Created `cli/interactive.py` with `is_interactive()` (9 CI env vars + TTY check) and `confirm()` helper
- Fixed `--no-confirm` default from `True` to `False` in `__main__.py` (was silently skipping cost confirmation)
- Replaced raw `input()` calls in `setup_env()` and `run_chain()` with `confirm()`
- `confirm()` returns default value in non-interactive mode (CI/piped), handles EOFError gracefully
- 14 tests in `tests/test_interactive.py` — all passing

---

## Objective

Add `--yes` / `--non-interactive` flags to skip all confirmation prompts. Auto-detect CI environments so pipelines work without user input in automated contexts.

---

## Critical Finding: `--no-confirm` defaults to True

**WARNING**: At `__main__.py:635`, `--no-confirm` is defined with `default=True`:
```python
chain_parser.add_argument("--no-confirm", action="store_true", default=True, help="Skip confirmation prompt")
```
This means the confirmation prompt at lines 278-282 is **already bypassed by default**. This is a safety concern — pipelines will execute without cost confirmation unless the user explicitly passes `--no-no-confirm` (which doesn't exist).

**Fix**: Change `--no-confirm` default to `False` and introduce `--yes` as the proper opt-in flag.

---

## Step-by-Step Implementation

### Step 1: Add `--yes` to global parser + fix `--no-confirm`

**File**: `packages/core/ai_content_pipeline/ai_content_pipeline/__main__.py`

In the global argument parser (line 598-599, after `--debug` and `--base-dir`):

```python
parser.add_argument('--yes', '-y', action='store_true', default=False,
    help='Skip all confirmation prompts (non-interactive mode)')
```

Fix `--no-confirm` at line 635 — change default to `False`:
```python
# Before:
chain_parser.add_argument("--no-confirm", action="store_true", default=True, help="Skip confirmation prompt")
# After:
chain_parser.add_argument("--no-confirm", action="store_true", default=False, help="Skip confirmation prompt (deprecated: use --yes)")
```

### Step 2: Create confirm helper

Add to `cli/output.py` (or a new `cli/interactive.py` if preferred):

```python
import os
import sys


# CI environment variable names
_CI_ENV_VARS = [
    'CI',
    'GITHUB_ACTIONS',
    'GITLAB_CI',
    'JENKINS_URL',
    'TF_BUILD',
    'CIRCLECI',
    'TRAVIS',
    'BUILDKITE',
]


def is_non_interactive(args=None) -> bool:
    """Check if running in non-interactive mode.

    Returns True if:
    - --yes flag is set
    - Any CI environment variable is set
    - stdin is not a TTY (piped input, cron, etc.)
    """
    if args and getattr(args, 'yes', False):
        return True
    if any(os.environ.get(v) for v in _CI_ENV_VARS):
        return True
    if not hasattr(sys.stdin, "isatty") or not sys.stdin.isatty():
        return True
    return False


def confirm(message: str, args=None, default: bool = False) -> bool:
    """Ask for user confirmation.

    In non-interactive mode, returns True (auto-accept).
    Otherwise prompts the user.

    Args:
        message: The confirmation question.
        args: Parsed CLI args (checked for --yes flag).
        default: Default answer when user just presses Enter.

    Returns:
        True if confirmed, False otherwise.
    """
    if is_non_interactive(args):
        return True

    suffix = " (Y/n): " if default else " (y/N): "
    try:
        response = input(message + suffix).strip().lower()
    except (EOFError, KeyboardInterrupt):
        print(file=sys.stderr)  # Newline after ^C
        return False

    if not response:
        return default
    return response in ('y', 'yes')
```

### Step 3: Replace `input()` prompts in `__main__.py`

**File**: `packages/core/ai_content_pipeline/ai_content_pipeline/__main__.py`

**Location 1 — `run_chain()` at lines 278-282:**
```python
# Before:
if not args.no_confirm:
    response = input("\nProceed with execution? (y/N): ")
    if response.lower() not in ['y', 'yes']:
        print("Execution cancelled.")
        sys.exit(0)

# After:
if not confirm("\nProceed with execution?", args):
    output.info("Execution cancelled.")
    sys.exit(0)
```

**Location 2 — `setup_env()` at lines 121-125:**
```python
# Before:
if env_path.exists():
    response = input(f"⚠️  .env file already exists at {env_path}. Overwrite? (y/N): ")
    if response.lower() != 'y':
        print("❌ Setup cancelled.")
        return

# After:
if env_path.exists():
    if not confirm(f"⚠️  .env file already exists at {env_path}. Overwrite?", args):
        output.info("Setup cancelled.")
        return
```

These are the only 2 `input()` calls in `__main__.py` used for confirmation.

### Step 4: Update `ai_content_platform` CLI

**File**: `packages/core/ai_content_platform/cli/commands.py`

**Current** (lines 96-99): Cost threshold confirmation
```python
if cost_summary.total_estimated_cost > 1.0:
    if not click.confirm(f"Proceed with estimated cost of ${cost_summary.total_estimated_cost:.2f}?"):
        console.print("Pipeline execution cancelled.")
        return
```

**New**:
```python
if cost_summary.total_estimated_cost > 1.0 and not ctx.obj.get('yes', False):
    if not click.confirm(f"Proceed with estimated cost of ${cost_summary.total_estimated_cost:.2f}?"):
        console.print("Pipeline execution cancelled.")
        return
```

Pass `--yes` through Click context by adding to the CLI group in `cli/main.py`.

### Step 5: Write tests

Add to existing test file or create `tests/test_non_interactive.py`:

```python
"""Tests for non-interactive mode."""

import os
import pytest
from unittest.mock import patch
from ai_content_pipeline.cli.output import is_non_interactive, confirm


class TestNonInteractive:
    def test_yes_flag_skips_confirmation(self):
        class Args:
            yes = True
        assert is_non_interactive(Args()) is True

    def test_ci_env_auto_skips(self):
        with patch.dict(os.environ, {'CI': 'true'}):
            assert is_non_interactive() is True

    def test_github_actions_auto_skips(self):
        with patch.dict(os.environ, {'GITHUB_ACTIONS': 'true'}):
            assert is_non_interactive() is True

    def test_default_is_interactive(self):
        # Clear all CI vars
        env = {v: '' for v in ['CI', 'GITHUB_ACTIONS', 'GITLAB_CI',
                                'JENKINS_URL', 'TF_BUILD']}
        with patch.dict(os.environ, env, clear=False):
            class Args:
                yes = False
            # Only non-interactive if CI vars are truly absent
            # (this test may need adjustment based on actual env)


class TestConfirm:
    def test_confirm_returns_true_in_non_interactive(self):
        class Args:
            yes = True
        assert confirm("Continue?", args=Args()) is True

    def test_confirm_returns_true_with_ci(self):
        with patch.dict(os.environ, {'CI': 'true'}):
            assert confirm("Continue?") is True

    def test_confirm_handles_eof(self):
        with patch('builtins.input', side_effect=EOFError):
            assert confirm("Continue?") is False
```

---

## Verification

```bash
# Non-interactive mode
ai-content-pipeline run-chain --config pipeline.yaml --yes --json

# CI simulation
CI=true ai-content-pipeline run-chain --config pipeline.yaml --json

# Run tests
python -m pytest tests/test_non_interactive.py -v
```

---

## Notes

- `--yes` only affects confirmation prompts, not error handling
- `is_interactive()` checks `sys.stdin.isatty()` as part of its detection: if stdin is not a TTY (piped input, cron, etc.), the session is considered non-interactive. Note that `--input -` reads from stdin separately and does not conflict with this check because `confirm()` simply returns the default value without prompting in non-interactive mode.
- The `confirm()` helper catches `EOFError` and `KeyboardInterrupt` gracefully
