codebeacon 0.1.2__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.
Files changed (59) hide show
  1. codebeacon/__init__.py +1 -0
  2. codebeacon/__main__.py +3 -0
  3. codebeacon/cache.py +136 -0
  4. codebeacon/cli.py +391 -0
  5. codebeacon/common/__init__.py +0 -0
  6. codebeacon/common/filters.py +170 -0
  7. codebeacon/common/symbols.py +121 -0
  8. codebeacon/common/types.py +98 -0
  9. codebeacon/config.py +144 -0
  10. codebeacon/contextmap/__init__.py +0 -0
  11. codebeacon/contextmap/generator.py +602 -0
  12. codebeacon/discover/__init__.py +0 -0
  13. codebeacon/discover/detector.py +388 -0
  14. codebeacon/discover/scanner.py +192 -0
  15. codebeacon/export/__init__.py +0 -0
  16. codebeacon/export/mcp.py +515 -0
  17. codebeacon/export/obsidian.py +812 -0
  18. codebeacon/extract/__init__.py +22 -0
  19. codebeacon/extract/base.py +372 -0
  20. codebeacon/extract/components.py +357 -0
  21. codebeacon/extract/dependencies.py +140 -0
  22. codebeacon/extract/entities.py +575 -0
  23. codebeacon/extract/queries/README.md +116 -0
  24. codebeacon/extract/queries/actix.scm +115 -0
  25. codebeacon/extract/queries/angular.scm +155 -0
  26. codebeacon/extract/queries/aspnet.scm +159 -0
  27. codebeacon/extract/queries/django.scm +122 -0
  28. codebeacon/extract/queries/express.scm +124 -0
  29. codebeacon/extract/queries/fastapi.scm +152 -0
  30. codebeacon/extract/queries/flask.scm +120 -0
  31. codebeacon/extract/queries/gin.scm +142 -0
  32. codebeacon/extract/queries/ktor.scm +144 -0
  33. codebeacon/extract/queries/laravel.scm +172 -0
  34. codebeacon/extract/queries/nestjs.scm +183 -0
  35. codebeacon/extract/queries/rails.scm +114 -0
  36. codebeacon/extract/queries/react.scm +111 -0
  37. codebeacon/extract/queries/spring_boot.scm +204 -0
  38. codebeacon/extract/queries/svelte.scm +73 -0
  39. codebeacon/extract/queries/vapor.scm +130 -0
  40. codebeacon/extract/queries/vue.scm +123 -0
  41. codebeacon/extract/routes.py +910 -0
  42. codebeacon/extract/semantic.py +280 -0
  43. codebeacon/extract/services.py +597 -0
  44. codebeacon/graph/__init__.py +1 -0
  45. codebeacon/graph/analyze.py +281 -0
  46. codebeacon/graph/build.py +320 -0
  47. codebeacon/graph/cluster.py +160 -0
  48. codebeacon/graph/enrich.py +206 -0
  49. codebeacon/skill/SKILL.md +127 -0
  50. codebeacon/wave.py +292 -0
  51. codebeacon/wiki/__init__.py +0 -0
  52. codebeacon/wiki/generator.py +376 -0
  53. codebeacon/wiki/index.py +95 -0
  54. codebeacon/wiki/templates.py +467 -0
  55. codebeacon-0.1.2.dist-info/METADATA +319 -0
  56. codebeacon-0.1.2.dist-info/RECORD +59 -0
  57. codebeacon-0.1.2.dist-info/WHEEL +4 -0
  58. codebeacon-0.1.2.dist-info/entry_points.txt +2 -0
  59. codebeacon-0.1.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,515 @@
1
+ """stdio MCP server for codebeacon.
2
+
3
+ Exposes the knowledge graph and wiki as MCP tools for AI agents.
4
+
5
+ Tools:
6
+ beacon_wiki_index - global wiki index (short token budget)
7
+ beacon_wiki_article - read a specific wiki article by path
8
+ beacon_query - search nodes/edges by label substring
9
+ beacon_path - shortest path between two named nodes
10
+ beacon_blast_radius - downstream + upstream neighbours of a node
11
+ beacon_routes - list all routes (optional: filter by project)
12
+ beacon_services - list all services (optional: filter by project)
13
+
14
+ Usage:
15
+ codebeacon serve --dir /path/to/.codebeacon
16
+ codebeacon serve # defaults to .codebeacon in cwd
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import sys
23
+ import os
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+
28
+ # ── Graph loader ──────────────────────────────────────────────────────────────
29
+
30
+ class BeaconIndex:
31
+ """Loaded graph + wiki index, built once at startup."""
32
+
33
+ def __init__(self, beacon_dir: Path) -> None:
34
+ self.beacon_dir = beacon_dir
35
+ self.wiki_dir = beacon_dir / "wiki"
36
+ self.G = None
37
+ self._label_to_ids: dict[str, list[str]] = {}
38
+
39
+ def load(self) -> None:
40
+ import networkx as nx
41
+ import networkx.readwrite.json_graph as nxjson
42
+
43
+ beacon_json = self.beacon_dir / "beacon.json"
44
+ if not beacon_json.exists():
45
+ raise FileNotFoundError(
46
+ f"beacon.json not found at {beacon_json}. "
47
+ "Run 'codebeacon scan <path>' first."
48
+ )
49
+
50
+ data = json.loads(beacon_json.read_text(encoding="utf-8"))
51
+ self.G = nxjson.node_link_graph(data, directed=True, multigraph=False)
52
+
53
+ # Build label → [node_ids] lookup (case-insensitive key)
54
+ for node_id, node_data in self.G.nodes(data=True):
55
+ label = node_data.get("label", node_id).lower()
56
+ self._label_to_ids.setdefault(label, []).append(node_id)
57
+
58
+ def find_node_ids(self, name: str) -> list[str]:
59
+ """Return node IDs whose label contains `name` (case-insensitive)."""
60
+ name_lower = name.lower()
61
+ results: list[str] = []
62
+ for label, ids in self._label_to_ids.items():
63
+ if name_lower in label:
64
+ results.extend(ids)
65
+ return results
66
+
67
+ def node_summary(self, node_id: str) -> dict[str, Any]:
68
+ """Return a compact dict for a single node."""
69
+ data = self.G.nodes[node_id]
70
+ return {
71
+ "id": node_id,
72
+ "label": data.get("label", node_id),
73
+ "type": data.get("type", ""),
74
+ "project": data.get("project", ""),
75
+ "source_file": data.get("source_file", ""),
76
+ "framework": data.get("framework", ""),
77
+ }
78
+
79
+
80
+ # ── Tool implementations ──────────────────────────────────────────────────────
81
+
82
+ def tool_beacon_wiki_index(idx: BeaconIndex, _args: dict) -> str:
83
+ """Read the global wiki index."""
84
+ index_md = idx.wiki_dir / "index.md"
85
+ if index_md.exists():
86
+ return index_md.read_text(encoding="utf-8")
87
+ return "_No wiki index found. Run 'codebeacon scan' to generate._"
88
+
89
+
90
+ def tool_beacon_wiki_article(idx: BeaconIndex, args: dict) -> str:
91
+ """Read a wiki article by relative path (e.g. 'api-server/services/UserService.md').
92
+
93
+ Args:
94
+ path: relative path under wiki/ dir
95
+ """
96
+ rel = args.get("path", "").lstrip("/")
97
+ if not rel:
98
+ return "Error: 'path' argument required."
99
+ target = idx.wiki_dir / rel
100
+ # Security: ensure we stay inside wiki_dir
101
+ try:
102
+ target.resolve().relative_to(idx.wiki_dir.resolve())
103
+ except ValueError:
104
+ return "Error: path escapes wiki directory."
105
+ if not target.exists():
106
+ return f"Article not found: {rel}"
107
+ return target.read_text(encoding="utf-8")
108
+
109
+
110
+ def tool_beacon_query(idx: BeaconIndex, args: dict) -> str:
111
+ """Search nodes by label substring.
112
+
113
+ Args:
114
+ term: search term (case-insensitive substring match)
115
+ limit: max results (default 20)
116
+ """
117
+ if idx.G is None:
118
+ return "Graph not loaded."
119
+ term = args.get("term", "")
120
+ limit = int(args.get("limit", 20))
121
+ if not term:
122
+ return "Error: 'term' argument required."
123
+
124
+ node_ids = idx.find_node_ids(term)[:limit]
125
+ if not node_ids:
126
+ return f"No nodes matching '{term}'."
127
+
128
+ lines = [f"## Nodes matching '{term}' ({len(node_ids)} found)\n"]
129
+ for nid in node_ids:
130
+ s = idx.node_summary(nid)
131
+ lines.append(f"- **{s['label']}** ({s['type']}) — {s['project']} — `{s['source_file']}`")
132
+
133
+ # Immediate edges
134
+ out_edges = [
135
+ f" → {idx.G.nodes[t].get('label', t)} [{d.get('relation','')}]"
136
+ for _, t, d in idx.G.out_edges(nid, data=True)
137
+ ][:5]
138
+ in_edges = [
139
+ f" ← {idx.G.nodes[s].get('label', s)} [{d.get('relation','')}]"
140
+ for s, _, d in idx.G.in_edges(nid, data=True)
141
+ ][:5]
142
+ lines.extend(out_edges + in_edges)
143
+
144
+ return "\n".join(lines)
145
+
146
+
147
+ def tool_beacon_path(idx: BeaconIndex, args: dict) -> str:
148
+ """Find shortest path between two nodes by label.
149
+
150
+ Args:
151
+ source: source node label (substring match)
152
+ target: target node label (substring match)
153
+ """
154
+ import networkx as nx
155
+
156
+ if idx.G is None:
157
+ return "Graph not loaded."
158
+ source = args.get("source", "")
159
+ target = args.get("target", "")
160
+ if not source or not target:
161
+ return "Error: 'source' and 'target' arguments required."
162
+
163
+ src_ids = idx.find_node_ids(source)
164
+ tgt_ids = idx.find_node_ids(target)
165
+ if not src_ids:
166
+ return f"No node matching source '{source}'."
167
+ if not tgt_ids:
168
+ return f"No node matching target '{target}'."
169
+
170
+ # Try all combinations, return first found
171
+ for sid in src_ids[:3]:
172
+ for tid in tgt_ids[:3]:
173
+ try:
174
+ path = nx.shortest_path(idx.G, sid, tid)
175
+ labels = [idx.G.nodes[n].get("label", n) for n in path]
176
+ edges = []
177
+ for i in range(len(path) - 1):
178
+ e = idx.G.edges[path[i], path[i + 1]]
179
+ edges.append(e.get("relation", "→"))
180
+ # Interleave labels and relations
181
+ parts = [labels[0]]
182
+ for rel, lbl in zip(edges, labels[1:]):
183
+ parts.append(f" --[{rel}]--> {lbl}")
184
+ return f"## Path ({len(path)} hops)\n" + "".join(parts)
185
+ except nx.NetworkXNoPath:
186
+ continue
187
+ except nx.NodeNotFound:
188
+ continue
189
+
190
+ return f"No path found between '{source}' and '{target}'."
191
+
192
+
193
+ def tool_beacon_blast_radius(idx: BeaconIndex, args: dict) -> str:
194
+ """Show blast radius: downstream + upstream neighbours of a node.
195
+
196
+ Args:
197
+ node: node label (substring match)
198
+ depth: max traversal depth (default 2)
199
+ """
200
+ import networkx as nx
201
+
202
+ if idx.G is None:
203
+ return "Graph not loaded."
204
+ node_name = args.get("node", "")
205
+ depth = int(args.get("depth", 2))
206
+ if not node_name:
207
+ return "Error: 'node' argument required."
208
+
209
+ ids = idx.find_node_ids(node_name)
210
+ if not ids:
211
+ return f"No node matching '{node_name}'."
212
+
213
+ nid = ids[0]
214
+ label = idx.G.nodes[nid].get("label", nid)
215
+
216
+ # Downstream (descendants)
217
+ downstream = set()
218
+ frontier = {nid}
219
+ for _ in range(depth):
220
+ next_frontier = set()
221
+ for n in frontier:
222
+ for succ in idx.G.successors(n):
223
+ if succ not in downstream and succ != nid:
224
+ downstream.add(succ)
225
+ next_frontier.add(succ)
226
+ frontier = next_frontier
227
+
228
+ # Upstream (immediate callers only — one level)
229
+ upstream = set(idx.G.predecessors(nid))
230
+ upstream.discard(nid)
231
+
232
+ lines = [f"## Blast Radius: {label}\n"]
233
+ lines.append(f"**Upstream callers** ({len(upstream)}):")
234
+ for u in sorted(upstream, key=lambda n: idx.G.nodes[n].get("label", n)):
235
+ s = idx.node_summary(u)
236
+ lines.append(f"- {s['label']} ({s['type']}) — {s['project']}")
237
+
238
+ lines.append(f"\n**Downstream affected** (depth={depth}, {len(downstream)} nodes):")
239
+ for d in sorted(downstream, key=lambda n: idx.G.nodes[n].get("label", n)):
240
+ s = idx.node_summary(d)
241
+ lines.append(f"- {s['label']} ({s['type']}) — {s['project']}")
242
+
243
+ if not upstream and not downstream:
244
+ lines.append("_No connections found._")
245
+
246
+ return "\n".join(lines)
247
+
248
+
249
+ def tool_beacon_routes(idx: BeaconIndex, args: dict) -> str:
250
+ """List all routes, optionally filtered by project.
251
+
252
+ Args:
253
+ project: filter by project name (optional)
254
+ limit: max results (default 50)
255
+ """
256
+ if idx.G is None:
257
+ return "Graph not loaded."
258
+ project_filter = args.get("project", "").lower()
259
+ limit = int(args.get("limit", 50))
260
+
261
+ routes = []
262
+ for nid, data in idx.G.nodes(data=True):
263
+ if data.get("type") != "route":
264
+ continue
265
+ proj = data.get("project", "")
266
+ if project_filter and project_filter not in proj.lower():
267
+ continue
268
+ routes.append({
269
+ "method": data.get("method", ""),
270
+ "path": data.get("path", ""),
271
+ "handler": data.get("label", ""),
272
+ "project": proj,
273
+ "framework": data.get("framework", ""),
274
+ })
275
+
276
+ routes.sort(key=lambda r: (r["project"], r["method"], r["path"]))
277
+ routes = routes[:limit]
278
+
279
+ if not routes:
280
+ return "No routes found."
281
+
282
+ lines = [f"## Routes ({len(routes)})\n"]
283
+ lines.append(f"{'Method':<8} {'Path':<40} {'Handler':<30} {'Project'}")
284
+ lines.append("-" * 90)
285
+ for r in routes:
286
+ lines.append(
287
+ f"{r['method']:<8} {r['path']:<40} {r['handler']:<30} {r['project']}"
288
+ )
289
+ return "\n".join(lines)
290
+
291
+
292
+ def tool_beacon_services(idx: BeaconIndex, args: dict) -> str:
293
+ """List all services/classes, optionally filtered by project.
294
+
295
+ Args:
296
+ project: filter by project name (optional)
297
+ limit: max results (default 50)
298
+ """
299
+ if idx.G is None:
300
+ return "Graph not loaded."
301
+ project_filter = args.get("project", "").lower()
302
+ limit = int(args.get("limit", 50))
303
+
304
+ services = []
305
+ for nid, data in idx.G.nodes(data=True):
306
+ if data.get("type") not in ("class", "service"):
307
+ continue
308
+ proj = data.get("project", "")
309
+ if project_filter and project_filter not in proj.lower():
310
+ continue
311
+ services.append({
312
+ "label": data.get("label", nid),
313
+ "type": data.get("type", ""),
314
+ "project": proj,
315
+ "framework": data.get("framework", ""),
316
+ "source_file": data.get("source_file", ""),
317
+ "annotations": data.get("annotations", []),
318
+ })
319
+
320
+ services.sort(key=lambda s: (s["project"], s["label"]))
321
+ services = services[:limit]
322
+
323
+ if not services:
324
+ return "No services found."
325
+
326
+ lines = [f"## Services ({len(services)})\n"]
327
+ for s in services:
328
+ annots = ", ".join(s["annotations"][:3]) if s["annotations"] else ""
329
+ suffix = f" [{annots}]" if annots else ""
330
+ lines.append(f"- **{s['label']}** ({s['project']}){suffix} `{s['source_file']}`")
331
+ return "\n".join(lines)
332
+
333
+
334
+ # ── Tool registry ─────────────────────────────────────────────────────────────
335
+
336
+ TOOLS = {
337
+ "beacon_wiki_index": {
338
+ "fn": tool_beacon_wiki_index,
339
+ "description": "Return the global wiki index (short overview of all projects and node counts).",
340
+ "inputSchema": {
341
+ "type": "object",
342
+ "properties": {},
343
+ "required": [],
344
+ },
345
+ },
346
+ "beacon_wiki_article": {
347
+ "fn": tool_beacon_wiki_article,
348
+ "description": "Read a specific wiki article by its relative path under wiki/ (e.g. 'api-server/services/UserService.md').",
349
+ "inputSchema": {
350
+ "type": "object",
351
+ "properties": {
352
+ "path": {"type": "string", "description": "Relative path under wiki/ directory"},
353
+ },
354
+ "required": ["path"],
355
+ },
356
+ },
357
+ "beacon_query": {
358
+ "fn": tool_beacon_query,
359
+ "description": "Search graph nodes by label substring. Returns matching nodes with their edges.",
360
+ "inputSchema": {
361
+ "type": "object",
362
+ "properties": {
363
+ "term": {"type": "string", "description": "Search term (case-insensitive)"},
364
+ "limit": {"type": "integer", "description": "Max results (default 20)"},
365
+ },
366
+ "required": ["term"],
367
+ },
368
+ },
369
+ "beacon_path": {
370
+ "fn": tool_beacon_path,
371
+ "description": "Find the shortest dependency path between two nodes by label.",
372
+ "inputSchema": {
373
+ "type": "object",
374
+ "properties": {
375
+ "source": {"type": "string", "description": "Source node label"},
376
+ "target": {"type": "string", "description": "Target node label"},
377
+ },
378
+ "required": ["source", "target"],
379
+ },
380
+ },
381
+ "beacon_blast_radius": {
382
+ "fn": tool_beacon_blast_radius,
383
+ "description": "Show upstream callers and downstream affected nodes for a given node.",
384
+ "inputSchema": {
385
+ "type": "object",
386
+ "properties": {
387
+ "node": {"type": "string", "description": "Node label to analyze"},
388
+ "depth": {"type": "integer", "description": "Downstream traversal depth (default 2)"},
389
+ },
390
+ "required": ["node"],
391
+ },
392
+ },
393
+ "beacon_routes": {
394
+ "fn": tool_beacon_routes,
395
+ "description": "List all HTTP routes in the knowledge graph, optionally filtered by project.",
396
+ "inputSchema": {
397
+ "type": "object",
398
+ "properties": {
399
+ "project": {"type": "string", "description": "Filter by project name (optional)"},
400
+ "limit": {"type": "integer", "description": "Max results (default 50)"},
401
+ },
402
+ "required": [],
403
+ },
404
+ },
405
+ "beacon_services": {
406
+ "fn": tool_beacon_services,
407
+ "description": "List all service/class nodes in the knowledge graph, optionally filtered by project.",
408
+ "inputSchema": {
409
+ "type": "object",
410
+ "properties": {
411
+ "project": {"type": "string", "description": "Filter by project name (optional)"},
412
+ "limit": {"type": "integer", "description": "Max results (default 50)"},
413
+ },
414
+ "required": [],
415
+ },
416
+ },
417
+ }
418
+
419
+
420
+ # ── JSON-RPC 2.0 / MCP protocol ───────────────────────────────────────────────
421
+
422
+ def _write(obj: dict) -> None:
423
+ """Write a JSON-RPC response to stdout."""
424
+ sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
425
+ sys.stdout.flush()
426
+
427
+
428
+ def _error(req_id: Any, code: int, message: str) -> dict:
429
+ return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
430
+
431
+
432
+ def _dispatch(idx: BeaconIndex, message: dict) -> dict | None:
433
+ """Dispatch a single JSON-RPC 2.0 message; return response dict or None."""
434
+ req_id = message.get("id")
435
+ method = message.get("method", "")
436
+ params = message.get("params") or {}
437
+
438
+ # Notifications (no id) — no response required
439
+ if req_id is None:
440
+ return None
441
+
442
+ if method == "initialize":
443
+ return {
444
+ "jsonrpc": "2.0",
445
+ "id": req_id,
446
+ "result": {
447
+ "protocolVersion": "2024-11-05",
448
+ "capabilities": {"tools": {}},
449
+ "serverInfo": {"name": "codebeacon", "version": "0.1.2"},
450
+ },
451
+ }
452
+
453
+ if method == "tools/list":
454
+ tools_list = [
455
+ {
456
+ "name": name,
457
+ "description": info["description"],
458
+ "inputSchema": info["inputSchema"],
459
+ }
460
+ for name, info in TOOLS.items()
461
+ ]
462
+ return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": tools_list}}
463
+
464
+ if method == "tools/call":
465
+ tool_name = params.get("name", "")
466
+ tool_args = params.get("arguments") or {}
467
+
468
+ if tool_name not in TOOLS:
469
+ return _error(req_id, -32601, f"Unknown tool: {tool_name}")
470
+
471
+ try:
472
+ result_text = TOOLS[tool_name]["fn"](idx, tool_args)
473
+ except Exception as exc:
474
+ print(f"[codebeacon-mcp] error in {tool_name}: {exc}", file=sys.stderr)
475
+ return _error(req_id, -32603, str(exc))
476
+
477
+ return {
478
+ "jsonrpc": "2.0",
479
+ "id": req_id,
480
+ "result": {
481
+ "content": [{"type": "text", "text": result_text}],
482
+ "isError": False,
483
+ },
484
+ }
485
+
486
+ # Unknown method
487
+ return _error(req_id, -32601, f"Method not found: {method}")
488
+
489
+
490
+ def serve(beacon_dir: str | Path) -> None:
491
+ """Start the stdio MCP server. Blocks until stdin is closed."""
492
+ beacon_dir = Path(beacon_dir)
493
+ idx = BeaconIndex(beacon_dir)
494
+
495
+ try:
496
+ idx.load()
497
+ except FileNotFoundError as e:
498
+ print(f"[codebeacon-mcp] {e}", file=sys.stderr)
499
+ # Still start server so MCP client can connect — tools will explain the error
500
+
501
+ print(f"[codebeacon-mcp] serving from {beacon_dir}", file=sys.stderr)
502
+
503
+ for raw_line in sys.stdin:
504
+ raw_line = raw_line.strip()
505
+ if not raw_line:
506
+ continue
507
+ try:
508
+ message = json.loads(raw_line)
509
+ except json.JSONDecodeError as e:
510
+ _write({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": f"Parse error: {e}"}})
511
+ continue
512
+
513
+ response = _dispatch(idx, message)
514
+ if response is not None:
515
+ _write(response)