···2828 """Unknown app name should produce an error."""
2929 result = runner.invoke(call_app, ["nonexistent"])
3030 assert result.exit_code != 0
3131+3232+3333+class TestJournal:
3434+ """Tests for 'sol call journal' commands."""
3535+3636+ def test_journal_app_discovered(self):
3737+ """Journal sub-app is registered and shows help."""
3838+ result = runner.invoke(call_app, ["journal", "--help"])
3939+ assert result.exit_code == 0
4040+ assert "search" in result.output
4141+ assert "events" in result.output
4242+ assert "facet" in result.output
4343+ assert "news" in result.output
4444+4545+ def test_journal_search(self):
4646+ """Search command runs without error."""
4747+ result = runner.invoke(call_app, ["journal", "search", "test", "--limit", "5"])
4848+ assert result.exit_code == 0
4949+ assert "results" in result.output
5050+5151+ def test_journal_events(self):
5252+ """Events command returns fixture data."""
5353+ result = runner.invoke(call_app, ["journal", "events", "20240101"])
5454+ assert result.exit_code == 0
5555+ # Fixture has work + personal events for this day
5656+ assert "Team standup" in result.output
5757+5858+ def test_journal_events_with_facet(self):
5959+ """Events command filters by facet."""
6060+ result = runner.invoke(
6161+ call_app, ["journal", "events", "20240101", "--facet", "work"]
6262+ )
6363+ assert result.exit_code == 0
6464+ assert "Team standup" in result.output
6565+6666+ def test_journal_facet(self):
6767+ """Facet command shows summary for test-facet."""
6868+ result = runner.invoke(call_app, ["journal", "facet", "test-facet"])
6969+ assert result.exit_code == 0
7070+ assert "Test Facet" in result.output
7171+7272+ def test_journal_news(self):
7373+ """News command reads fixture news."""
7474+ result = runner.invoke(
7575+ call_app, ["journal", "news", "work", "--day", "20240101"]
7676+ )
7777+ assert result.exit_code == 0
7878+ assert "Authentication" in result.output
+5
think/call.py
···72727373_discover_app_calls()
74747575+# Mount built-in journal CLI (not auto-discovered since it lives under think/)
7676+from think.tools.call import app as journal_app
7777+7878+call_app.add_typer(journal_app, name="journal")
7979+75807681def main() -> None:
7782 """Entry point for ``sol call``."""
+104
think/tools/call.py
···11+# SPDX-License-Identifier: AGPL-3.0-only
22+# Copyright (c) 2026 sol pbc
33+44+"""CLI commands for journal search and browsing.
55+66+Provides human-friendly CLI access to journal operations, paralleling the
77+MCP tools in ``think/tools/search.py`` and ``think/tools/facets.py`` but
88+optimized for terminal use.
99+1010+Mounted by ``think.call`` as ``sol call journal ...``.
1111+"""
1212+1313+import typer
1414+1515+from think.facets import facet_summary, get_facet_news
1616+from think.indexer.journal import get_events as get_events_impl
1717+from think.indexer.journal import search_journal as search_journal_impl
1818+1919+app = typer.Typer(help="Journal search and browsing.")
2020+2121+2222+@app.command()
2323+def search(
2424+ query: str = typer.Argument("", help="Search query (FTS5 syntax)."),
2525+ limit: int = typer.Option(10, "--limit", "-n", help="Max results."),
2626+ offset: int = typer.Option(0, "--offset", help="Skip N results."),
2727+ day: str | None = typer.Option(None, "--day", "-d", help="Filter by day YYYYMMDD."),
2828+ day_from: str | None = typer.Option(
2929+ None, "--day-from", help="Date range start YYYYMMDD."
3030+ ),
3131+ day_to: str | None = typer.Option(
3232+ None, "--day-to", help="Date range end YYYYMMDD."
3333+ ),
3434+ facet: str | None = typer.Option(None, "--facet", "-f", help="Filter by facet."),
3535+ topic: str | None = typer.Option(None, "--topic", "-t", help="Filter by topic."),
3636+) -> None:
3737+ """Search the journal index."""
3838+ total, results = search_journal_impl(
3939+ query,
4040+ limit,
4141+ offset,
4242+ day=day,
4343+ day_from=day_from,
4444+ day_to=day_to,
4545+ facet=facet,
4646+ topic=topic,
4747+ )
4848+ typer.echo(f"{total} results")
4949+ for r in results:
5050+ meta = r["metadata"]
5151+ typer.echo(f"\n--- {meta['day']} | {meta['facet']} | {meta['topic']} ---")
5252+ typer.echo(r["text"].strip())
5353+5454+5555+@app.command()
5656+def events(
5757+ day: str = typer.Argument(help="Day in YYYYMMDD format."),
5858+ facet: str | None = typer.Option(None, "--facet", "-f", help="Filter by facet."),
5959+) -> None:
6060+ """List events for a day."""
6161+ items = get_events_impl(day, facet)
6262+ if not items:
6363+ typer.echo("No events found.")
6464+ return
6565+ for ev in items:
6666+ time_range = ""
6767+ if ev.get("start"):
6868+ time_range = ev["start"]
6969+ if ev.get("end"):
7070+ time_range += f"-{ev['end']}"
7171+ time_range = f" ({time_range})"
7272+ typer.echo(f"- {ev.get('title', 'Untitled')}{time_range}")
7373+ if ev.get("summary"):
7474+ typer.echo(f" {ev['summary']}")
7575+7676+7777+@app.command()
7878+def facet(
7979+ name: str = typer.Argument(help="Facet name."),
8080+) -> None:
8181+ """Show facet summary."""
8282+ try:
8383+ summary = facet_summary(name)
8484+ except FileNotFoundError:
8585+ typer.echo(f"Facet '{name}' not found.", err=True)
8686+ raise typer.Exit(1)
8787+ typer.echo(summary)
8888+8989+9090+@app.command()
9191+def news(
9292+ name: str = typer.Argument(help="Facet name."),
9393+ day: str | None = typer.Option(None, "--day", "-d", help="Specific day YYYYMMDD."),
9494+ limit: int = typer.Option(5, "--limit", "-n", help="Max days to show."),
9595+ cursor: str | None = typer.Option(None, "--cursor", help="Pagination cursor."),
9696+) -> None:
9797+ """Read facet news."""
9898+ result = get_facet_news(name, cursor=cursor, limit=limit, day=day)
9999+ days = result.get("days", [])
100100+ if not days:
101101+ typer.echo("No news found.")
102102+ return
103103+ for entry in days:
104104+ typer.echo(entry.get("raw_content", ""))