context-router-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """context-router CLI application."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,3 @@
1
+ """CLI command modules for context-router."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,153 @@
1
+ """context-router benchmark command — runs the benchmark harness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import date
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ benchmark_app = typer.Typer(help="Run the context-router benchmark harness.")
13
+
14
+
15
+ def _root_path(root: str) -> Path:
16
+ return Path(root).resolve() if root else Path.cwd()
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # benchmark run
21
+ # ---------------------------------------------------------------------------
22
+
23
+ @benchmark_app.command("run")
24
+ def benchmark_run(
25
+ project_root: Annotated[
26
+ str,
27
+ typer.Option("--project-root", help="Project root. Auto-detected when omitted."),
28
+ ] = "",
29
+ output: Annotated[
30
+ str,
31
+ typer.Option("--output", "-o", help="Output JSON path. Auto-named when omitted."),
32
+ ] = "",
33
+ naive: Annotated[
34
+ bool,
35
+ typer.Option("--naive/--no-naive", help="Include naive baseline token count."),
36
+ ] = True,
37
+ keyword: Annotated[
38
+ bool,
39
+ typer.Option("--keyword/--no-keyword", help="Include keyword baseline token count."),
40
+ ] = True,
41
+ json_output: Annotated[bool, typer.Option("--json")] = False,
42
+ ) -> None:
43
+ """Run the 20-task benchmark suite and produce a JSON + Markdown report.
44
+
45
+ Exit codes:
46
+ 0 — success
47
+ 1 — project not initialised (no DB)
48
+ """
49
+ from benchmark import BenchmarkRunner, to_json, to_markdown
50
+ from benchmark.baselines import naive_tokens, keyword_tokens
51
+
52
+ root = _root_path(project_root)
53
+ db_path = root / ".context-router" / "context-router.db"
54
+ if not db_path.exists():
55
+ typer.echo(
56
+ "No index found. Run 'context-router init' and 'context-router index' first.",
57
+ err=True,
58
+ )
59
+ raise typer.Exit(1)
60
+
61
+ if not json_output:
62
+ typer.echo("Running 20-task benchmark suite...")
63
+ runner = BenchmarkRunner(project_root=root)
64
+ report = runner.run_suite()
65
+
66
+ naive_tok = naive_tokens(root) if naive else 0
67
+ keyword_tok = 0
68
+ if keyword:
69
+ # Use an implement-mode query as a representative sample
70
+ keyword_tok = keyword_tokens(root, "implement feature endpoint handler")
71
+
72
+ # Save JSON report
73
+ cr_dir = root / ".context-router"
74
+ cr_dir.mkdir(exist_ok=True)
75
+ today = date.today().strftime("%Y%m%d")
76
+ json_path = Path(output) if output else cr_dir / f"benchmark-{today}.json"
77
+ json_path.write_text(to_json(report))
78
+
79
+ # Save Markdown report alongside JSON
80
+ md_path = json_path.with_suffix(".md")
81
+ md_path.write_text(to_markdown(report, naive_tok=naive_tok, keyword_tok=keyword_tok))
82
+
83
+ if json_output:
84
+ typer.echo(to_json(report))
85
+ return
86
+
87
+ s = report.summary
88
+ typer.echo(f"\n{'=' * 60}")
89
+ typer.echo(f"Benchmark complete — run {report.run_id}")
90
+ typer.echo(f"{'=' * 60}")
91
+ typer.echo(f" Tasks: {s.get('total_tasks', 0)}")
92
+ typer.echo(f" Success rate: {s.get('success_rate', 0):.1f}%")
93
+ typer.echo(f" Avg reduction: {s.get('avg_reduction_pct', 0):.1f}%")
94
+ typer.echo(f" Avg tokens: {s.get('avg_est_tokens', 0):,}")
95
+ typer.echo(f" Avg latency: {s.get('avg_latency_ms', 0):.0f} ms")
96
+ if naive_tok:
97
+ router_tok = s.get("avg_est_tokens", 1) or 1
98
+ vs_naive = round((naive_tok - router_tok) / naive_tok * 100, 1)
99
+ typer.echo(f" vs Naive: -{vs_naive:.0f}% ({naive_tok:,} → {router_tok:,} tokens)")
100
+ typer.echo(f"\nJSON report: {json_path}")
101
+ typer.echo(f"Markdown: {md_path}")
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # benchmark report
106
+ # ---------------------------------------------------------------------------
107
+
108
+ @benchmark_app.command("report")
109
+ def benchmark_report(
110
+ input_file: Annotated[
111
+ str,
112
+ typer.Option("--input", "-i", help="Path to benchmark JSON report file."),
113
+ ] = "",
114
+ project_root: Annotated[
115
+ str,
116
+ typer.Option("--project-root", help="Project root to auto-find latest report."),
117
+ ] = "",
118
+ json_output: Annotated[bool, typer.Option("--json")] = False,
119
+ ) -> None:
120
+ """Print a Markdown summary of a saved benchmark JSON report.
121
+
122
+ If --input is omitted, finds the most recent benchmark JSON in
123
+ .context-router/.
124
+
125
+ Exit codes:
126
+ 0 — success
127
+ 1 — no report found
128
+ """
129
+ from benchmark import to_json, to_markdown
130
+ from benchmark.models import BenchmarkReport
131
+
132
+ # Resolve report path
133
+ if input_file:
134
+ report_path = Path(input_file)
135
+ else:
136
+ root = _root_path(project_root)
137
+ cr_dir = root / ".context-router"
138
+ candidates = sorted(cr_dir.glob("benchmark-*.json"), reverse=True)
139
+ if not candidates:
140
+ typer.echo("No benchmark report found. Run 'context-router benchmark run' first.", err=True)
141
+ raise typer.Exit(1)
142
+ report_path = candidates[0]
143
+
144
+ if not report_path.exists():
145
+ typer.echo(f"Report not found: {report_path}", err=True)
146
+ raise typer.Exit(1)
147
+
148
+ report = BenchmarkReport.model_validate_json(report_path.read_text())
149
+
150
+ if json_output:
151
+ typer.echo(to_json(report))
152
+ else:
153
+ typer.echo(to_markdown(report))
@@ -0,0 +1,169 @@
1
+ """context-router decisions command — manages architectural decision records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ decisions_app = typer.Typer(help="Manage architectural decision records (ADRs).")
11
+
12
+
13
+ def _open_store(project_root: str) -> tuple["DecisionStore", "Database"]:
14
+ """Open the database and return (DecisionStore, Database).
15
+
16
+ Caller must close the Database.
17
+ """
18
+ from core.orchestrator import _find_project_root
19
+ from memory.store import DecisionStore
20
+ from storage_sqlite.database import Database
21
+
22
+ root = Path(project_root) if project_root else _find_project_root(Path.cwd())
23
+ db_path = root / ".context-router" / "context-router.db"
24
+ if not db_path.exists():
25
+ typer.echo(
26
+ "No index found. Run 'context-router init' and 'context-router index' first.",
27
+ err=True,
28
+ )
29
+ raise typer.Exit(1)
30
+ db = Database(db_path)
31
+ db.initialize()
32
+ return DecisionStore(db), db
33
+
34
+
35
+ @decisions_app.command("add")
36
+ def add(
37
+ title: Annotated[str, typer.Argument(help="Short title for the decision.")],
38
+ context: Annotated[
39
+ str,
40
+ typer.Option("--context", "-c", help="Background context for the decision."),
41
+ ] = "",
42
+ decision: Annotated[
43
+ str,
44
+ typer.Option("--decision", "-d", help="The decision that was made."),
45
+ ] = "",
46
+ consequences: Annotated[
47
+ str,
48
+ typer.Option("--consequences", help="Consequences and trade-offs."),
49
+ ] = "",
50
+ tags: Annotated[
51
+ str,
52
+ typer.Option("--tags", help="Comma-separated tags (e.g. storage,performance)."),
53
+ ] = "",
54
+ status: Annotated[
55
+ str,
56
+ typer.Option("--status", help="proposed|accepted|deprecated|superseded"),
57
+ ] = "accepted",
58
+ project_root: Annotated[
59
+ str,
60
+ typer.Option("--project-root", help="Project root. Auto-detected when omitted."),
61
+ ] = "",
62
+ json_output: Annotated[bool, typer.Option("--json")] = False,
63
+ ) -> None:
64
+ """Add a new architectural decision record.
65
+
66
+ Exit codes:
67
+ 0 — success
68
+ 1 — database not initialised
69
+ 2 — invalid status value
70
+ """
71
+ valid_statuses = ("proposed", "accepted", "deprecated", "superseded")
72
+ if status not in valid_statuses:
73
+ typer.echo(f"Error: --status must be one of: {', '.join(valid_statuses)}", err=True)
74
+ raise typer.Exit(2)
75
+
76
+ from contracts.models import Decision
77
+
78
+ tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else []
79
+ dec = Decision(
80
+ title=title,
81
+ status=status, # type: ignore[arg-type]
82
+ context=context,
83
+ decision=decision,
84
+ consequences=consequences,
85
+ tags=tag_list,
86
+ )
87
+
88
+ store, db = _open_store(project_root)
89
+ try:
90
+ dec_id = store.add(dec)
91
+ finally:
92
+ db.close()
93
+
94
+ if json_output:
95
+ import json
96
+ typer.echo(json.dumps({"id": dec_id, "title": title}))
97
+ else:
98
+ typer.echo(f"Decision added: {dec_id[:8]} {title}")
99
+
100
+
101
+ @decisions_app.command("search")
102
+ def search(
103
+ query: Annotated[str, typer.Argument(help="Search query.")],
104
+ project_root: Annotated[
105
+ str,
106
+ typer.Option("--project-root", help="Project root. Auto-detected when omitted."),
107
+ ] = "",
108
+ json_output: Annotated[bool, typer.Option("--json")] = False,
109
+ ) -> None:
110
+ """Search stored architectural decisions by keyword.
111
+
112
+ Exit codes:
113
+ 0 — success (even if no results)
114
+ 1 — database not initialised
115
+ """
116
+ store, db = _open_store(project_root)
117
+ try:
118
+ results = store.search(query)
119
+ finally:
120
+ db.close()
121
+
122
+ if json_output:
123
+ import json
124
+ typer.echo(json.dumps([r.model_dump(mode="json") for r in results], indent=2))
125
+ return
126
+
127
+ if not results:
128
+ typer.echo("No decisions found.")
129
+ return
130
+
131
+ for dec in results:
132
+ typer.echo(f" [{dec.status}] {dec.title}")
133
+ if dec.decision:
134
+ typer.echo(f" {dec.decision[:120]}")
135
+ if dec.tags:
136
+ typer.echo(f" tags: {', '.join(dec.tags)}")
137
+
138
+
139
+ @decisions_app.command("list")
140
+ def list_decisions(
141
+ project_root: Annotated[
142
+ str,
143
+ typer.Option("--project-root", help="Project root. Auto-detected when omitted."),
144
+ ] = "",
145
+ json_output: Annotated[bool, typer.Option("--json")] = False,
146
+ ) -> None:
147
+ """List all stored architectural decisions.
148
+
149
+ Exit codes:
150
+ 0 — success
151
+ 1 — database not initialised
152
+ """
153
+ store, db = _open_store(project_root)
154
+ try:
155
+ all_decs = store.get_all()
156
+ finally:
157
+ db.close()
158
+
159
+ if json_output:
160
+ import json
161
+ typer.echo(json.dumps([d.model_dump(mode="json") for d in all_decs], indent=2))
162
+ return
163
+
164
+ if not all_decs:
165
+ typer.echo("No decisions stored yet.")
166
+ return
167
+
168
+ for dec in all_decs:
169
+ typer.echo(f" [{dec.status}] {dec.title}")
@@ -0,0 +1,46 @@
1
+ """context-router explain command — explains the last generated context pack."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ explain_app = typer.Typer(help="Explain context selection decisions.")
8
+
9
+
10
+ @explain_app.command("last-pack")
11
+ def last_pack(
12
+ json_output: bool = typer.Option(False, "--json", help="Output result as JSON."),
13
+ ) -> None:
14
+ """Print a human-readable rationale for the last generated context pack.
15
+
16
+ Each selected item is shown with a one-sentence explanation of why it was
17
+ included. Run 'context-router pack' first to generate a pack.
18
+
19
+ Exit codes:
20
+ 0 — success
21
+ 1 — no pack found (run 'context-router pack' first)
22
+ """
23
+ from core.orchestrator import Orchestrator # local import — keeps CLI startup fast
24
+
25
+ result = Orchestrator().last_pack()
26
+
27
+ if result is None:
28
+ typer.echo(
29
+ "No context pack found. Run 'context-router pack --mode <mode>' first.",
30
+ err=True,
31
+ )
32
+ raise typer.Exit(code=1)
33
+
34
+ if json_output:
35
+ typer.echo(result.model_dump_json(indent=2))
36
+ return
37
+
38
+ typer.echo(
39
+ f"Last pack mode={result.mode} items={len(result.selected_items)} "
40
+ f"tokens={result.total_est_tokens:,}"
41
+ )
42
+ typer.echo("")
43
+
44
+ for item in result.selected_items:
45
+ typer.echo(f" [{item.source_type}] {item.title}")
46
+ typer.echo(f" {item.reason}")