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 +3 -0
- cli/commands/__init__.py +3 -0
- cli/commands/benchmark.py +153 -0
- cli/commands/decisions.py +169 -0
- cli/commands/explain.py +46 -0
- cli/commands/graph.py +338 -0
- cli/commands/index.py +148 -0
- cli/commands/init.py +79 -0
- cli/commands/mcp.py +30 -0
- cli/commands/memory.py +145 -0
- cli/commands/pack.py +110 -0
- cli/commands/watch.py +126 -0
- cli/commands/workspace.py +294 -0
- cli/main.py +47 -0
- context_router_cli-0.2.0.dist-info/METADATA +632 -0
- context_router_cli-0.2.0.dist-info/RECORD +18 -0
- context_router_cli-0.2.0.dist-info/WHEEL +4 -0
- context_router_cli-0.2.0.dist-info/entry_points.txt +2 -0
cli/__init__.py
ADDED
cli/commands/__init__.py
ADDED
|
@@ -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}")
|
cli/commands/explain.py
ADDED
|
@@ -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}")
|