codexlr8 0.0.1__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.
codexlr8/mcp_server.py ADDED
@@ -0,0 +1,163 @@
1
+ """CodeXLR8 MCP server — exposes codebase search to LLM agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+
8
+ from mcp.server import Server
9
+ from mcp.server.stdio import stdio_server
10
+ from mcp.types import Tool, TextContent
11
+
12
+ from .search import SearchEngine
13
+ from .config import load_config
14
+
15
+ _DEFAULT_PATH = os.getcwd()
16
+ server = Server("codexlr8")
17
+
18
+
19
+ def _resolve_path(arg_path: str | None) -> str:
20
+ """Resolve the project path from arg, config, or cwd."""
21
+ if arg_path and arg_path != ".":
22
+ return os.path.abspath(arg_path)
23
+ # Try reading config from cwd to get root
24
+ config = load_config(_DEFAULT_PATH)
25
+ root = config.get("root", ".")
26
+ return os.path.abspath(os.path.join(_DEFAULT_PATH, root))
27
+
28
+
29
+ @server.list_tools()
30
+ async def list_tools() -> list[Tool]:
31
+ return [
32
+ Tool(
33
+ name="codebase_search",
34
+ description=(
35
+ "Search the codebase using natural language. "
36
+ "Returns ranked results with file paths, line numbers, "
37
+ "relevance scores, metadata descriptions, and code previews. "
38
+ "Use this BEFORE reading any files to find the right code. "
39
+ "Describe what you're looking for — more terms increase precision. "
40
+ ),
41
+ inputSchema={
42
+ "type": "object",
43
+ "properties": {
44
+ "query": {
45
+ "type": "string",
46
+ "description": "Natural language search query",
47
+ },
48
+ "path": {
49
+ "type": "string",
50
+ "description": "Path to the project root (default: current directory)",
51
+ "default": ".",
52
+ },
53
+ "limit": {
54
+ "type": "integer",
55
+ "description": "Maximum results to return (default 10)",
56
+ "default": 10,
57
+ },
58
+ "exclude": {
59
+ "type": "array",
60
+ "items": {"type": "string"},
61
+ "description": "Glob patterns for files to exclude. "
62
+ "Uses .codexlr8.yaml defaults if not set.",
63
+ },
64
+ },
65
+ "required": ["query"],
66
+ },
67
+ ),
68
+ Tool(
69
+ name="codebase_index",
70
+ description=(
71
+ "Build or update the codebase search index. "
72
+ "Run this at the start of a session if the index is missing or stale. "
73
+ "Use --incremental for updates after code changes."
74
+ ),
75
+ inputSchema={
76
+ "type": "object",
77
+ "properties": {
78
+ "path": {
79
+ "type": "string",
80
+ "description": "Path to the project root (default: current directory)",
81
+ "default": ".",
82
+ },
83
+ "incremental": {
84
+ "type": "boolean",
85
+ "description": "Only update changed files (default false)",
86
+ "default": False,
87
+ },
88
+ "exclude": {
89
+ "type": "array",
90
+ "items": {"type": "string"},
91
+ "description": "Glob patterns for files to exclude",
92
+ },
93
+ },
94
+ "required": [],
95
+ },
96
+ ),
97
+ ]
98
+
99
+
100
+ @server.call_tool()
101
+ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
102
+ if name == "codebase_search":
103
+ return await _handle_search(arguments)
104
+ elif name == "codebase_index":
105
+ return await _handle_index(arguments)
106
+ raise ValueError(f"Unknown tool: {name}")
107
+
108
+
109
+ async def _handle_search(args: dict) -> list[TextContent]:
110
+ project_path = _resolve_path(args.get("path"))
111
+ query = args["query"]
112
+ limit = args.get("limit", 10)
113
+ exclude = args.get("exclude")
114
+
115
+ engine = SearchEngine(project_path)
116
+ results = engine.search(query, limit=limit, exclude=exclude)
117
+
118
+ if not results:
119
+ return [TextContent(type="text", text="No results found.")]
120
+
121
+ lines = []
122
+ for i, r in enumerate(results, 1):
123
+ lines.append(
124
+ f"{i}. {r['path']}:{r['line_start']}-{r['line_end']} "
125
+ f"[score: {r['score']:.2f}]"
126
+ )
127
+ if r.get("summary"):
128
+ lines.append(f" summary: {r['summary']}")
129
+ if r.get("tags"):
130
+ lines.append(f" tags: {', '.join(r['tags'])}")
131
+ if r.get("preview"):
132
+ lines.append(" preview: |")
133
+ for pline in r["preview"].strip().splitlines()[:6]:
134
+ lines.append(f" {pline}")
135
+ lines.append("")
136
+
137
+ return [TextContent(type="text", text="\n".join(lines))]
138
+
139
+
140
+ async def _handle_index(args: dict) -> list[TextContent]:
141
+ project_path = _resolve_path(args.get("path"))
142
+ incremental = args.get("incremental", False)
143
+ exclude = args.get("exclude")
144
+
145
+ engine = SearchEngine(project_path)
146
+ count = engine.build_index(incremental=incremental, exclude=exclude)
147
+
148
+ msg = f"Index updated: {count} files." if incremental else f"Index built: {count} files."
149
+ return [TextContent(type="text", text=msg)]
150
+
151
+
152
+ def main():
153
+ import asyncio
154
+ asyncio.run(_run())
155
+
156
+
157
+ async def _run():
158
+ async with stdio_server() as (read, write):
159
+ await server.run(read, write, server.create_initialization_options())
160
+
161
+
162
+ if __name__ == "__main__":
163
+ main()
codexlr8/meta.py ADDED
@@ -0,0 +1,110 @@
1
+ """.meta.yaml sidecar reading, writing, and generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from datetime import datetime, timezone
7
+
8
+ import yaml
9
+
10
+ from .scanner import scan_project
11
+
12
+ META_EXTENSION = ".meta.yaml"
13
+
14
+
15
+ def meta_path_for(filepath: str) -> str:
16
+ """Return the .meta.yaml sidecar path for a given source file path."""
17
+ return filepath + META_EXTENSION
18
+
19
+
20
+ def source_path_for(meta_path: str) -> str:
21
+ """Return the source file path for a given .meta.yaml sidecar path."""
22
+ assert meta_path.endswith(META_EXTENSION)
23
+ return meta_path[: -len(META_EXTENSION)]
24
+
25
+
26
+ def read_meta(meta_path: str) -> dict | None:
27
+ """Read a .meta.yaml file, returning parsed dict or None."""
28
+ if not os.path.exists(meta_path):
29
+ return None
30
+ with open(meta_path, "r", encoding="utf-8") as f:
31
+ return yaml.safe_load(f) or {}
32
+
33
+
34
+ def write_meta(meta_path: str, data: dict) -> None:
35
+ """Write a .meta.yaml file."""
36
+ with open(meta_path, "w", encoding="utf-8") as f:
37
+ yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
38
+
39
+
40
+ def generate_meta_skeleton(existing_meta: dict | None = None) -> dict:
41
+ """Generate a fresh .meta.yaml skeleton, preserving curated fields.
42
+
43
+ Auto fields are empty — they can be populated by agents over time.
44
+ Curated fields (summary, tags, invariants, examples) are preserved
45
+ from existing_meta if provided.
46
+ """
47
+ result: dict = {
48
+ "public_api": [],
49
+ "dependencies": [],
50
+ "used_by": [],
51
+ "last_modified": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
52
+ }
53
+
54
+ if existing_meta:
55
+ for key in ("summary", "tags", "invariants", "examples"):
56
+ if key in existing_meta:
57
+ result[key] = existing_meta[key]
58
+
59
+ return result
60
+
61
+
62
+ def generate_missing_sidecars(project_path: str) -> list[str]:
63
+ """Scan project and create .meta.yaml files for any source files that lack them.
64
+
65
+ Returns list of created meta file paths (relative to project root).
66
+ """
67
+ files_data = scan_project(project_path)
68
+ created = []
69
+
70
+ for entry in files_data:
71
+ filepath = os.path.join(project_path, entry["path"])
72
+ meta_path = meta_path_for(filepath)
73
+
74
+ if os.path.exists(meta_path):
75
+ continue
76
+
77
+ meta_data = generate_meta_skeleton()
78
+ write_meta(meta_path, meta_data)
79
+ created.append(entry["path"] + META_EXTENSION)
80
+
81
+ return created
82
+
83
+
84
+ def validate_meta(meta_path: str) -> list[str]:
85
+ """Validate a .meta.yaml file structure.
86
+
87
+ Returns list of warning strings (empty if valid).
88
+ Checks: file exists, is valid YAML, required keys present.
89
+ """
90
+ warnings = []
91
+
92
+ if not os.path.exists(meta_path):
93
+ return ["No .meta.yaml found"]
94
+
95
+ try:
96
+ meta = read_meta(meta_path)
97
+ except Exception:
98
+ return [f"Failed to parse {meta_path}"]
99
+
100
+ if meta is None:
101
+ return ["Empty or invalid .meta.yaml"]
102
+
103
+ # Check auto fields exist
104
+ for key in ("public_api", "dependencies", "used_by"):
105
+ if key not in meta:
106
+ warnings.append(f"Missing required field: '{key}'")
107
+ elif not isinstance(meta[key], list):
108
+ warnings.append(f"Field '{key}' must be a list")
109
+
110
+ return warnings
codexlr8/scanner.py ADDED
@@ -0,0 +1,82 @@
1
+ """File scanner — walks project and collects source file content for indexing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import os
7
+
8
+ DEFAULT_EXTENSIONS = [
9
+ ".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".rb",
10
+ ".java", ".c", ".h", ".cpp", ".hpp", ".cc", ".hh",
11
+ ".cs", ".swift", ".kt", ".kts", ".scala", ".sh", ".bash",
12
+ ".sql", ".r", ".lua", ".pl", ".pm",
13
+ ]
14
+
15
+ DEFAULT_IGNORE_DIRS = [
16
+ ".git", "__pycache__", "node_modules", ".venv", "venv",
17
+ ".tox", ".mypy_cache", ".pytest_cache", ".ruff_cache",
18
+ "dist", "build", ".eggs", "*.egg-info",
19
+ ]
20
+
21
+
22
+ def _is_ignored_dir(dirname: str, ignore_dirs: list[str]) -> bool:
23
+ for pattern in ignore_dirs:
24
+ if fnmatch.fnmatch(dirname, pattern):
25
+ return True
26
+ if dirname.startswith("."):
27
+ return True
28
+ return False
29
+
30
+
31
+ def _matches_glob(path: str, patterns: list[str]) -> bool:
32
+ if not patterns:
33
+ return False
34
+ basename = os.path.basename(path)
35
+ for pattern in patterns:
36
+ if fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch(basename, pattern):
37
+ return True
38
+ return False
39
+
40
+
41
+ def scan_project(project_path: str,
42
+ extensions: list[str] | None = None,
43
+ ignore_dirs: list[str] | None = None,
44
+ include: list[str] | None = None,
45
+ exclude: list[str] | None = None) -> list[dict]:
46
+ """Walk a project directory and collect file content for indexing.
47
+
48
+ extensions: file extensions to scan (default: common source code extensions).
49
+ ignore_dirs: directory names to skip (default: .git, node_modules, etc.).
50
+ include: only scan files matching these glob patterns (if set).
51
+ exclude: skip files matching these glob patterns.
52
+
53
+ Returns a list of dicts with 'path' (relative) and 'content' (raw text).
54
+ """
55
+ results = []
56
+ _extensions = extensions if extensions is not None else DEFAULT_EXTENSIONS
57
+ _ignore = ignore_dirs if ignore_dirs is not None else DEFAULT_IGNORE_DIRS
58
+
59
+ for root, dirs, files in os.walk(project_path):
60
+ dirs[:] = [d for d in dirs if not _is_ignored_dir(d, _ignore)]
61
+ for filename in sorted(files):
62
+ ext = os.path.splitext(filename)[1]
63
+ if ext not in _extensions:
64
+ continue
65
+ filepath = os.path.join(root, filename)
66
+ relpath = os.path.relpath(filepath, project_path)
67
+
68
+ if include and not _matches_glob(relpath, include):
69
+ continue
70
+ if exclude and _matches_glob(relpath, exclude):
71
+ continue
72
+
73
+ try:
74
+ with open(filepath, "r", encoding="utf-8", errors="replace") as f:
75
+ content = f.read()
76
+ except Exception:
77
+ continue
78
+ results.append({
79
+ "path": relpath,
80
+ "content": content,
81
+ })
82
+ return results