# sqlflag Implementation Plan

> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a Python library that auto-generates a user-friendly CLI from any SQLite database, where tables become subcommands and columns become filter flags.

**Architecture:** `SqlFlag` is the public entry point. It uses `SchemaInfo` to introspect the database, generates Click commands with dynamic column flags, delegates query execution to `QueryEngine` (which uses `ValueParser` to compile `op:value` strings to SQL), and pipes results through `format_rows()` for output. sqlite-utils handles all database access.

**Tech Stack:** Python 3.10+, sqlite-utils, typer/click, rich, pytest

**Spec:** `docs/superpowers/specs/2026-03-17-sqlflag-design.md`

---

## File Structure

```
sqlflag/                          # Project root (rename from sqlcli/)
├── pyproject.toml
├── src/
│   └── sqlflag/
│       ├── __init__.py           # Public API: SqlFlag, QueryEngine
│       ├── parser.py             # ValueParser: op:value → SQL fragments
│       ├── schema.py             # SchemaInfo: introspect tables, type categories
│       ├── query.py              # QueryEngine: filters → parameterized SQL → rows
│       ├── formatter.py          # format_rows(): table/json/csv output
│       └── cli.py                # SqlFlag: CLI generation, mount, run
├── tests/
│   ├── conftest.py               # Sample database fixtures
│   ├── test_parser.py
│   ├── test_schema.py
│   ├── test_query.py
│   ├── test_formatter.py
│   └── test_cli.py
└── docs/
    └── superpowers/
        ├── specs/
        │   └── 2026-03-17-sqlflag-design.md
        └── plans/
            └── 2026-03-17-sqlflag.md
```

**Responsibilities:**

- `parser.py`: Pure function. Parses a single value string into `(sql_fragment, params)`. Handles all 7 operators, `null`, relative dates. No database dependency.
- `schema.py`: Wraps sqlite-utils schema introspection. Maps SQLite types to categories (TEXT, INTEGER, BOOLEAN, DATETIME). Reports available operators per column.
- `query.py`: Accepts filter dicts, compiles them to parameterized SQL via `parser.py`, executes via sqlite-utils, returns row dicts.
- `formatter.py`: Pure output. Takes row dicts + column list, writes to stdout as table/json/csv.
- `cli.py`: `SqlFlag` class. Builds Click commands from schema, wires reserved flags, delegates to `QueryEngine` and `formatter`.

---

### Task 1: Project Scaffold

**Files:**
- Create: `pyproject.toml`
- Create: `src/sqlflag/__init__.py`
- Create: `tests/conftest.py`

- [ ] **Step 1: Initialize git repo and create pyproject.toml**

```bash
cd /home/spinoza/github/beta/sqlcli
git init
```

```toml
# pyproject.toml
[project]
name = "sqlflag"
version = "0.1.0"
description = "Auto-generate CLIs from SQLite databases. Tables become subcommands, columns become filter flags."
requires-python = ">=3.10"
license = "MIT"
dependencies = [
    "sqlite-utils>=3.35",
    "typer>=0.9",
    "rich>=13",
]

[project.optional-dependencies]
dev = [
    "pytest>=7",
    "pytest-cov",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/sqlflag"]

[tool.pytest.ini_options]
testpaths = ["tests"]
```

- [ ] **Step 2: Create package init and test conftest**

```python
# src/sqlflag/__init__.py
"""sqlflag: Auto-generate CLIs from SQLite databases."""
```

```python
# tests/conftest.py
import pytest
from sqlite_utils import Database


@pytest.fixture
def sample_db(tmp_path):
    """Create a sample database with typed tables for testing."""
    db_path = str(tmp_path / "test.db")
    db = Database(db_path)

    db.execute("""CREATE TABLE repos (
        name TEXT,
        language TEXT,
        stars INTEGER,
        is_archived BOOLEAN,
        created_at DATETIME,
        description TEXT
    )""")
    db["repos"].insert_all([
        {"name": "alpha", "language": "python", "stars": 100,
         "is_archived": 0, "created_at": "2024-01-15", "description": "Alpha project"},
        {"name": "beta", "language": "go", "stars": 50,
         "is_archived": 1, "created_at": "2024-06-01", "description": "Beta project"},
        {"name": "gamma", "language": "python", "stars": 200,
         "is_archived": 0, "created_at": "2025-03-10", "description": None},
        {"name": "delta", "language": "rust", "stars": 75,
         "is_archived": 0, "created_at": "2025-11-20", "description": "Delta tools"},
    ])

    db.execute("""CREATE TABLE events (
        repo_name TEXT,
        event_type TEXT,
        timestamp DATETIME
    )""")
    db["events"].insert_all([
        {"repo_name": "alpha", "event_type": "push", "timestamp": "2026-03-15T10:00:00"},
        {"repo_name": "alpha", "event_type": "release", "timestamp": "2026-03-01T14:00:00"},
        {"repo_name": "beta", "event_type": "push", "timestamp": "2026-03-10T09:30:00"},
    ])

    db.execute("""CREATE VIEW active_repos AS
        SELECT * FROM repos WHERE is_archived = 0
    """)

    # Table with reserved column names and invalid flag names for edge case testing
    db.execute("""CREATE TABLE edge_cases (
        id INTEGER,
        format TEXT,
        name TEXT
    )""")
    db["edge_cases"].insert_all([
        {"id": 1, "format": "json", "name": "test"},
    ])

    return db_path


@pytest.fixture
def sample_db_with_fts(sample_db):
    """Extend sample_db with FTS index on repos.description."""
    db = Database(sample_db)
    db["repos"].enable_fts(["name", "description"], create_triggers=True)
    return sample_db
```

- [ ] **Step 3: Install in dev mode and verify**

Run: `cd /home/spinoza/github/beta/sqlcli && pip install -e ".[dev]"`
Run: `pytest tests/ -v`
Expected: collected 0 items (no tests yet, but no errors)

- [ ] **Step 4: Commit**

```bash
git add pyproject.toml src/ tests/conftest.py docs/
git commit -m "scaffold: init sqlflag project with pyproject.toml and test fixtures"
```

---

### Task 2: Value Parser

**Files:**
- Create: `src/sqlflag/parser.py`
- Create: `tests/test_parser.py`

The value parser is the core innovation. It takes a single string (a CLI flag value) and returns a SQL fragment with parameters.

- [ ] **Step 1: Write tests for all operator cases**

```python
# tests/test_parser.py
import pytest
from datetime import datetime, timedelta
from sqlflag.parser import parse_value


class TestBareValues:
    def test_equality_string(self):
        frag, params = parse_value("language", "python")
        assert frag == "language = ?"
        assert params == ["python"]

    def test_equality_integer(self):
        frag, params = parse_value("stars", "50", col_type="INTEGER")
        assert frag == "stars = ?"
        assert params == [50]

    def test_null(self):
        frag, params = parse_value("description", "null")
        assert frag == "description IS NULL"
        assert params == []

    def test_unknown_prefix_treated_as_literal(self):
        frag, params = parse_value("url", "http://example.com")
        assert frag == "url = ?"
        assert params == ["http://example.com"]

    def test_colon_in_value_not_operator(self):
        frag, params = parse_value("tag", "foo:bar")
        assert frag == "tag = ?"
        assert params == ["foo:bar"]


class TestOperators:
    def test_not(self):
        frag, params = parse_value("language", "not:python")
        assert frag == "language != ?"
        assert params == ["python"]

    def test_not_null(self):
        frag, params = parse_value("description", "not:null")
        assert frag == "description IS NOT NULL"
        assert params == []

    def test_gt(self):
        frag, params = parse_value("stars", "gt:50", col_type="INTEGER")
        assert frag == "stars > ?"
        assert params == [50]

    def test_lt(self):
        frag, params = parse_value("stars", "lt:100", col_type="INTEGER")
        assert frag == "stars < ?"
        assert params == [100]

    def test_contains(self):
        frag, params = parse_value("description", "contains:test")
        assert frag == "description LIKE ?"
        assert params == ["%test%"]


class TestDateOperators:
    def test_since_absolute(self):
        frag, params = parse_value("created_at", "since:2024-01-01")
        assert frag == "created_at >= ?"
        assert params == ["2024-01-01"]

    def test_before_absolute(self):
        frag, params = parse_value("created_at", "before:2025-06-15")
        assert frag == "created_at < ?"
        assert params == ["2025-06-15"]

    def test_since_relative_days(self):
        frag, params = parse_value("created_at", "since:30d")
        assert frag == "created_at >= ?"
        assert len(params) == 1
        # Should be roughly 30 days ago in ISO format
        parsed = datetime.fromisoformat(params[0])
        delta = datetime.now() - parsed
        assert 29 <= delta.days <= 31

    def test_since_relative_hours(self):
        frag, params = parse_value("timestamp", "since:6h")
        assert frag == "timestamp >= ?"
        parsed = datetime.fromisoformat(params[0])
        delta = datetime.now() - parsed
        assert 5 * 3600 <= delta.total_seconds() <= 7 * 3600

    def test_since_relative_weeks(self):
        frag, params = parse_value("created_at", "since:2w")
        assert frag == "created_at >= ?"
        parsed = datetime.fromisoformat(params[0])
        delta = datetime.now() - parsed
        assert 13 <= delta.days <= 15

    def test_since_relative_months(self):
        frag, params = parse_value("created_at", "since:3mo")
        assert frag == "created_at >= ?"
        parsed = datetime.fromisoformat(params[0])
        delta = datetime.now() - parsed
        assert 80 <= delta.days <= 100

    def test_since_relative_minutes(self):
        frag, params = parse_value("timestamp", "since:30min")
        assert frag == "timestamp >= ?"
        parsed = datetime.fromisoformat(params[0])
        delta = datetime.now() - parsed
        assert 25 * 60 <= delta.total_seconds() <= 35 * 60

    def test_since_relative_years(self):
        frag, params = parse_value("created_at", "since:1y")
        assert frag == "created_at >= ?"
        parsed = datetime.fromisoformat(params[0])
        delta = datetime.now() - parsed
        assert 350 <= delta.days <= 380


class TestTypeCoercion:
    def test_integer_coercion(self):
        _, params = parse_value("stars", "50", col_type="INTEGER")
        assert params == [50]
        assert isinstance(params[0], int)

    def test_real_coercion(self):
        _, params = parse_value("score", "3.14", col_type="REAL")
        assert params == [3.14]
        assert isinstance(params[0], float)

    def test_text_no_coercion(self):
        _, params = parse_value("name", "50", col_type="TEXT")
        assert params == ["50"]
        assert isinstance(params[0], str)

    def test_default_str(self):
        _, params = parse_value("name", "50")
        assert params == ["50"]
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_parser.py -v`
Expected: FAIL (ModuleNotFoundError: No module named 'sqlflag.parser')

- [ ] **Step 3: Implement the value parser**

```python
# src/sqlflag/parser.py
"""Parse op:value flag values into SQL fragments with parameters."""

from datetime import datetime, timedelta
import re

OPERATORS = {
    "not": "not",
    "gt": "gt",
    "lt": "lt",
    "contains": "contains",
    "since": "since",
    "before": "before",
}

_RELATIVE_DATE_RE = re.compile(r"^(\d+)(min|h|d|w|mo|y)$")


def parse_relative_date(value: str) -> str | None:
    """Parse a relative date string like '30d' into an ISO 8601 datetime string.

    Returns None if the value is not a relative date.
    """
    match = _RELATIVE_DATE_RE.match(value)
    if not match:
        return None
    amount = int(match.group(1))
    unit = match.group(2)
    now = datetime.now()
    if unit == "min":
        dt = now - timedelta(minutes=amount)
    elif unit == "h":
        dt = now - timedelta(hours=amount)
    elif unit == "d":
        dt = now - timedelta(days=amount)
    elif unit == "w":
        dt = now - timedelta(weeks=amount)
    elif unit == "mo":
        dt = now - timedelta(days=amount * 30)
    elif unit == "y":
        dt = now - timedelta(days=amount * 365)
    else:
        return None
    return dt.isoformat()


def _coerce_value(value: str, col_type: str) -> int | float | str:
    """Coerce a string value to the appropriate Python type based on column type."""
    col_type_upper = (col_type or "").upper()
    if col_type_upper == "INTEGER":
        try:
            return int(value)
        except ValueError:
            return value
    if col_type_upper == "REAL":
        try:
            return float(value)
        except ValueError:
            return value
    return value


def parse_value(
    column: str, value: str, col_type: str = "TEXT"
) -> tuple[str, list]:
    """Parse a single flag value into a SQL fragment and parameter list.

    Args:
        column: The SQL column name.
        value: The raw flag value, possibly with an operator prefix (e.g., "gt:50").
        col_type: The SQLite column type (TEXT, INTEGER, REAL, BOOLEAN, DATETIME).

    Returns:
        A tuple of (sql_fragment, params) where sql_fragment contains '?' placeholders.
    """
    # Rule 1: check for known operator prefix
    for op_name in OPERATORS:
        prefix = op_name + ":"
        if value.startswith(prefix):
            rest = value[len(prefix):]
            return _apply_operator(column, op_name, rest, col_type)

    # Rule 2: literal null
    if value == "null":
        return f"{column} IS NULL", []

    # Rule 3: bare value = equality
    coerced = _coerce_value(value, col_type)
    return f"{column} = ?", [coerced]


def _apply_operator(
    column: str, op: str, value: str, col_type: str
) -> tuple[str, list]:
    """Apply a known operator to produce a SQL fragment."""
    if op == "not":
        if value == "null":
            return f"{column} IS NOT NULL", []
        coerced = _coerce_value(value, col_type)
        return f"{column} != ?", [coerced]

    if op == "gt":
        coerced = _coerce_value(value, col_type)
        return f"{column} > ?", [coerced]

    if op == "lt":
        coerced = _coerce_value(value, col_type)
        return f"{column} < ?", [coerced]

    if op == "contains":
        escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
        return f"{column} LIKE ? ESCAPE '\\'", [f"%{escaped}%"]

    if op == "since":
        resolved = parse_relative_date(value) or value
        return f"{column} >= ?", [resolved]

    if op == "before":
        resolved = parse_relative_date(value) or value
        return f"{column} < ?", [resolved]

    # Should not reach here (operator set is closed)
    coerced = _coerce_value(value, col_type)
    return f"{column} = ?", [coerced]
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/test_parser.py -v`
Expected: All PASS

- [ ] **Step 5: Commit**

```bash
git add src/sqlflag/parser.py tests/test_parser.py
git commit -m "feat: value parser with 7 operators and relative date support"
```

---

### Task 3: Schema Introspection

**Files:**
- Create: `src/sqlflag/schema.py`
- Create: `tests/test_schema.py`

- [ ] **Step 1: Write tests for schema introspection**

```python
# tests/test_schema.py
from sqlflag.schema import SchemaInfo


class TestSchemaInfo:
    def test_table_names(self, sample_db):
        info = SchemaInfo(sample_db)
        names = info.table_names()
        assert "repos" in names
        assert "events" in names

    def test_view_names(self, sample_db):
        info = SchemaInfo(sample_db)
        names = info.view_names()
        assert "active_repos" in names

    def test_all_queryable(self, sample_db):
        info = SchemaInfo(sample_db)
        names = info.queryable_names()
        assert "repos" in names
        assert "events" in names
        assert "active_repos" in names

    def test_columns(self, sample_db):
        info = SchemaInfo(sample_db)
        cols = info.columns("repos")
        col_names = [c.name for c in cols]
        assert "name" in col_names
        assert "stars" in col_names
        assert "created_at" in col_names

    def test_column_type_category(self, sample_db):
        info = SchemaInfo(sample_db)
        assert info.type_category("repos", "name") == "TEXT"
        assert info.type_category("repos", "stars") == "INTEGER"
        assert info.type_category("repos", "is_archived") == "BOOLEAN"
        assert info.type_category("repos", "created_at") == "DATETIME"

    def test_operators_for_column(self, sample_db):
        info = SchemaInfo(sample_db)
        text_ops = info.operators_for("repos", "name")
        assert "not" in text_ops
        assert "contains" in text_ops
        assert "gt" not in text_ops

        int_ops = info.operators_for("repos", "stars")
        assert "gt" in int_ops
        assert "lt" in int_ops
        assert "contains" not in int_ops

        dt_ops = info.operators_for("repos", "created_at")
        assert "since" in dt_ops
        assert "before" in dt_ops
        assert "gt" not in dt_ops

    def test_row_count(self, sample_db):
        info = SchemaInfo(sample_db)
        assert info.row_count("repos") == 4
        assert info.row_count("events") == 3

    def test_has_fts(self, sample_db):
        info = SchemaInfo(sample_db)
        assert info.has_fts("repos") is False

    def test_has_fts_with_index(self, sample_db_with_fts):
        info = SchemaInfo(sample_db_with_fts)
        assert info.has_fts("repos") is True

    def test_tables_allowlist(self, sample_db):
        info = SchemaInfo(sample_db, tables=["repos"])
        assert info.queryable_names() == ["repos"]
        # But all tables still visible in schema
        assert "events" in info.table_names()

    def test_reserved_columns_excluded(self, sample_db):
        info = SchemaInfo(sample_db)
        flaggable = info.flaggable_columns("edge_cases")
        col_names = [c.name for c in flaggable]
        assert "format" not in col_names  # reserved name excluded
        assert "name" in col_names        # non-reserved name included
        assert "id" in col_names

    def test_fts_tables_filtered(self, sample_db_with_fts):
        info = SchemaInfo(sample_db_with_fts)
        names = info.queryable_names()
        assert "repos" in names
        assert "repos_fts" not in names
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_schema.py -v`
Expected: FAIL (ModuleNotFoundError)

- [ ] **Step 3: Implement SchemaInfo**

```python
# src/sqlflag/schema.py
"""Schema introspection and type categorization."""

from sqlite_utils import Database

RESERVED_FLAGS = frozenset({
    "all", "any", "order", "limit", "columns", "format", "search", "help",
})

TYPE_CATEGORIES = {
    "TEXT": "TEXT",
    "VARCHAR": "TEXT",
    "CHAR": "TEXT",
    "CLOB": "TEXT",
    "INTEGER": "INTEGER",
    "INT": "INTEGER",
    "BIGINT": "INTEGER",
    "SMALLINT": "INTEGER",
    "TINYINT": "INTEGER",
    "REAL": "REAL",
    "FLOAT": "REAL",
    "DOUBLE": "REAL",
    "NUMERIC": "REAL",
    "BOOLEAN": "BOOLEAN",
    "BOOL": "BOOLEAN",
    "DATETIME": "DATETIME",
    "TIMESTAMP": "DATETIME",
    "DATE": "DATETIME",
}

OPERATORS_BY_TYPE = {
    "TEXT": ["not", "contains"],
    "INTEGER": ["not", "gt", "lt"],
    "REAL": ["not", "gt", "lt"],
    "BOOLEAN": ["not"],
    "DATETIME": ["not", "since", "before"],
}


class SchemaInfo:
    """Introspect a SQLite database schema for CLI generation."""

    def __init__(self, db_path: str, tables: list[str] | None = None):
        self._db = Database(f"file:{db_path}?mode=ro", uri=True)
        self._tables_allowlist = tables

    def table_names(self) -> list[str]:
        return self._db.table_names()

    def view_names(self) -> list[str]:
        return self._db.view_names()

    def queryable_names(self) -> list[str]:
        """Tables and views exposed as query subcommands."""
        all_names = self.table_names() + self.view_names()
        # Filter out FTS internal tables
        fts_suffixes = ("_fts", "_fts_data", "_fts_idx", "_fts_content", "_fts_docsize", "_fts_config")
        all_names = [n for n in all_names if not any(n.endswith(s) for s in fts_suffixes)]
        if self._tables_allowlist is not None:
            return [n for n in self._tables_allowlist if n in all_names]
        return all_names

    def columns(self, table: str):
        """Return Column objects for a table or view."""
        return self._db[table].columns

    def flaggable_columns(self, table: str):
        """Columns that can become CLI flags (not reserved, valid flag names)."""
        return [
            c for c in self.columns(table)
            if c.name.lower() not in RESERVED_FLAGS
            and c.name.isidentifier()
            and not c.name.startswith("_")
        ]

    def type_category(self, table: str, column: str) -> str:
        """Map a column's SQLite type to a category (TEXT, INTEGER, REAL, BOOLEAN, DATETIME)."""
        for col in self.columns(table):
            if col.name == column:
                raw = (col.type or "").upper().strip()
                return TYPE_CATEGORIES.get(raw, "TEXT")
        return "TEXT"

    def operators_for(self, table: str, column: str) -> list[str]:
        """Return the list of useful operators for a column based on its type."""
        cat = self.type_category(table, column)
        return OPERATORS_BY_TYPE.get(cat, OPERATORS_BY_TYPE["TEXT"])

    def row_count(self, table: str) -> int:
        return self._db[table].count

    def has_fts(self, table: str) -> bool:
        """Check if a table has an FTS index."""
        fts_table = f"{table}_fts"
        return fts_table in self._db.table_names()
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/test_schema.py -v`
Expected: All PASS

- [ ] **Step 5: Commit**

```bash
git add src/sqlflag/schema.py tests/test_schema.py
git commit -m "feat: schema introspection with type categories and operator mapping"
```

---

### Task 4: Query Engine

**Files:**
- Create: `src/sqlflag/query.py`
- Create: `tests/test_query.py`

- [ ] **Step 1: Write tests for query engine**

```python
# tests/test_query.py
import pytest
from sqlflag.query import QueryEngine


class TestBasicQueries:
    def test_unfiltered(self, sample_db):
        engine = QueryEngine(sample_db)
        rows = list(engine.query("repos"))
        assert len(rows) == 4

    def test_equality_filter(self, sample_db):
        engine = QueryEngine(sample_db)
        rows = list(engine.query("repos", filters={"language": ["python"]}))
        assert len(rows) == 2
        assert all(r["language"] == "python" for r in rows)

    def test_gt_filter(self, sample_db):
        engine = QueryEngine(sample_db)
        rows = list(engine.query("repos", filters={"stars": ["gt:75"]}))
        assert len(rows) == 2
        assert all(r["stars"] > 75 for r in rows)

    def test_contains_filter(self, sample_db):
        engine = QueryEngine(sample_db)
        rows = list(engine.query("repos", filters={"name": ["contains:lph"]}))
        assert len(rows) == 1
        assert rows[0]["name"] == "alpha"

    def test_not_filter(self, sample_db):
        engine = QueryEngine(sample_db)
        rows = list(engine.query("repos", filters={"language": ["not:python"]}))
        assert len(rows) == 2
        assert all(r["language"] != "python" for r in rows)

    def test_null_filter(self, sample_db):
        engine = QueryEngine(sample_db)
        rows = list(engine.query("repos", filters={"description": ["null"]}))
        assert len(rows) == 1
        assert rows[0]["name"] == "gamma"


class TestMultipleValues:
    def test_in_clause(self, sample_db):
        engine = QueryEngine(sample_db)
        rows = list(engine.query("repos", filters={"language": ["python", "go"]}))
        assert len(rows) == 3

    def test_range(self, sample_db):
        engine = QueryEngine(sample_db)
        rows = list(engine.query("repos", filters={"stars": ["gt:50", "lt:150"]}))
        assert len(rows) == 2
        assert all(50 < r["stars"] < 150 for r in rows)


class TestConjunction:
    def test_and_across_flags(self, sample_db):
        engine = QueryEngine(sample_db)
        rows = list(engine.query("repos", filters={
            "language": ["python"], "stars": ["gt:150"],
        }))
        assert len(rows) == 1
        assert rows[0]["name"] == "gamma"

    def test_or_across_flags(self, sample_db):
        engine = QueryEngine(sample_db)
        rows = list(engine.query("repos", filters={
            "language": ["go"], "stars": ["gt:150"],
        }, conjunction="any"))
        assert len(rows) == 2  # beta (go) + gamma (stars>150)


class TestOrderAndLimit:
    def test_order_asc(self, sample_db):
        engine = QueryEngine(sample_db)
        rows = list(engine.query("repos", order=["stars"]))
        assert rows[0]["stars"] <= rows[-1]["stars"]

    def test_order_desc(self, sample_db):
        engine = QueryEngine(sample_db)
        rows = list(engine.query("repos", order=["-stars"]))
        assert rows[0]["stars"] >= rows[-1]["stars"]

    def test_limit(self, sample_db):
        engine = QueryEngine(sample_db)
        rows = list(engine.query("repos", limit=2))
        assert len(rows) == 2

    def test_columns_select(self, sample_db):
        engine = QueryEngine(sample_db)
        rows = list(engine.query("repos", columns=["name", "stars"]))
        assert set(rows[0].keys()) == {"name", "stars"}


class TestRawSql:
    def test_raw_sql(self, sample_db):
        engine = QueryEngine(sample_db)
        rows = list(engine.execute_sql("SELECT name FROM repos WHERE stars > 100"))
        assert len(rows) == 1
        assert rows[0]["name"] == "gamma"

    def test_raw_sql_read_only(self, sample_db):
        engine = QueryEngine(sample_db)
        with pytest.raises(Exception):
            list(engine.execute_sql("DROP TABLE repos"))


class TestViews:
    def test_query_view(self, sample_db):
        engine = QueryEngine(sample_db)
        rows = list(engine.query("active_repos"))
        assert len(rows) == 3
        assert all(r["is_archived"] == 0 for r in rows)


class TestSearch:
    def test_fts_search(self, sample_db_with_fts):
        engine = QueryEngine(sample_db_with_fts)
        rows = list(engine.search("repos", "alpha"))
        assert len(rows) >= 1
        assert any(r["name"] == "alpha" for r in rows)
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_query.py -v`
Expected: FAIL (ModuleNotFoundError)

- [ ] **Step 3: Implement QueryEngine**

```python
# src/sqlflag/query.py
"""Query engine: compile filters to parameterized SQL and execute."""

from sqlite_utils import Database
from sqlflag.parser import parse_value
from sqlflag.schema import SchemaInfo


class QueryEngine:
    """Execute queries against a SQLite database using the sqlflag filter syntax."""

    def __init__(self, db_path: str):
        self._db_path = db_path
        self._db = Database(f"file:{db_path}?mode=ro", uri=True)
        self._schema = SchemaInfo(db_path)

    def _fetchall_dicts(self, sql: str, params: list | None = None) -> list[dict]:
        """Execute SQL and return results as a list of dicts."""
        import sqlite3
        old_factory = self._db.conn.row_factory
        self._db.conn.row_factory = sqlite3.Row
        try:
            cursor = self._db.execute(sql, params or [])
            return [dict(row) for row in cursor.fetchall()]
        finally:
            self._db.conn.row_factory = old_factory

    def query(
        self,
        table: str,
        filters: dict[str, list[str]] | None = None,
        conjunction: str = "all",
        order: list[str] | None = None,
        limit: int | None = None,
        columns: list[str] | None = None,
        search: str | None = None,
    ) -> list[dict]:
        """Query a table with filters, returning a list of row dicts."""
        where_clause, params = self._compile_filters(table, filters or {}, conjunction)
        order_clause = self._compile_order(order)
        select = ", ".join(f"[{c}]" for c in columns) if columns else "*"

        # FTS search: AND with other filters via rowid subquery
        if search:
            fts_condition = f"[{table}].rowid IN (SELECT rowid FROM [{table}_fts] WHERE [{table}_fts] MATCH ?)"
            params.append(search)
            if where_clause:
                where_clause = f"{fts_condition} AND ({where_clause})"
            else:
                where_clause = fts_condition

        sql = f"SELECT {select} FROM [{table}]"
        if where_clause:
            sql += f" WHERE {where_clause}"
        if order_clause:
            sql += f" ORDER BY {order_clause}"
        if limit is not None:
            sql += f" LIMIT {limit}"

        return self._fetchall_dicts(sql, params)

    def execute_sql(self, sql: str) -> list[dict]:
        """Execute raw SQL (read-only)."""
        return self._fetchall_dicts(sql)

    def search(self, table: str, query: str) -> list[dict]:
        """Full-text search on a table (requires FTS index)."""
        return list(self._db[table].search(query))

    def _compile_filters(
        self,
        table: str,
        filters: dict[str, list[str]],
        conjunction: str,
    ) -> tuple[str, list]:
        """Compile a filters dict into a WHERE clause and params list."""
        if not filters:
            return "", []

        flag_clauses = []
        all_params = []

        for col_name, values in filters.items():
            col_type = self._schema.type_category(table, col_name)
            bare_values = []
            op_fragments = []
            op_params = []

            for v in values:
                # Check if this value has an operator prefix
                has_op = False
                for op_name in ("not", "gt", "lt", "contains", "since", "before"):
                    if v.startswith(op_name + ":"):
                        has_op = True
                        break

                if v == "null" or has_op:
                    frag, params = parse_value(col_name, v, col_type)
                    op_fragments.append(frag)
                    op_params.extend(params)
                else:
                    bare_values.append(v)

            # Bare values: single = equality, multiple = IN
            col_conditions = []
            col_params = []
            if len(bare_values) == 1:
                frag, params = parse_value(col_name, bare_values[0], col_type)
                col_conditions.append(frag)
                col_params.extend(params)
            elif len(bare_values) > 1:
                from sqlflag.parser import _coerce_value
                coerced = [_coerce_value(v, col_type) for v in bare_values]
                placeholders = ", ".join("?" for _ in coerced)
                col_conditions.append(f"{col_name} IN ({placeholders})")
                col_params.extend(coerced)

            col_conditions.extend(op_fragments)
            col_params.extend(op_params)

            if col_conditions:
                # Conditions within a flag always AND together
                within = " AND ".join(col_conditions)
                if len(col_conditions) > 1:
                    within = f"({within})"
                flag_clauses.append(within)
                all_params.extend(col_params)

        if not flag_clauses:
            return "", []

        joiner = " OR " if conjunction == "any" else " AND "
        where = joiner.join(flag_clauses)
        return where, all_params

    def _compile_order(self, order: list[str] | None) -> str:
        if not order:
            return ""
        parts = []
        for spec in order:
            if spec.startswith("-"):
                parts.append(f"[{spec[1:]}] DESC")
            else:
                parts.append(f"[{spec}]")
        return ", ".join(parts)
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/test_query.py -v`
Expected: All PASS

- [ ] **Step 5: Commit**

```bash
git add src/sqlflag/query.py tests/test_query.py
git commit -m "feat: query engine with filter compilation, AND/OR, order, limit"
```

---

### Task 5: Output Formatter

**Files:**
- Create: `src/sqlflag/formatter.py`
- Create: `tests/test_formatter.py`

- [ ] **Step 1: Write tests for output formatting**

```python
# tests/test_formatter.py
import json
from io import StringIO
from sqlflag.formatter import format_rows


SAMPLE_ROWS = [
    {"name": "alpha", "stars": 100},
    {"name": "beta", "stars": 50},
]


class TestJsonFormat:
    def test_json_output(self):
        buf = StringIO()
        format_rows(SAMPLE_ROWS, fmt="json", file=buf)
        lines = buf.getvalue().strip().split("\n")
        assert len(lines) == 2
        assert json.loads(lines[0]) == {"name": "alpha", "stars": 100}
        assert json.loads(lines[1]) == {"name": "beta", "stars": 50}


class TestCsvFormat:
    def test_csv_output(self):
        buf = StringIO()
        format_rows(SAMPLE_ROWS, fmt="csv", file=buf)
        lines = buf.getvalue().strip().split("\n")
        assert lines[0] == "name,stars"
        assert lines[1] == "alpha,100"

    def test_csv_empty(self):
        buf = StringIO()
        format_rows([], fmt="csv", file=buf)
        assert buf.getvalue().strip() == ""


class TestTableFormat:
    def test_table_output(self):
        buf = StringIO()
        format_rows(SAMPLE_ROWS, fmt="table", file=buf)
        output = buf.getvalue()
        assert "alpha" in output
        assert "beta" in output
        assert "100" in output


class TestEmpty:
    def test_empty_rows(self):
        buf = StringIO()
        format_rows([], fmt="json", file=buf)
        assert buf.getvalue().strip() == ""
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_formatter.py -v`
Expected: FAIL (ModuleNotFoundError)

- [ ] **Step 3: Implement formatter**

```python
# src/sqlflag/formatter.py
"""Output formatting: table, json, csv."""

import csv
import json
import sys
from io import StringIO
from rich.console import Console
from rich.table import Table


def format_rows(
    rows: list[dict],
    fmt: str = "table",
    file=None,
) -> None:
    """Format and write rows to a file object.

    Args:
        rows: List of row dicts.
        fmt: Output format ('table', 'json', 'csv').
        file: Output file object. Defaults to sys.stdout.
    """
    if file is None:
        file = sys.stdout

    if not rows:
        return

    if fmt == "json":
        _write_json(rows, file)
    elif fmt == "csv":
        _write_csv(rows, file)
    elif fmt == "table":
        _write_table(rows, file)
    else:
        _write_json(rows, file)


def _write_json(rows: list[dict], file) -> None:
    for row in rows:
        file.write(json.dumps(row, default=str) + "\n")


def _write_csv(rows: list[dict], file) -> None:
    if not rows:
        return
    writer = csv.DictWriter(file, fieldnames=list(rows[0].keys()))
    writer.writeheader()
    writer.writerows(rows)


def _write_table(rows: list[dict], file) -> None:
    if not rows:
        return
    table = Table()
    columns = list(rows[0].keys())
    for col in columns:
        table.add_column(col)
    for row in rows:
        table.add_row(*[str(v) if v is not None else "" for v in row.values()])
    console = Console(file=file)
    console.print(table)


def detect_format() -> str:
    """Return 'table' if stdout is a TTY, 'json' otherwise."""
    return "table" if sys.stdout.isatty() else "json"
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/test_formatter.py -v`
Expected: All PASS

- [ ] **Step 5: Commit**

```bash
git add src/sqlflag/formatter.py tests/test_formatter.py
git commit -m "feat: output formatter with table/json/csv and TTY auto-detection"
```

---

### Task 6: CLI Query Commands

**Files:**
- Create: `src/sqlflag/cli.py`
- Create: `tests/test_cli.py`

This is the most complex task. We use Click directly for the query group (dynamic column flags) and Typer for the top-level app.

- [ ] **Step 1: Write tests for CLI query commands**

```python
# tests/test_cli.py
import json
from click.testing import CliRunner
from sqlflag.cli import SqlFlag


class TestQueryCommands:
    def test_query_all_rows(self, sample_db):
        app = SqlFlag(sample_db)
        runner = CliRunner()
        result = runner.invoke(app.click_app, ["query", "repos", "--format", "json"])
        assert result.exit_code == 0
        lines = result.output.strip().split("\n")
        assert len(lines) == 4

    def test_query_equality_filter(self, sample_db):
        app = SqlFlag(sample_db)
        runner = CliRunner()
        result = runner.invoke(app.click_app, [
            "query", "repos", "--language", "python", "--format", "json",
        ])
        assert result.exit_code == 0
        rows = [json.loads(line) for line in result.output.strip().split("\n")]
        assert len(rows) == 2
        assert all(r["language"] == "python" for r in rows)

    def test_query_operator_filter(self, sample_db):
        app = SqlFlag(sample_db)
        runner = CliRunner()
        result = runner.invoke(app.click_app, [
            "query", "repos", "--stars", "gt:75", "--format", "json",
        ])
        assert result.exit_code == 0
        rows = [json.loads(line) for line in result.output.strip().split("\n")]
        assert all(r["stars"] > 75 for r in rows)

    def test_query_multiple_values_in(self, sample_db):
        app = SqlFlag(sample_db)
        runner = CliRunner()
        result = runner.invoke(app.click_app, [
            "query", "repos",
            "--language", "python", "--language", "go",
            "--format", "json",
        ])
        assert result.exit_code == 0
        rows = [json.loads(line) for line in result.output.strip().split("\n")]
        assert len(rows) == 3

    def test_query_order(self, sample_db):
        app = SqlFlag(sample_db)
        runner = CliRunner()
        result = runner.invoke(app.click_app, [
            "query", "repos", "--order", "-stars", "--format", "json",
        ])
        assert result.exit_code == 0
        rows = [json.loads(line) for line in result.output.strip().split("\n")]
        assert rows[0]["stars"] >= rows[-1]["stars"]

    def test_query_limit(self, sample_db):
        app = SqlFlag(sample_db)
        runner = CliRunner()
        result = runner.invoke(app.click_app, [
            "query", "repos", "--limit", "2", "--format", "json",
        ])
        assert result.exit_code == 0
        lines = result.output.strip().split("\n")
        assert len(lines) == 2

    def test_query_columns(self, sample_db):
        app = SqlFlag(sample_db)
        runner = CliRunner()
        result = runner.invoke(app.click_app, [
            "query", "repos", "--columns", "name,stars", "--format", "json",
        ])
        assert result.exit_code == 0
        row = json.loads(result.output.strip().split("\n")[0])
        assert set(row.keys()) == {"name", "stars"}

    def test_query_any_mode(self, sample_db):
        app = SqlFlag(sample_db)
        runner = CliRunner()
        result = runner.invoke(app.click_app, [
            "query", "repos",
            "--language", "go", "--stars", "gt:150",
            "--any", "--format", "json",
        ])
        assert result.exit_code == 0
        rows = [json.loads(line) for line in result.output.strip().split("\n")]
        assert len(rows) == 2  # beta (go) + gamma (stars>150)

    def test_query_view(self, sample_db):
        app = SqlFlag(sample_db)
        runner = CliRunner()
        result = runner.invoke(app.click_app, [
            "query", "active_repos", "--format", "json",
        ])
        assert result.exit_code == 0
        rows = [json.loads(line) for line in result.output.strip().split("\n")]
        assert all(r["is_archived"] == 0 for r in rows)

    def test_query_contains(self, sample_db):
        app = SqlFlag(sample_db)
        runner = CliRunner()
        result = runner.invoke(app.click_app, [
            "query", "repos", "--name", "contains:lph", "--format", "json",
        ])
        assert result.exit_code == 0
        rows = [json.loads(line) for line in result.output.strip().split("\n")]
        assert len(rows) == 1
        assert rows[0]["name"] == "alpha"

    def test_hyphenated_flag(self, sample_db):
        """created_at column should be accessible as --created-at."""
        app = SqlFlag(sample_db)
        runner = CliRunner()
        result = runner.invoke(app.click_app, [
            "query", "repos", "--created-at", "since:2025-01-01", "--format", "json",
        ])
        assert result.exit_code == 0
        rows = [json.loads(line) for line in result.output.strip().split("\n")]
        assert len(rows) == 2  # gamma (2025-03-10) and delta (2025-11-20)


class TestTableAllowlist:
    def test_only_allowed_tables(self, sample_db):
        app = SqlFlag(sample_db, tables=["repos"])
        runner = CliRunner()
        # repos should work
        result = runner.invoke(app.click_app, ["query", "repos", "--format", "json"])
        assert result.exit_code == 0
        # events should not be a subcommand
        result = runner.invoke(app.click_app, ["query", "events", "--format", "json"])
        assert result.exit_code != 0
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_cli.py -v`
Expected: FAIL (ImportError)

- [ ] **Step 3: Implement SqlFlag CLI**

```python
# src/sqlflag/cli.py
"""CLI generation: SqlFlag class with dynamic query commands."""

import click
from sqlflag.query import QueryEngine
from sqlflag.schema import SchemaInfo, RESERVED_FLAGS
from sqlflag.formatter import format_rows, detect_format


class SqlFlag:
    """Auto-generate a CLI from a SQLite database."""

    def __init__(self, db_path: str, tables: list[str] | None = None):
        self._db_path = db_path
        self._tables = tables
        self._schema = SchemaInfo(db_path, tables=tables)
        self._engine = QueryEngine(db_path)
        self._click_app = self._build()

    @property
    def click_app(self) -> click.Group:
        return self._click_app

    def run(self):
        self._click_app()

    def _build(self) -> click.Group:
        root = click.Group()

        # query group
        query_group = click.Group(name="query", help="Query database tables.")
        for table_name in self._schema.queryable_names():
            cmd = self._make_table_command(table_name)
            query_group.add_command(cmd, name=table_name)
        root.add_command(query_group)

        # sql command
        @click.command(name="sql")
        @click.argument("query")
        @click.option("--format", "fmt", default=None,
                       type=click.Choice(["table", "json", "csv"]))
        def sql_cmd(query, fmt):
            """Execute raw SQL (read-only)."""
            fmt = fmt or detect_format()
            rows = self._engine.execute_sql(query)
            format_rows(rows, fmt=fmt)

        root.add_command(sql_cmd)

        # schema command
        @click.command(name="schema")
        @click.argument("table", required=False)
        def schema_cmd(table):
            """Inspect database structure."""
            if table:
                self._print_table_schema(table)
            else:
                self._print_schema_overview()

        root.add_command(schema_cmd)

        return root

    def _make_table_command(self, table_name: str) -> click.Command:
        """Create a Click command for querying a specific table."""
        schema = self._schema
        engine = self._engine

        # Build parameters: reserved flags + column flags
        params = [
            click.Option(["--any"], is_flag=True, default=False,
                          help="OR-compose conditions across flags (default is AND)."),
            click.Option(["--order"], multiple=True,
                          help="ORDER BY column. Prefix with - for DESC."),
            click.Option(["--limit"], type=int, default=None,
                          help="Max rows to return."),
            click.Option(["--columns"], default=None,
                          help="Comma-separated columns to display."),
            click.Option(["--format", "fmt"], default=None,
                          type=click.Choice(["table", "json", "csv"]),
                          help="Output format."),
        ]

        # FTS search flag (only if table has FTS index)
        if schema.has_fts(table_name):
            params.append(
                click.Option(["--search"], default=None,
                              help="Full-text search query.")
            )

        # Column flags
        col_map = {}  # flag_param_name -> column_name
        for col in schema.flaggable_columns(table_name):
            flag_name = f"--{col.name.replace('_', '-')}"
            param_name = col.name.replace("-", "_")
            col_map[param_name] = col.name
            col_type = schema.type_category(table_name, col.name)
            ops = schema.operators_for(table_name, col.name)
            help_text = f"Filter: {col.name} ({col_type}). Ops: {', '.join(ops)}"
            params.append(
                click.Option([flag_name], multiple=True, help=help_text)
            )

        def callback(**kwargs):
            fmt = kwargs.get("fmt") or detect_format()
            use_any = kwargs.get("any", False)
            order_specs = kwargs.get("order") or []
            limit = kwargs.get("limit")
            columns_str = kwargs.get("columns")
            search_query = kwargs.get("search")
            columns_list = [c.strip() for c in columns_str.split(",")] if columns_str else None

            # Collect column filters
            filters = {}
            for param_name, col_name in col_map.items():
                values = kwargs.get(param_name, ())
                if values:
                    filters[col_name] = list(values)

            rows = list(engine.query(
                table_name, filters=filters,
                conjunction="any" if use_any else "all",
                order=list(order_specs) if order_specs else None,
                limit=limit, columns=columns_list,
                search=search_query,
            ))

            format_rows(rows, fmt=fmt)

        return click.Command(
            name=table_name,
            params=params,
            callback=callback,
            help=f"Query the {table_name} table.",
        )

    def _print_schema_overview(self):
        """Print table list with row counts."""
        from rich.console import Console
        from rich.table import Table
        console = Console()
        table = Table()
        table.add_column("Table")
        table.add_column("Rows", justify="right")
        table.add_column("Columns", justify="right")
        for name in self._schema.table_names():
            cols = self._schema.columns(name)
            count = self._schema.row_count(name)
            table.add_row(name, str(count), str(len(cols)))
        for name in self._schema.view_names():
            cols = self._schema.columns(name)
            count = self._schema.row_count(name)
            table.add_row(f"{name} (view)", str(count), str(len(cols)))
        console.print(table)

    def _print_table_schema(self, table_name: str):
        """Print detailed schema for a single table."""
        from rich.console import Console
        from rich.table import Table
        console = Console()
        count = self._schema.row_count(table_name)
        console.print(f"Table: {table_name} ({count} rows)\n")
        table = Table()
        table.add_column("Column")
        table.add_column("Type")
        table.add_column("Operators")
        reserved = []
        for col in self._schema.columns(table_name):
            cat = self._schema.type_category(table_name, col.name)
            ops = self._schema.operators_for(table_name, col.name)
            if col.name.lower() in RESERVED_FLAGS:
                reserved.append(col.name)
                continue
            table.add_row(col.name, cat, ", ".join(ops))
        console.print(table)
        console.print("\nAll columns support equality (bare value) and IN (repeated flag).")
        if reserved:
            console.print(f"Reserved (use sql): {', '.join(reserved)}")
        fts = self._schema.has_fts(table_name)
        console.print(f"FTS index: {'yes' if fts else 'no'}")

    def mount(self, typer_app, query_name: str = "query"):
        """Mount sqlflag commands into an existing Typer app."""
        import typer as _typer
        click_group = _typer.main.get_group(typer_app)
        # Add query group (possibly renamed)
        query_group = self._click_app.commands.get("query")
        if query_group:
            click_group.add_command(query_group, name=query_name)
        # Add sql and schema at top level
        for cmd_name in ("sql", "schema"):
            cmd = self._click_app.commands.get(cmd_name)
            if cmd:
                click_group.add_command(cmd, name=cmd_name)
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/test_cli.py -v`
Expected: All PASS

- [ ] **Step 5: Update __init__.py with public exports**

```python
# src/sqlflag/__init__.py
"""sqlflag: Auto-generate CLIs from SQLite databases."""

from sqlflag.cli import SqlFlag
from sqlflag.query import QueryEngine

__all__ = ["SqlFlag", "QueryEngine"]
```

- [ ] **Step 6: Run full test suite**

Run: `pytest tests/ -v`
Expected: All PASS

- [ ] **Step 7: Commit**

```bash
git add src/sqlflag/ tests/test_cli.py
git commit -m "feat: SqlFlag CLI with query commands, sql, schema, mount API"
```

---

### Task 7: Mount API and Integration Tests

**Files:**
- Modify: `tests/test_cli.py` (add mount and SQL/schema tests)

- [ ] **Step 1: Write integration tests for sql, schema, and mount**

Add to `tests/test_cli.py`:

```python
class TestSqlCommand:
    def test_raw_sql(self, sample_db):
        app = SqlFlag(sample_db)
        runner = CliRunner()
        result = runner.invoke(app.click_app, [
            "sql", "SELECT name FROM repos WHERE stars > 100", "--format", "json",
        ])
        assert result.exit_code == 0
        rows = [json.loads(line) for line in result.output.strip().split("\n")]
        assert len(rows) == 1
        assert rows[0]["name"] == "gamma"


class TestSchemaCommand:
    def test_schema_overview(self, sample_db):
        app = SqlFlag(sample_db)
        runner = CliRunner()
        result = runner.invoke(app.click_app, ["schema"])
        assert result.exit_code == 0
        assert "repos" in result.output
        assert "events" in result.output

    def test_schema_table_detail(self, sample_db):
        app = SqlFlag(sample_db)
        runner = CliRunner()
        result = runner.invoke(app.click_app, ["schema", "repos"])
        assert result.exit_code == 0
        assert "name" in result.output
        assert "stars" in result.output
        assert "INTEGER" in result.output
        assert "not, gt, lt" in result.output

    def test_schema_shows_fts(self, sample_db_with_fts):
        app = SqlFlag(sample_db_with_fts)
        runner = CliRunner()
        result = runner.invoke(app.click_app, ["schema", "repos"])
        assert result.exit_code == 0
        assert "yes" in result.output.lower()


class TestMount:
    def test_mount_into_typer(self, sample_db):
        import typer
        main = typer.Typer()

        @main.command()
        def hello():
            print("hello")

        SqlFlag(sample_db).mount(main)
        runner = CliRunner()
        click_app = typer.main.get_group(main)

        # query should work
        result = runner.invoke(click_app, ["query", "repos", "--format", "json"])
        assert result.exit_code == 0

        # sql should work
        result = runner.invoke(click_app, [
            "sql", "SELECT count(*) as n FROM repos", "--format", "json",
        ])
        assert result.exit_code == 0

        # original command should still work
        result = runner.invoke(click_app, ["hello"])
        assert result.exit_code == 0

    def test_mount_custom_query_name(self, sample_db):
        import typer
        main = typer.Typer()
        SqlFlag(sample_db).mount(main, query_name="db")
        click_app = typer.main.get_group(main)
        runner = CliRunner()
        result = runner.invoke(click_app, ["db", "repos", "--format", "json"])
        assert result.exit_code == 0


class TestSearchCommand:
    def test_fts_search(self, sample_db_with_fts):
        app = SqlFlag(sample_db_with_fts)
        runner = CliRunner()
        result = runner.invoke(app.click_app, [
            "query", "repos", "--search", "alpha", "--format", "json",
        ])
        assert result.exit_code == 0
        rows = [json.loads(line) for line in result.output.strip().split("\n")]
        assert any(r["name"] == "alpha" for r in rows)
```

- [ ] **Step 2: Run tests**

Run: `pytest tests/test_cli.py -v`
Expected: All PASS (or failures from implementation issues to fix)

- [ ] **Step 3: Fix any issues and run full suite**

Run: `pytest tests/ -v`
Expected: All PASS

- [ ] **Step 4: Commit**

```bash
git add tests/test_cli.py
git commit -m "test: integration tests for sql, schema, mount, and FTS search"
```

---

### Task 8: Polish and Package

**Files:**
- Modify: `src/sqlflag/__init__.py` (version)
- Create: `LICENSE`

- [ ] **Step 1: Run full test suite with coverage**

Run: `pytest tests/ -v --cov=sqlflag --cov-report=term-missing`
Expected: All PASS, identify any coverage gaps

- [ ] **Step 2: Write any tests for uncovered paths**

Review coverage output. Add tests for edge cases found (empty tables, tables with no columns, etc.)

- [ ] **Step 3: Create LICENSE file**

```
MIT License

Copyright (c) 2026 [run deets show for author name]

Permission is hereby granted, free of charge, to any person obtaining a copy...
```

Use `deets show --format json` to get the author name.

- [ ] **Step 4: Verify package installs and imports cleanly**

Run: `pip install -e . && python -c "from sqlflag import SqlFlag, QueryEngine; print('OK')"`
Expected: `OK`

- [ ] **Step 5: Final commit**

```bash
git add -A
git commit -m "chore: license, coverage, polish for initial release"
```
