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/commands/graph.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""context-router graph command — generates an interactive D3.js symbol graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import webbrowser
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
graph_app = typer.Typer(help="Generate an interactive graph visualization.")
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# D3.js HTML template
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
_HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
19
|
+
<html lang="en">
|
|
20
|
+
<head>
|
|
21
|
+
<meta charset="UTF-8">
|
|
22
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
23
|
+
<title>context-router — Symbol Graph</title>
|
|
24
|
+
<style>
|
|
25
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
26
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
27
|
+
background: #0d1117; color: #e6edf3; height: 100vh; overflow: hidden; }
|
|
28
|
+
#toolbar { display: flex; align-items: center; gap: 12px; padding: 10px 16px;
|
|
29
|
+
background: #161b22; border-bottom: 1px solid #30363d; z-index: 10; }
|
|
30
|
+
#toolbar h1 { font-size: 14px; font-weight: 600; color: #58a6ff; white-space: nowrap; }
|
|
31
|
+
#search { flex: 1; max-width: 320px; padding: 5px 10px; border-radius: 6px;
|
|
32
|
+
border: 1px solid #30363d; background: #0d1117; color: #e6edf3;
|
|
33
|
+
font-size: 13px; outline: none; }
|
|
34
|
+
#search:focus { border-color: #58a6ff; }
|
|
35
|
+
#stats { font-size: 12px; color: #8b949e; white-space: nowrap; }
|
|
36
|
+
#legend { display: flex; gap: 10px; flex-wrap: wrap; }
|
|
37
|
+
.legend-item { display: flex; align-items: center; gap: 4px; font-size: 11px; color: #8b949e; }
|
|
38
|
+
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
|
|
39
|
+
#canvas { width: 100%; height: calc(100vh - 49px); }
|
|
40
|
+
#panel { position: fixed; right: 0; top: 49px; width: 320px; height: calc(100vh - 49px);
|
|
41
|
+
background: #161b22; border-left: 1px solid #30363d; padding: 16px;
|
|
42
|
+
overflow-y: auto; transform: translateX(100%); transition: transform 0.2s;
|
|
43
|
+
z-index: 5; }
|
|
44
|
+
#panel.open { transform: translateX(0); }
|
|
45
|
+
#panel h2 { font-size: 13px; font-weight: 600; color: #58a6ff; margin-bottom: 8px;
|
|
46
|
+
word-break: break-all; }
|
|
47
|
+
#panel .meta { font-size: 11px; color: #8b949e; margin-bottom: 4px; }
|
|
48
|
+
#panel .sig { font-size: 11px; font-family: monospace; background: #0d1117; padding: 8px;
|
|
49
|
+
border-radius: 4px; margin-top: 8px; white-space: pre-wrap;
|
|
50
|
+
word-break: break-all; border: 1px solid #30363d; }
|
|
51
|
+
#panel .close-btn { position: absolute; top: 12px; right: 12px; background: none;
|
|
52
|
+
border: none; color: #8b949e; cursor: pointer; font-size: 16px; }
|
|
53
|
+
.node { cursor: pointer; }
|
|
54
|
+
.node circle { stroke-width: 1.5px; }
|
|
55
|
+
.node text { font-size: 10px; fill: #8b949e; pointer-events: none; }
|
|
56
|
+
.link { stroke-opacity: 0.3; }
|
|
57
|
+
.node.highlighted circle { stroke: #f0e040 !important; stroke-width: 2.5px; }
|
|
58
|
+
.node.dimmed { opacity: 0.15; }
|
|
59
|
+
.link.dimmed { opacity: 0.05; }
|
|
60
|
+
</style>
|
|
61
|
+
</head>
|
|
62
|
+
<body>
|
|
63
|
+
<div id="toolbar">
|
|
64
|
+
<h1>⬡ context-router graph</h1>
|
|
65
|
+
<input id="search" type="text" placeholder="Search nodes…" autocomplete="off">
|
|
66
|
+
<div id="stats"></div>
|
|
67
|
+
<div id="legend"></div>
|
|
68
|
+
</div>
|
|
69
|
+
<svg id="canvas"></svg>
|
|
70
|
+
<div id="panel">
|
|
71
|
+
<button class="close-btn" onclick="closePanel()">✕</button>
|
|
72
|
+
<h2 id="p-title"></h2>
|
|
73
|
+
<div class="meta" id="p-kind"></div>
|
|
74
|
+
<div class="meta" id="p-file"></div>
|
|
75
|
+
<div class="sig" id="p-sig"></div>
|
|
76
|
+
</div>
|
|
77
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
78
|
+
<script>
|
|
79
|
+
const GRAPH = __GRAPH_DATA__;
|
|
80
|
+
|
|
81
|
+
const KIND_COLOR = {
|
|
82
|
+
"function": "#3fb950",
|
|
83
|
+
"class": "#58a6ff",
|
|
84
|
+
"method": "#79c0ff",
|
|
85
|
+
"k8s_resource": "#ffa657",
|
|
86
|
+
"helm_chart": "#ff7b72",
|
|
87
|
+
"github_actions_workflow": "#d2a8ff",
|
|
88
|
+
"github_actions_job": "#c0a6ff",
|
|
89
|
+
"import": "#8b949e",
|
|
90
|
+
"file": "#8b949e",
|
|
91
|
+
};
|
|
92
|
+
const DEFAULT_COLOR = "#8b949e";
|
|
93
|
+
|
|
94
|
+
function kindColor(k) { return KIND_COLOR[k] || DEFAULT_COLOR; }
|
|
95
|
+
|
|
96
|
+
// Build legend
|
|
97
|
+
const kinds = [...new Set(GRAPH.nodes.map(n => n.kind))].sort();
|
|
98
|
+
const legend = document.getElementById("legend");
|
|
99
|
+
kinds.forEach(k => {
|
|
100
|
+
const el = document.createElement("div");
|
|
101
|
+
el.className = "legend-item";
|
|
102
|
+
el.innerHTML = `<div class="legend-dot" style="background:${kindColor(k)}"></div>${k}`;
|
|
103
|
+
legend.appendChild(el);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
document.getElementById("stats").textContent =
|
|
107
|
+
`${GRAPH.nodes.length} nodes · ${GRAPH.links.length} edges`;
|
|
108
|
+
|
|
109
|
+
const svg = d3.select("#canvas");
|
|
110
|
+
const container = svg.append("g");
|
|
111
|
+
const W = () => svg.node().clientWidth;
|
|
112
|
+
const H = () => svg.node().clientHeight;
|
|
113
|
+
|
|
114
|
+
// Zoom
|
|
115
|
+
svg.call(d3.zoom().scaleExtent([0.05, 4])
|
|
116
|
+
.on("zoom", e => container.attr("transform", e.transform)));
|
|
117
|
+
|
|
118
|
+
// Degree map for node sizing
|
|
119
|
+
const degMap = {};
|
|
120
|
+
GRAPH.nodes.forEach(n => { degMap[n.id] = 0; });
|
|
121
|
+
GRAPH.links.forEach(l => {
|
|
122
|
+
degMap[l.source] = (degMap[l.source] || 0) + 1;
|
|
123
|
+
degMap[l.target] = (degMap[l.target] || 0) + 1;
|
|
124
|
+
});
|
|
125
|
+
const maxDeg = Math.max(...Object.values(degMap), 1);
|
|
126
|
+
const nodeRadius = d => 4 + (degMap[d.id] || 0) / maxDeg * 14;
|
|
127
|
+
|
|
128
|
+
// Simulation
|
|
129
|
+
const sim = d3.forceSimulation(GRAPH.nodes)
|
|
130
|
+
.force("link", d3.forceLink(GRAPH.links).id(d => d.id).distance(60).strength(0.4))
|
|
131
|
+
.force("charge", d3.forceManyBody().strength(-120))
|
|
132
|
+
.force("center", d3.forceCenter(W() / 2, H() / 2))
|
|
133
|
+
.force("collision", d3.forceCollide().radius(d => nodeRadius(d) + 3));
|
|
134
|
+
|
|
135
|
+
// Links
|
|
136
|
+
const link = container.append("g")
|
|
137
|
+
.selectAll("line")
|
|
138
|
+
.data(GRAPH.links)
|
|
139
|
+
.join("line")
|
|
140
|
+
.attr("class", "link")
|
|
141
|
+
.attr("stroke", "#30363d")
|
|
142
|
+
.attr("stroke-width", 1);
|
|
143
|
+
|
|
144
|
+
// Nodes
|
|
145
|
+
const node = container.append("g")
|
|
146
|
+
.selectAll(".node")
|
|
147
|
+
.data(GRAPH.nodes)
|
|
148
|
+
.join("g")
|
|
149
|
+
.attr("class", "node")
|
|
150
|
+
.call(d3.drag()
|
|
151
|
+
.on("start", (e, d) => { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; })
|
|
152
|
+
.on("drag", (e, d) => { d.fx=e.x; d.fy=e.y; })
|
|
153
|
+
.on("end", (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx=null; d.fy=null; }))
|
|
154
|
+
.on("click", (e, d) => { e.stopPropagation(); showPanel(d); highlight(d); });
|
|
155
|
+
|
|
156
|
+
node.append("circle")
|
|
157
|
+
.attr("r", nodeRadius)
|
|
158
|
+
.attr("fill", d => kindColor(d.kind))
|
|
159
|
+
.attr("stroke", d => d3.color(kindColor(d.kind)).darker(0.8));
|
|
160
|
+
|
|
161
|
+
node.append("text")
|
|
162
|
+
.attr("dx", d => nodeRadius(d) + 3)
|
|
163
|
+
.attr("dy", "0.35em")
|
|
164
|
+
.text(d => d.name.length > 20 ? d.name.slice(0, 18) + "…" : d.name);
|
|
165
|
+
|
|
166
|
+
sim.on("tick", () => {
|
|
167
|
+
link.attr("x1", d => d.source.x).attr("y1", d => d.source.y)
|
|
168
|
+
.attr("x2", d => d.target.x).attr("y2", d => d.target.y);
|
|
169
|
+
node.attr("transform", d => `translate(${d.x},${d.y})`);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
svg.on("click", () => { closePanel(); clearHighlight(); });
|
|
173
|
+
|
|
174
|
+
// Panel
|
|
175
|
+
function showPanel(d) {
|
|
176
|
+
document.getElementById("p-title").textContent = d.name;
|
|
177
|
+
document.getElementById("p-kind").textContent = `kind: ${d.kind}`;
|
|
178
|
+
document.getElementById("p-file").textContent = d.file ? d.file.split("/").slice(-2).join("/") : "";
|
|
179
|
+
document.getElementById("p-sig").textContent = [d.signature, d.docstring].filter(Boolean).join("\\n\\n");
|
|
180
|
+
document.getElementById("panel").classList.add("open");
|
|
181
|
+
}
|
|
182
|
+
function closePanel() { document.getElementById("panel").classList.remove("open"); }
|
|
183
|
+
|
|
184
|
+
// Highlight
|
|
185
|
+
const neighborSet = new Set();
|
|
186
|
+
function highlight(d) {
|
|
187
|
+
clearHighlight();
|
|
188
|
+
neighborSet.clear();
|
|
189
|
+
neighborSet.add(d.id);
|
|
190
|
+
GRAPH.links.forEach(l => {
|
|
191
|
+
const s = typeof l.source === "object" ? l.source.id : l.source;
|
|
192
|
+
const t = typeof l.target === "object" ? l.target.id : l.target;
|
|
193
|
+
if (s === d.id) neighborSet.add(t);
|
|
194
|
+
if (t === d.id) neighborSet.add(s);
|
|
195
|
+
});
|
|
196
|
+
node.classed("highlighted", n => n.id === d.id)
|
|
197
|
+
.classed("dimmed", n => !neighborSet.has(n.id));
|
|
198
|
+
link.classed("dimmed", l => {
|
|
199
|
+
const s = typeof l.source === "object" ? l.source.id : l.source;
|
|
200
|
+
const t = typeof l.target === "object" ? l.target.id : l.target;
|
|
201
|
+
return !neighborSet.has(s) || !neighborSet.has(t);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
function clearHighlight() {
|
|
205
|
+
node.classed("highlighted", false).classed("dimmed", false);
|
|
206
|
+
link.classed("dimmed", false);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Search
|
|
210
|
+
document.getElementById("search").addEventListener("input", e => {
|
|
211
|
+
const q = e.target.value.toLowerCase().trim();
|
|
212
|
+
if (!q) { clearHighlight(); return; }
|
|
213
|
+
const matches = new Set(GRAPH.nodes.filter(n =>
|
|
214
|
+
n.name.toLowerCase().includes(q) ||
|
|
215
|
+
(n.file || "").toLowerCase().includes(q) ||
|
|
216
|
+
(n.kind || "").toLowerCase().includes(q)
|
|
217
|
+
).map(n => n.id));
|
|
218
|
+
node.classed("highlighted", n => matches.has(n.id))
|
|
219
|
+
.classed("dimmed", n => !matches.has(n.id));
|
|
220
|
+
link.classed("dimmed", true);
|
|
221
|
+
});
|
|
222
|
+
</script>
|
|
223
|
+
</body>
|
|
224
|
+
</html>'''
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@graph_app.callback(invoke_without_command=True)
|
|
228
|
+
def graph(
|
|
229
|
+
project_root: Annotated[
|
|
230
|
+
str,
|
|
231
|
+
typer.Option("--project-root", help="Project root. Auto-detected when omitted."),
|
|
232
|
+
] = "",
|
|
233
|
+
output: Annotated[
|
|
234
|
+
str,
|
|
235
|
+
typer.Option("--output", "-o", help="Output HTML file path. Default: graph.html"),
|
|
236
|
+
] = "graph.html",
|
|
237
|
+
open_browser: Annotated[
|
|
238
|
+
bool,
|
|
239
|
+
typer.Option("--open/--no-open", help="Open in browser after generating."),
|
|
240
|
+
] = False,
|
|
241
|
+
json_only: Annotated[
|
|
242
|
+
bool,
|
|
243
|
+
typer.Option("--json", help="Output graph JSON instead of HTML."),
|
|
244
|
+
] = False,
|
|
245
|
+
) -> None:
|
|
246
|
+
"""Generate an interactive D3.js force-directed symbol graph as a standalone HTML file."""
|
|
247
|
+
from storage_sqlite.database import Database
|
|
248
|
+
from storage_sqlite.repositories import EdgeRepository, SymbolRepository
|
|
249
|
+
|
|
250
|
+
root = Path(project_root).resolve() if project_root else _find_project_root()
|
|
251
|
+
db_path = root / ".context-router" / "context-router.db"
|
|
252
|
+
|
|
253
|
+
if not db_path.exists():
|
|
254
|
+
typer.echo(
|
|
255
|
+
"No index found. Run 'context-router init' and 'context-router index' first.",
|
|
256
|
+
err=True,
|
|
257
|
+
)
|
|
258
|
+
raise typer.Exit(1)
|
|
259
|
+
|
|
260
|
+
with Database(db_path) as db:
|
|
261
|
+
sym_repo = SymbolRepository(db.connection)
|
|
262
|
+
edge_repo = EdgeRepository(db.connection)
|
|
263
|
+
symbols = sym_repo.get_all("default")
|
|
264
|
+
|
|
265
|
+
# Build node list
|
|
266
|
+
sym_id_map: dict[int, str] = {} # rowid → uuid-like id
|
|
267
|
+
nodes = []
|
|
268
|
+
for sym in symbols:
|
|
269
|
+
sym_id = sym_repo.get_id(
|
|
270
|
+
"default", str(sym.file), sym.name, sym.kind
|
|
271
|
+
)
|
|
272
|
+
if sym_id is None:
|
|
273
|
+
continue
|
|
274
|
+
node_id = f"sym_{sym_id}"
|
|
275
|
+
sym_id_map[sym_id] = node_id
|
|
276
|
+
nodes.append({
|
|
277
|
+
"id": node_id,
|
|
278
|
+
"name": sym.name,
|
|
279
|
+
"kind": sym.kind,
|
|
280
|
+
"file": str(sym.file),
|
|
281
|
+
"signature": sym.signature,
|
|
282
|
+
"docstring": sym.docstring,
|
|
283
|
+
"line": sym.line_start,
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
# Build edge list from raw DB
|
|
287
|
+
rows = db.connection.execute(
|
|
288
|
+
"""
|
|
289
|
+
SELECT e.from_symbol_id, e.to_symbol_id, e.edge_type, e.weight
|
|
290
|
+
FROM edges e
|
|
291
|
+
WHERE e.repo = 'default'
|
|
292
|
+
"""
|
|
293
|
+
).fetchall()
|
|
294
|
+
links = []
|
|
295
|
+
for row in rows:
|
|
296
|
+
src = sym_id_map.get(row["from_symbol_id"])
|
|
297
|
+
tgt = sym_id_map.get(row["to_symbol_id"])
|
|
298
|
+
if src and tgt and src != tgt:
|
|
299
|
+
links.append({
|
|
300
|
+
"source": src,
|
|
301
|
+
"target": tgt,
|
|
302
|
+
"type": row["edge_type"],
|
|
303
|
+
"weight": row["weight"],
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
graph_data = {"nodes": nodes, "links": links}
|
|
307
|
+
|
|
308
|
+
if json_only:
|
|
309
|
+
typer.echo(json.dumps(graph_data, indent=2))
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
# Embed into HTML
|
|
313
|
+
graph_json = json.dumps(graph_data)
|
|
314
|
+
html_content = _HTML_TEMPLATE.replace("__GRAPH_DATA__", graph_json)
|
|
315
|
+
|
|
316
|
+
out_path = Path(output) if Path(output).is_absolute() else root / output
|
|
317
|
+
out_path.write_text(html_content, encoding="utf-8")
|
|
318
|
+
|
|
319
|
+
if not json_only:
|
|
320
|
+
typer.echo(f"Graph: {out_path} ({len(nodes)} nodes, {len(links)} edges)")
|
|
321
|
+
|
|
322
|
+
if open_browser:
|
|
323
|
+
webbrowser.open(f"file://{out_path}")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _find_project_root() -> Path:
|
|
327
|
+
"""Walk up from cwd to find .context-router/."""
|
|
328
|
+
from pathlib import Path as P
|
|
329
|
+
current = P.cwd().resolve()
|
|
330
|
+
while True:
|
|
331
|
+
if (current / ".context-router").is_dir():
|
|
332
|
+
return current
|
|
333
|
+
parent = current.parent
|
|
334
|
+
if parent == current:
|
|
335
|
+
raise typer.BadParameter(
|
|
336
|
+
"No .context-router/ found. Run 'context-router init' first."
|
|
337
|
+
)
|
|
338
|
+
current = parent
|
cli/commands/index.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""context-router index command — scans and indexes a repository."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from contracts.config import load_config
|
|
13
|
+
from core.plugin_loader import PluginLoader
|
|
14
|
+
from graph_index.git_diff import GitDiffParser
|
|
15
|
+
from graph_index.indexer import Indexer
|
|
16
|
+
from storage_sqlite.database import Database
|
|
17
|
+
|
|
18
|
+
index_app = typer.Typer(help="Scan and index a repository's symbols and dependencies.")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@index_app.callback(invoke_without_command=True)
|
|
22
|
+
def index(
|
|
23
|
+
project_root: Annotated[
|
|
24
|
+
Path,
|
|
25
|
+
typer.Option(
|
|
26
|
+
"--project-root",
|
|
27
|
+
"-p",
|
|
28
|
+
help="Root of the project to index. Defaults to current directory.",
|
|
29
|
+
),
|
|
30
|
+
] = Path("."),
|
|
31
|
+
since: Annotated[
|
|
32
|
+
str | None,
|
|
33
|
+
typer.Option(
|
|
34
|
+
"--since",
|
|
35
|
+
help="Git ref for incremental index (e.g. HEAD~1, a1b2c3d).",
|
|
36
|
+
),
|
|
37
|
+
] = None,
|
|
38
|
+
repo_name: Annotated[
|
|
39
|
+
str,
|
|
40
|
+
typer.Option("--repo", help="Logical repository name stored with symbols."),
|
|
41
|
+
] = "default",
|
|
42
|
+
json_output: Annotated[
|
|
43
|
+
bool,
|
|
44
|
+
typer.Option("--json", help="Output result as JSON."),
|
|
45
|
+
] = False,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Index source files into the context-router database.
|
|
48
|
+
|
|
49
|
+
Scans PROJECT_ROOT for source files, runs language analyzers, and writes
|
|
50
|
+
symbols and dependency edges to the SQLite database.
|
|
51
|
+
|
|
52
|
+
For incremental indexing pass --since <git-ref> to re-index only files
|
|
53
|
+
that changed since that ref.
|
|
54
|
+
|
|
55
|
+
Exit codes:
|
|
56
|
+
0 — success (even with per-file errors)
|
|
57
|
+
1 — configuration / setup error
|
|
58
|
+
2 — unexpected internal error
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
project_root = project_root.resolve()
|
|
62
|
+
config_dir = project_root / ".context-router"
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
config = load_config(project_root)
|
|
66
|
+
except Exception as exc: # noqa: BLE001
|
|
67
|
+
_err(f"Failed to load config: {exc}", json_output, exit_code=1)
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
db_path = config_dir / "context-router.db"
|
|
71
|
+
if not db_path.exists():
|
|
72
|
+
_err(
|
|
73
|
+
f"Database not found at {db_path}. Run 'context-router init' first.",
|
|
74
|
+
json_output,
|
|
75
|
+
exit_code=1,
|
|
76
|
+
)
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
db = Database(db_path)
|
|
80
|
+
db.initialize()
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
plugin_loader = PluginLoader()
|
|
84
|
+
plugin_loader.discover()
|
|
85
|
+
|
|
86
|
+
indexer = Indexer(db, plugin_loader, config, repo_name)
|
|
87
|
+
|
|
88
|
+
if since is not None:
|
|
89
|
+
try:
|
|
90
|
+
diff = GitDiffParser.from_git(project_root, since)
|
|
91
|
+
changed = [
|
|
92
|
+
cf.path if cf.path.is_absolute() else project_root / cf.path
|
|
93
|
+
for cf in diff
|
|
94
|
+
if cf.status != "deleted"
|
|
95
|
+
]
|
|
96
|
+
# Also include deleted files so the indexer can clean them up
|
|
97
|
+
deleted = [
|
|
98
|
+
cf.path if cf.path.is_absolute() else project_root / cf.path
|
|
99
|
+
for cf in diff
|
|
100
|
+
if cf.status == "deleted"
|
|
101
|
+
]
|
|
102
|
+
result = indexer.run_incremental(changed + deleted)
|
|
103
|
+
except Exception as exc: # noqa: BLE001
|
|
104
|
+
_err(f"Git diff failed: {exc}", json_output, exit_code=1)
|
|
105
|
+
return
|
|
106
|
+
else:
|
|
107
|
+
result = indexer.run(project_root)
|
|
108
|
+
finally:
|
|
109
|
+
db.close()
|
|
110
|
+
|
|
111
|
+
if json_output:
|
|
112
|
+
typer.echo(
|
|
113
|
+
json.dumps(
|
|
114
|
+
{
|
|
115
|
+
"files_scanned": result.files_scanned,
|
|
116
|
+
"symbols_written": result.symbols_written,
|
|
117
|
+
"edges_written": result.edges_written,
|
|
118
|
+
"duration_seconds": round(result.duration_seconds, 3),
|
|
119
|
+
"errors": result.errors,
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
typer.echo(
|
|
125
|
+
f"Indexed {result.files_scanned} files — "
|
|
126
|
+
f"{result.symbols_written} symbols, {result.edges_written} edges "
|
|
127
|
+
f"({result.duration_seconds:.2f}s)"
|
|
128
|
+
)
|
|
129
|
+
if result.errors:
|
|
130
|
+
typer.echo(f" {len(result.errors)} file(s) had errors:", err=True)
|
|
131
|
+
for err in result.errors[:10]:
|
|
132
|
+
typer.echo(f" {err}", err=True)
|
|
133
|
+
if len(result.errors) > 10:
|
|
134
|
+
typer.echo(
|
|
135
|
+
f" ... and {len(result.errors) - 10} more", err=True
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
except Exception as exc: # noqa: BLE001
|
|
139
|
+
_err(f"Unexpected error: {exc}", json_output, exit_code=2)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _err(message: str, json_output: bool, exit_code: int) -> None:
|
|
143
|
+
"""Print an error to stderr and exit with the given code."""
|
|
144
|
+
if json_output:
|
|
145
|
+
typer.echo(json.dumps({"status": "error", "message": message}), err=True)
|
|
146
|
+
else:
|
|
147
|
+
typer.echo(f"Error: {message}", err=True)
|
|
148
|
+
raise typer.Exit(code=exit_code)
|
cli/commands/init.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""context-router init command — bootstraps a project's .context-router directory and SQLite DB."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from contracts.config import DEFAULT_CONFIG_YAML
|
|
13
|
+
from storage_sqlite.database import Database
|
|
14
|
+
|
|
15
|
+
init_app = typer.Typer(help="Initialize context-router in the current project.")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@init_app.callback(invoke_without_command=True)
|
|
19
|
+
def init(
|
|
20
|
+
project_root: Annotated[
|
|
21
|
+
Path,
|
|
22
|
+
typer.Option(
|
|
23
|
+
"--project-root",
|
|
24
|
+
"-p",
|
|
25
|
+
help="Root of the project to initialize. Defaults to current directory.",
|
|
26
|
+
exists=False,
|
|
27
|
+
),
|
|
28
|
+
] = Path("."),
|
|
29
|
+
json_output: Annotated[
|
|
30
|
+
bool,
|
|
31
|
+
typer.Option("--json", help="Output result as JSON."),
|
|
32
|
+
] = False,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Bootstrap .context-router/ and the SQLite database in PROJECT_ROOT.
|
|
35
|
+
|
|
36
|
+
Creates:
|
|
37
|
+
- .context-router/config.yaml (default config, if not already present)
|
|
38
|
+
- .context-router/context-router.db (SQLite database with full schema)
|
|
39
|
+
|
|
40
|
+
Exit codes:
|
|
41
|
+
0 — success
|
|
42
|
+
1 — user error (bad path, permission denied)
|
|
43
|
+
2 — internal error (unexpected failure)
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
project_root = project_root.resolve()
|
|
47
|
+
config_dir = project_root / ".context-router"
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
except PermissionError as exc:
|
|
52
|
+
_err(f"Cannot create {config_dir}: {exc}", json_output, exit_code=1)
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
config_yaml = config_dir / "config.yaml"
|
|
56
|
+
if not config_yaml.exists():
|
|
57
|
+
config_yaml.write_text(DEFAULT_CONFIG_YAML, encoding="utf-8")
|
|
58
|
+
|
|
59
|
+
db_path = config_dir / "context-router.db"
|
|
60
|
+
db = Database(db_path)
|
|
61
|
+
db.initialize()
|
|
62
|
+
db.close()
|
|
63
|
+
|
|
64
|
+
if json_output:
|
|
65
|
+
typer.echo(json.dumps({"status": "ok", "db_path": str(db_path)}))
|
|
66
|
+
else:
|
|
67
|
+
typer.echo(f"Initialized context-router in {config_dir}")
|
|
68
|
+
|
|
69
|
+
except Exception as exc: # noqa: BLE001
|
|
70
|
+
_err(f"Unexpected error: {exc}", json_output, exit_code=2)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _err(message: str, json_output: bool, exit_code: int) -> None:
|
|
74
|
+
"""Print an error to stderr and exit with the given code."""
|
|
75
|
+
if json_output:
|
|
76
|
+
typer.echo(json.dumps({"status": "error", "message": message}), err=True)
|
|
77
|
+
else:
|
|
78
|
+
typer.echo(f"Error: {message}", err=True)
|
|
79
|
+
raise typer.Exit(code=exit_code)
|
cli/commands/mcp.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""context-router mcp command — starts the local MCP server over stdio."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
mcp_app = typer.Typer(help="Start the context-router MCP server over stdio transport.")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@mcp_app.callback(invoke_without_command=True)
|
|
11
|
+
def mcp() -> None:
|
|
12
|
+
"""Start the context-router MCP server over stdio transport.
|
|
13
|
+
|
|
14
|
+
Reads JSON-RPC 2.0 requests from stdin and writes responses to stdout.
|
|
15
|
+
This is the entry point for MCP-compatible AI coding agents (Claude Code,
|
|
16
|
+
Copilot, Codex) to discover and call context-router tools.
|
|
17
|
+
|
|
18
|
+
Example configuration for Claude Code (.mcp.json)::
|
|
19
|
+
|
|
20
|
+
{
|
|
21
|
+
"mcpServers": {
|
|
22
|
+
"context-router": {
|
|
23
|
+
"command": "context-router",
|
|
24
|
+
"args": ["mcp"]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
"""
|
|
29
|
+
from mcp_server.main import main as _mcp_main
|
|
30
|
+
_mcp_main()
|