feat(cli): wire all commands via Typer (ingest, search, discover, provenance, ask-stub)

- Implement all CLI commands as specified in F-07: ingest, search, and discover subcommands (leads, citing, cited-by, cocited), and provenance subcommands (scan, add-link, links, bib).
- Add ask command stub that exits with code 1 (not yet implemented).
- Implement comprehensive tests with mocked dependencies (no real DB/API calls).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-06-05 07:22:03 +02:00
parent c65fe19c24
commit 2acb2ee213
3 changed files with 443 additions and 3 deletions

View File

@@ -1,8 +1,157 @@
"""codex CLI — commands wired to domain modules."""
from __future__ import annotations
import json
from typing import Optional
import typer
app = typer.Typer(help="codex — personal knowledge base for scientific papers.")
discover_app = typer.Typer(help="Discovery queries over the citation graph.")
prov_app = typer.Typer(help="Provenance: @cite scan, code_links, bib export.")
app.add_typer(discover_app, name="discover")
app.add_typer(prov_app, name="provenance")
@app.callback()
def _main() -> None:
"""codex CLI — commands land here in F-07."""
@app.command()
def ingest(
paper_id: str = typer.Argument(..., help="arXiv ID, DOI, or OpenAlex W-ID"),
source: Optional[str] = typer.Option(None, "--source", "-s", help="Path to .tex or .pdf"), # noqa: UP045
) -> None:
"""Ingest a paper into the knowledge base."""
from codex.ingest import ingest_paper
result = ingest_paper(paper_id, source_path=source)
typer.echo(
f"Ingested {result.paper_id}: {result.chunks_upserted} chunks, "
f"{result.citations_upserted} citations"
)
@app.command()
def search(
query: str = typer.Argument(..., help="Natural-language search query"),
limit: int = typer.Option(10, "--limit", "-n", help="Number of results"),
) -> None:
"""Semantic similarity search over paper abstracts."""
from codex.db import get_conn
from codex.embed import get_embedder
emb = get_embedder().encode_dense([query])[0].tolist()
with get_conn() as conn:
rows = conn.execute(
"""
SELECT id, title, year,
abstract_emb <-> %(emb)s AS distance
FROM papers
WHERE abstract_emb IS NOT NULL
ORDER BY abstract_emb <-> %(emb)s
LIMIT %(limit)s
""",
{"emb": emb, "limit": limit},
).fetchall()
for row in rows:
typer.echo(f"[{row['distance']:.3f}] {row['id']} ({row['year']}) — {row['title']}")
@discover_app.command("leads")
def discover_leads(
limit: int = typer.Option(20, "--limit", "-n"),
) -> None:
"""Show discovery leads: cited papers not yet ingested, ranked by pull."""
from codex.discover import discovery_leads
for item in discovery_leads(limit=limit):
typer.echo(f"{item['pull']:>4}× {item['cited_id']}")
@discover_app.command("citing")
def discover_citing(paper_id: str = typer.Argument(...)) -> None:
"""List papers that cite PAPER_ID."""
from codex.discover import citing_papers
for pid in citing_papers(paper_id):
typer.echo(pid)
@discover_app.command("cited-by")
def discover_cited_by(paper_id: str = typer.Argument(...)) -> None:
"""List papers that PAPER_ID cites."""
from codex.discover import cited_by
for pid in cited_by(paper_id):
typer.echo(pid)
@discover_app.command("cocited")
def discover_cocited(
paper_id: str = typer.Argument(...),
limit: int = typer.Option(10, "--limit", "-n"),
) -> None:
"""List papers frequently co-cited with PAPER_ID."""
from codex.discover import cocited_papers
for item in cocited_papers(paper_id, limit=limit):
typer.echo(f"{item['co_citations']:>4}× {item['paper_id']}")
@prov_app.command("scan")
def prov_scan(root_dir: str = typer.Argument(..., help="Root directory to scan")) -> None:
"""Scan C++ sources for @cite tags."""
from codex.provenance import scan_cite_tags
hits = scan_cite_tags(root_dir)
typer.echo(json.dumps(hits, indent=2))
@prov_app.command("add-link")
def prov_add_link(
symbol: str = typer.Argument(...),
paper_id: Optional[str] = typer.Option(None, "--paper-id"), # noqa: UP045
role: Optional[str] = typer.Option(None, "--role"), # noqa: UP045
note: Optional[str] = typer.Option(None, "--note"), # noqa: UP045
) -> None:
"""Add a code → paper link."""
from codex.provenance import add_code_link
link = add_code_link(symbol, paper_id=paper_id, role=role, note=note)
typer.echo(f"Created code_link id={link.id}: {symbol}{paper_id}")
@prov_app.command("links")
def prov_links(
paper_id: Optional[str] = typer.Option(None, "--paper-id"), # noqa: UP045
) -> None:
"""List code → paper links."""
from codex.provenance import list_code_links
for link in list_code_links(paper_id=paper_id):
typer.echo(f"[{link.id}] {link.symbol}{link.paper_id} ({link.role})")
@prov_app.command("bib")
def prov_bib(
paper_ids: Optional[list[str]] = typer.Argument(None), # noqa: UP045, B008
output: Optional[str] = typer.Option( # noqa: UP045
None, "--output", "-o", help="Write to file instead of stdout"
),
) -> None:
"""Export papers as BibTeX."""
from codex.provenance import export_bib
bib = export_bib(paper_ids if paper_ids else None)
if output:
from pathlib import Path
Path(output).write_text(bib, encoding="utf-8")
typer.echo(f"Wrote {output}")
else:
typer.echo(bib)
@app.command()
def ask(question: str = typer.Argument(..., help="Question to answer")) -> None:
"""Ask a question (RAG — not yet implemented)."""
typer.echo("ask: not yet implemented", err=True)
raise typer.Exit(1)

0
tests/cli/__init__.py Normal file
View File

291
tests/cli/test_cli.py Normal file
View File

@@ -0,0 +1,291 @@
"""Tests for codex.cli — Typer command-line interface.
All external calls (ingest, DB, embed, discovery, provenance) are mocked
so the suite runs offline without real API calls.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
import numpy as np
from typer.testing import CliRunner
from codex.cli import app
from codex.ingest import IngestResult
from codex.models import CodeLink
runner = CliRunner()
class TestIngest:
"""Tests for `codex ingest` command."""
def test_ingest_command(self) -> None:
"""Test basic ingest command with mocked ingest_paper."""
with patch("codex.ingest.ingest_paper") as mock_ingest:
mock_ingest.return_value = IngestResult(
paper_id="2301.07041",
chunks_upserted=5,
citations_upserted=3,
)
result = runner.invoke(app, ["ingest", "2301.07041"])
assert result.exit_code == 0
assert "2301.07041" in result.stdout
assert "5 chunks" in result.stdout
assert "3 citations" in result.stdout
class TestSearch:
"""Tests for `codex search` command."""
def test_search_command(self) -> None:
"""Test search command with mocked embedder and DB."""
with (
patch("codex.embed.get_embedder") as mock_embedder,
patch("codex.db.get_conn") as mock_get_conn,
):
# Mock embedder - return numpy array with one row
mock_emb_instance = MagicMock()
mock_emb_instance.encode_dense.return_value = np.array([[0.1, 0.2, 0.3]])
mock_embedder.return_value = mock_emb_instance
# Mock DB connection
mock_conn = MagicMock()
mock_conn.__enter__.return_value = mock_conn
mock_conn.__exit__.return_value = None
mock_conn.execute.return_value.fetchall.return_value = [
{
"id": "2301.07041",
"title": "Test Paper",
"year": 2023,
"distance": 0.123,
},
{
"id": "2301.07042",
"title": "Another Paper",
"year": 2023,
"distance": 0.456,
},
]
mock_get_conn.return_value = mock_conn
result = runner.invoke(app, ["search", "machine learning", "--limit", "2"])
assert result.exit_code == 0
assert "2301.07041" in result.stdout
assert "2301.07042" in result.stdout
assert "Test Paper" in result.stdout
assert "0.123" in result.stdout
class TestDiscoverLeads:
"""Tests for `codex discover leads` command."""
def test_discover_leads_command(self) -> None:
"""Test discover leads command with mocked discovery_leads."""
with patch("codex.discover.discovery_leads") as mock_leads:
mock_leads.return_value = [
{"cited_id": "2301.07041", "pull": 5},
{"cited_id": "2301.07042", "pull": 3},
]
result = runner.invoke(app, ["discover", "leads", "--limit", "2"])
assert result.exit_code == 0
assert "2301.07041" in result.stdout
assert "2301.07042" in result.stdout
assert "5×" in result.stdout
assert "3×" in result.stdout
class TestDiscoverCiting:
"""Tests for `codex discover citing` command."""
def test_discover_citing_command(self) -> None:
"""Test discover citing command with mocked citing_papers."""
with patch("codex.discover.citing_papers") as mock_citing:
mock_citing.return_value = ["2301.07041", "2301.07042"]
result = runner.invoke(app, ["discover", "citing", "2301.07043"])
assert result.exit_code == 0
assert "2301.07041" in result.stdout
assert "2301.07042" in result.stdout
class TestDiscoverCitedBy:
"""Tests for `codex discover cited-by` command."""
def test_discover_cited_by_command(self) -> None:
"""Test discover cited-by command with mocked cited_by."""
with patch("codex.discover.cited_by") as mock_cited:
mock_cited.return_value = ["2301.07041", "2301.07042"]
result = runner.invoke(app, ["discover", "cited-by", "2301.07043"])
assert result.exit_code == 0
assert "2301.07041" in result.stdout
assert "2301.07042" in result.stdout
class TestDiscoverCocited:
"""Tests for `codex discover cocited` command."""
def test_discover_cocited_command(self) -> None:
"""Test discover cocited command with mocked cocited_papers."""
with patch("codex.discover.cocited_papers") as mock_cocited:
mock_cocited.return_value = [
{"paper_id": "2301.07041", "co_citations": 5},
{"paper_id": "2301.07042", "co_citations": 3},
]
result = runner.invoke(app, ["discover", "cocited", "2301.07043"])
assert result.exit_code == 0
assert "2301.07041" in result.stdout
assert "2301.07042" in result.stdout
assert "5×" in result.stdout
assert "3×" in result.stdout
class TestProvScan:
"""Tests for `codex provenance scan` command."""
def test_prov_scan_command(self) -> None:
"""Test provenance scan command with mocked scan_cite_tags."""
with patch("codex.provenance.scan_cite_tags") as mock_scan:
mock_scan.return_value = [
{
"file": "src/main.cpp",
"line": "42",
"symbol": "compute_embedding",
"bibkey": "2301.07041",
},
{
"file": "src/utils.h",
"line": "10",
"symbol": "helper_func",
"bibkey": "2301.07042",
},
]
result = runner.invoke(app, ["provenance", "scan", "/path/to/src"])
assert result.exit_code == 0
assert "src/main.cpp" in result.stdout
assert "2301.07041" in result.stdout
assert "compute_embedding" in result.stdout
class TestProvAddLink:
"""Tests for `codex provenance add-link` command."""
def test_prov_add_link_command(self) -> None:
"""Test provenance add-link command with mocked add_code_link."""
with patch("codex.provenance.add_code_link") as mock_add:
mock_link = CodeLink(
id=1,
symbol="my_func",
paper_id="2301.07041",
role="core_algorithm",
note="Primary reference",
added_at="2023-01-01T00:00:00Z",
)
mock_add.return_value = mock_link
result = runner.invoke(
app,
[
"provenance",
"add-link",
"my_func",
"--paper-id",
"2301.07041",
"--role",
"core_algorithm",
"--note",
"Primary reference",
],
)
assert result.exit_code == 0
assert "Created code_link" in result.stdout
assert "my_func" in result.stdout
assert "2301.07041" in result.stdout
class TestProvLinks:
"""Tests for `codex provenance links` command."""
def test_prov_links_command(self) -> None:
"""Test provenance links command with mocked list_code_links."""
with patch("codex.provenance.list_code_links") as mock_links:
mock_links.return_value = [
CodeLink(
id=1,
symbol="my_func",
paper_id="2301.07041",
role="core",
note=None,
added_at="2023-01-01T00:00:00Z",
),
CodeLink(
id=2,
symbol="another_func",
paper_id="2301.07042",
role="utility",
note=None,
added_at="2023-01-02T00:00:00Z",
),
]
result = runner.invoke(app, ["provenance", "links"])
assert result.exit_code == 0
assert "[1]" in result.stdout
assert "[2]" in result.stdout
assert "my_func" in result.stdout
assert "another_func" in result.stdout
class TestProvBib:
"""Tests for `codex provenance bib` command."""
def test_prov_bib_command(self) -> None:
"""Test provenance bib command with mocked export_bib."""
with patch("codex.provenance.export_bib") as mock_export:
mock_export.return_value = "@article{2301.07041,\n title = {Test},\n}"
result = runner.invoke(app, ["provenance", "bib"])
assert result.exit_code == 0
assert "@article" in result.stdout
assert "2301.07041" in result.stdout
def test_prov_bib_with_output_file(self, tmp_path: Any) -> None:
"""Test provenance bib command writing to file."""
with patch("codex.provenance.export_bib") as mock_export:
mock_export.return_value = "@article{2301.07041,\n title = {Test},\n}"
output_file = tmp_path / "refs.bib"
result = runner.invoke(app, ["provenance", "bib", "--output", str(output_file)])
assert result.exit_code == 0
assert "Wrote" in result.stdout
assert output_file.exists()
assert "@article" in output_file.read_text()
class TestAsk:
"""Tests for `codex ask` command."""
def test_ask_command_exits_1(self) -> None:
"""Test that ask command exits with code 1 (not yet implemented)."""
result = runner.invoke(app, ["ask", "what is machine learning?"])
assert result.exit_code == 1
assert "not yet implemented" in result.stdout or "not yet implemented" in result.stderr