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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """CodeXLR8 — A codebase search engine for LLM coding agents."""
2
+
3
+ __version__ = "0.0.1"
codexlr8/cli.py ADDED
@@ -0,0 +1,515 @@
1
+ """CodeXLR8 CLI — search-first codebase navigation for agents."""
2
+
3
+ import asyncio
4
+ import click
5
+
6
+ from .config import load_config
7
+ from .scanner import scan_project
8
+ from .meta import generate_missing_sidecars
9
+ from .search import SearchEngine
10
+
11
+
12
+ EXCLUDE_HELP = (
13
+ "Exclude files matching a glob pattern. Repeatable. "
14
+ "Defaults from .codexlr8.yaml if not specified. "
15
+ 'Example: --exclude "tests/*" --exclude "migrations/*"'
16
+ )
17
+
18
+
19
+ def _parse_excludes(ctx: click.Context, param: click.Option, values: tuple[str, ...]) -> list[str]:
20
+ """Collect --exclude values from CLI and fall back to config defaults."""
21
+ if values:
22
+ return list(values)
23
+ config = load_config(ctx.params["project_path"])
24
+ return config.get("exclude", [])
25
+
26
+
27
+ @click.group()
28
+ def main():
29
+ """CodeXLR8 — a codebase search engine for LLM coding agents."""
30
+
31
+
32
+ @main.command()
33
+ @click.argument("project_path", type=click.Path(exists=True, file_okay=False))
34
+ @click.option("--output", "-o", default=None, help="Write scan data to JSON file")
35
+ def scan(project_path: str, output: str | None):
36
+ """Scan a project and show file counts and line counts."""
37
+ config = load_config(project_path)
38
+ results = scan_project(
39
+ project_path,
40
+ extensions=config.get("extensions"),
41
+ ignore_dirs=config.get("ignore_dirs"),
42
+ include=config.get("include"),
43
+ exclude=config.get("exclude"),
44
+ )
45
+ if output:
46
+ import json
47
+ with open(output, "w") as f:
48
+ json.dump(results, f, indent=2)
49
+ click.echo(f"Wrote content data for {len(results)} files to {output}")
50
+ else:
51
+ total_lines = sum(len(r.get("content", "").splitlines()) for r in results)
52
+ click.echo(f"Scanned {len(results)} files ({total_lines} lines total)")
53
+ for entry in results[:10]:
54
+ lines = len(entry["content"].splitlines())
55
+ click.echo(f" {entry['path']} ({lines} lines)")
56
+ if len(results) > 10:
57
+ click.echo(f" ... and {len(results) - 10} more files")
58
+
59
+
60
+ @main.command()
61
+ @click.argument("project_path", type=click.Path(exists=True, file_okay=False))
62
+ @click.argument("query")
63
+ @click.option("--exclude", "-x", "exclude_patterns", multiple=True,
64
+ callback=_parse_excludes, help=EXCLUDE_HELP)
65
+ @click.option("--format", "-f", "output_format",
66
+ type=click.Choice(["text", "json"]), default="text")
67
+ @click.option("--limit", "-n", default=10, help="Maximum number of results")
68
+ def search(project_path: str, query: str, exclude_patterns: list[str],
69
+ output_format: str, limit: int):
70
+ """Search the codebase for code matching QUERY.
71
+
72
+ PROJECT_PATH is the root directory of the codebase to search.
73
+
74
+ \b
75
+ Examples:
76
+ codexlr8 search . "login auth"
77
+ codexlr8 search . "login auth" --exclude "tests/*"
78
+ codexlr8 search . "login auth" -x "tests/*" -x "vendor/*"
79
+ """
80
+ engine = SearchEngine(project_path)
81
+ results = engine.search(query, limit=limit, exclude=exclude_patterns)
82
+
83
+ if output_format == "json":
84
+ import json
85
+ click.echo(json.dumps(results, indent=2))
86
+ return
87
+
88
+ if not results:
89
+ click.echo("No results found.")
90
+ return
91
+
92
+ for i, r in enumerate(results, 1):
93
+ click.echo(f"{i}. {r['path']}:{r['line_start']}-{r['line_end']} "
94
+ f"[score: {r['score']:.2f}]")
95
+ if r.get("summary"):
96
+ click.echo(f" meta: {r['summary']}")
97
+ if r.get("tags"):
98
+ click.echo(f" tags: {', '.join(r['tags'])}")
99
+ if r.get("preview"):
100
+ click.echo(" preview: |")
101
+ for line in r["preview"].strip().splitlines()[:6]:
102
+ click.echo(f" {line}")
103
+ click.echo()
104
+
105
+
106
+ @main.command()
107
+ @click.argument("project_path", type=click.Path(exists=True, file_okay=False))
108
+ @click.option("--incremental", "-i", is_flag=True, default=False,
109
+ help="Only re-index files that have changed since last build")
110
+ @click.option("--exclude", "-x", "exclude_patterns", multiple=True,
111
+ callback=_parse_excludes, help=EXCLUDE_HELP)
112
+ def index(project_path: str, incremental: bool, exclude_patterns: list[str]):
113
+ """Build the full search index for a project.
114
+
115
+ \b
116
+ Examples:
117
+ codexlr8 index .
118
+ codexlr8 index . --incremental
119
+ codexlr8 index . --exclude "tests/*" --exclude "vendor/*"
120
+ """
121
+ engine = SearchEngine(project_path)
122
+ count = engine.build_index(incremental=incremental, exclude=exclude_patterns)
123
+ if incremental:
124
+ click.echo(f"Incrementally updated {count} files.")
125
+ else:
126
+ click.echo(f"Indexed {count} files.")
127
+
128
+
129
+ @main.command()
130
+ @click.argument("project_path", type=click.Path(exists=True, file_okay=False))
131
+ def init(project_path: str):
132
+ """Bootstrap missing .meta.yaml sidecar files for a project."""
133
+ created = generate_missing_sidecars(project_path)
134
+ if created:
135
+ click.echo(f"Created {len(created)} .meta.yaml files:")
136
+ for path in created:
137
+ click.echo(f" {path}")
138
+ else:
139
+ click.echo("All files already have .meta.yaml sidecars.")
140
+
141
+
142
+ @main.command()
143
+ @click.argument("project_path", type=click.Path(exists=True, file_okay=False))
144
+ def status(project_path: str):
145
+ """Show index state and file coverage."""
146
+ engine = SearchEngine(project_path)
147
+ state = engine.status()
148
+ click.echo(f"Project: {state['project_path']}")
149
+ click.echo(f"Files indexed: {state['files_indexed']}")
150
+ click.echo(f"Files with .meta.yaml: {state['files_with_meta']}")
151
+ click.echo(f"Files without .meta.yaml: {state['files_without_meta']}")
152
+ click.echo(f"Total lines indexed: {state['total_lines']}")
153
+ click.echo(f"Index age: {state.get('index_age', 'N/A')}")
154
+
155
+
156
+ @main.command()
157
+ @click.argument("project_path", type=click.Path(exists=True, file_okay=False), default=".")
158
+ def setup(project_path: str):
159
+ """Interactively create a .codexlr8.yaml configuration file.
160
+
161
+ Also detects MCP clients and offers to inject the server config.
162
+ """
163
+ import os
164
+ import json
165
+ import yaml
166
+ import sys
167
+
168
+ click.echo()
169
+ click.secho(" ╔══════════════════════════════════════════╗", fg="cyan")
170
+ click.secho(" ║ CodeXLR8 — Setup ║", fg="cyan", bold=True)
171
+ click.secho(" ╚══════════════════════════════════════════╝", fg="cyan")
172
+ click.echo()
173
+
174
+ # ---- Phase 1: MCP client detection and injection ----
175
+ mcp_config = {
176
+ "mcpServers": {
177
+ "codexlr8": {
178
+ "command": "uvx",
179
+ "args": ["codexlr8", "mcp-server"],
180
+ }
181
+ }
182
+ }
183
+ mcp_json = json.dumps(mcp_config, indent=2)
184
+
185
+ clients = {
186
+ "Claude Code": os.path.expanduser("~/.claude/claude.json"),
187
+ "Cursor": os.path.expanduser("~/.cursor/mcp.json"),
188
+ }
189
+
190
+ detected = {name: path for name, path in clients.items() if os.path.exists(path)}
191
+
192
+ if detected:
193
+ click.secho(" ▸ MCP Clients Detected", fg="green", bold=True)
194
+ for name, path in detected.items():
195
+ click.echo(f" [✓] {name} ({path})")
196
+
197
+ click.echo()
198
+ for name, path in detected.items():
199
+ if click.confirm(click.style(f" Inject CodeXLR8 into {name}?", fg="yellow")):
200
+ _inject_mcp_config(path, mcp_json)
201
+ click.secho(f" ✓ Injected into {os.path.basename(path)}", fg="green")
202
+
203
+ click.echo()
204
+ click.secho(" CodeXLR8 MCP server is now configured.", fg="cyan")
205
+ click.secho(" Restart your MCP client to activate the tools.", dim=True)
206
+ click.echo()
207
+ else:
208
+ click.secho(" ▸ No MCP clients detected.", dim=True)
209
+ click.echo()
210
+ click.echo(" To manually configure an MCP client, add this to its config file:")
211
+ click.echo()
212
+ click.echo(mcp_json)
213
+ click.echo()
214
+ if not click.confirm(click.style(" Continue with project config?", fg="yellow")):
215
+ click.secho(" Done. Run 'codexlr8 setup' again later if needed.", fg="cyan")
216
+ return
217
+
218
+ # ---- Phase 2: Project config ----
219
+ config_path = os.path.join(project_path, ".codexlr8.yaml")
220
+
221
+ if os.path.exists(config_path):
222
+ if not click.confirm(
223
+ click.style(" .codexlr8.yaml already exists. Overwrite?", fg="yellow")
224
+ ):
225
+ click.secho(" Skipped project config.", fg="cyan")
226
+ return
227
+
228
+ click.secho(" ▸ Project Config", fg="green", bold=True)
229
+ click.secho(" Press Enter to accept defaults, or type your own values.", dim=True)
230
+ click.echo()
231
+
232
+ root = click.prompt(
233
+ click.style(" Root", fg="bright_white"), default="."
234
+ ).strip() or "."
235
+ click.echo()
236
+
237
+ custom_include = click.prompt(
238
+ click.style(" Include (comma-separated, empty = all)", fg="bright_white"), default=""
239
+ ).strip()
240
+ include = [p.strip() for p in custom_include.split(",") if p.strip()]
241
+ click.echo()
242
+
243
+ defaults = ["tests/*", "test/*", "spec/*", "__tests__/*", "test_*", "*_test.*"]
244
+ custom_exclude = click.prompt(
245
+ click.style(" Exclude (comma-separated)", fg="bright_white"),
246
+ default=", ".join(defaults),
247
+ ).strip()
248
+ exclude = [p.strip() for p in custom_exclude.split(",") if p.strip()] if custom_exclude else defaults
249
+ click.echo()
250
+
251
+ ext_defaults = [".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".rb",
252
+ ".java", ".c", ".h", ".cpp", ".hpp", ".cs", ".swift",
253
+ ".kt", ".sql", ".sh", ".lua"]
254
+ custom_ext = click.prompt(
255
+ click.style(" Extensions (comma-separated)", fg="bright_white"),
256
+ default=", ".join(ext_defaults),
257
+ ).strip()
258
+ extensions = [p.strip() for p in custom_ext.split(",") if p.strip()] if custom_ext else ext_defaults
259
+ click.echo()
260
+
261
+ ig_defaults = [".git", "__pycache__", "node_modules", ".venv", "venv",
262
+ ".tox", ".mypy_cache", ".pytest_cache", "dist", "build"]
263
+ custom_ig = click.prompt(
264
+ click.style(" Ignore dirs (comma-separated)", fg="bright_white"),
265
+ default=", ".join(ig_defaults),
266
+ ).strip()
267
+ ignore_dirs = [p.strip() for p in custom_ig.split(",") if p.strip()] if custom_ig else ig_defaults
268
+
269
+ config = {
270
+ "root": root,
271
+ "include": include,
272
+ "exclude": exclude,
273
+ "extensions": extensions,
274
+ "ignore_dirs": ignore_dirs,
275
+ }
276
+
277
+ click.echo()
278
+ click.secho(" ── Preview ──", fg="cyan")
279
+ for line in yaml.dump(config, default_flow_style=False).strip().splitlines():
280
+ click.echo(f" {click.style(line, fg='bright_white')}")
281
+ click.echo()
282
+
283
+ if click.confirm(click.style(" Write this to .codexlr8.yaml?", fg="yellow")):
284
+ with open(config_path, "w") as f:
285
+ yaml.dump(config, f, default_flow_style=False)
286
+ click.secho(f" ✓ Wrote {config_path}", fg="green")
287
+ else:
288
+ click.secho(" Skipped.", dim=True)
289
+
290
+ # ---- Phase 3: Agent skill ----
291
+ click.echo()
292
+ click.secho(" ▸ Agent Skill", fg="green", bold=True)
293
+ skill_dir = os.path.expanduser("~/.claude/skills/codexlr8")
294
+ skill_path = os.path.join(skill_dir, "SKILL.md")
295
+
296
+ if os.path.exists(skill_path):
297
+ click.echo(f" Skill already installed: {skill_path}")
298
+ elif click.confirm(click.style(" Install agent skill for Claude Code?", fg="yellow")):
299
+ os.makedirs(skill_dir, exist_ok=True)
300
+ with open(skill_path, "w") as f:
301
+ f.write(_SKILL_CONTENT)
302
+ click.secho(f" ✓ Installed to {skill_path}", fg="green")
303
+
304
+ click.echo()
305
+ click.secho(" Setup complete.", fg="cyan", bold=True)
306
+ click.secho(" Run 'codexlr8 index .' to build your first search index.", dim=True)
307
+
308
+
309
+ def _inject_mcp_config(config_path: str, mcp_json: str) -> None:
310
+ """Inject the CodeXLR8 MCP config into an existing client config file.
311
+
312
+ If the file contains valid JSON with an 'mcpServers' key, merge.
313
+ Otherwise, write fresh.
314
+ """
315
+ import json
316
+ import os
317
+
318
+ existing: dict = {}
319
+ if os.path.exists(config_path):
320
+ try:
321
+ with open(config_path, "r") as f:
322
+ existing = json.load(f)
323
+ except (json.JSONDecodeError, Exception):
324
+ pass
325
+
326
+ if "mcpServers" not in existing:
327
+ existing["mcpServers"] = {}
328
+
329
+ if "codexlr8" in existing.get("mcpServers", {}):
330
+ # Already present — skip
331
+ return
332
+
333
+ codexlr8_config = {"command": "uvx", "args": ["codexlr8", "mcp-server"]}
334
+ existing["mcpServers"]["codexlr8"] = codexlr8_config
335
+
336
+ with open(config_path, "w") as f:
337
+ json.dump(existing, f, indent=2)
338
+ f.write("\n")
339
+
340
+
341
+ @main.command()
342
+ def mcp_config():
343
+ """Print the MCP client config JSON for Claude Code / other clients."""
344
+ import json
345
+
346
+ config = {
347
+ "mcpServers": {
348
+ "codexlr8": {
349
+ "command": "uvx",
350
+ "args": ["codexlr8", "mcp-server"],
351
+ }
352
+ }
353
+ }
354
+ click.echo()
355
+ click.secho(" Add this to your MCP client config:", fg="cyan")
356
+ click.echo(" (Claude Code: ~/.claude/claude.json, Cursor: .cursor/mcp.json)")
357
+ click.echo()
358
+ click.echo(json.dumps(config, indent=2))
359
+ click.echo()
360
+ click.echo(
361
+ " Works with any MCP client: Claude Code, Cursor, Windsurf, "
362
+ "Continue.dev, custom agents."
363
+ )
364
+
365
+
366
+ @main.command(name="mcp-server")
367
+ def mcp_server_cmd():
368
+ """Start the CodeXLR8 MCP server (for use with uvx / MCP clients)."""
369
+
370
+ from .mcp_server import _run
371
+ asyncio.run(_run())
372
+
373
+
374
+ @main.command()
375
+ def install_skill():
376
+ """Install the CodeXLR8 agent skill into ~/.claude/skills/."""
377
+ import os
378
+
379
+ skill_dir = os.path.expanduser("~/.claude/skills/codexlr8")
380
+ os.makedirs(skill_dir, exist_ok=True)
381
+ dest = os.path.join(skill_dir, "SKILL.md")
382
+
383
+ with open(dest, "w") as f:
384
+ f.write(_SKILL_CONTENT)
385
+
386
+ click.secho(f" ✓ Installed skill to {dest}", fg="green")
387
+
388
+
389
+ _SKILL_CONTENT = r"""# CodeXLR8 — Agent Search Skill
390
+
391
+ You have access to a codebase search engine called CodeXLR8. It is a purpose-built search index for this codebase. Use it **before** reading any files to find the right code.
392
+
393
+ ## When to search
394
+
395
+ - **Before any file read** — if a task mentions a feature, concept, or bug (e.g. "fix the login bug", "add refund to payments", "how does checkout work"), search first.
396
+ - **When you're lost** — if you don't know which file or module handles a responsibility, search.
397
+ - **Before grep or ls** — CodeXLR8 replaces directory listing and text search. One query is cheaper and more precise than `ls` + `grep`.
398
+
399
+ ## How to search
400
+
401
+ Use `codebase_search` with the key nouns and terms from the task description:
402
+
403
+ ```
404
+ codebase_search(query="login auth")
405
+ codebase_search(query="stripe charge customer refund")
406
+ codebase_search(query="shopping cart checkout payment")
407
+ ```
408
+
409
+ Describe what you're looking for in natural language. The engine uses AND semantics — more terms increase precision, not noise.
410
+
411
+ ## Interpreting results
412
+
413
+ Results include:
414
+
415
+ | Field | Meaning |
416
+ |---|---|
417
+ | `path:line-line` | File and line range where the match lives |
418
+ | `score` | Relevance (higher = better) |
419
+ | `summary` | Human-written description of the file's purpose |
420
+ | `tags` | Curated keywords (auth, payment, cart, etc.) |
421
+ | `preview` | First ~10 lines around the best match |
422
+
423
+ **Ranking:** Files with curated `.meta.yaml` (summary + tags) rank highest. Raw content matches rank lower. `__init__.py` re-exports are penalized.
424
+
425
+ ## Maintaining the index
426
+
427
+ ### Session start — check health
428
+
429
+ At the start of every session, run:
430
+
431
+ ```
432
+ codebase_index(path=".")
433
+ ```
434
+
435
+ This builds the index if it doesn't exist, or is a no-op if it's fresh. If the index is stale (older than the latest commit), consider:
436
+
437
+ ```
438
+ codebase_index(path=".", incremental=true)
439
+ ```
440
+
441
+ ### After making changes
442
+
443
+ After you modify files, update the index so your next search reflects the changes:
444
+
445
+ ```
446
+ codebase_index(path=".", incremental=true)
447
+ ```
448
+
449
+ Run this once per session when you're done editing, not after every single file.
450
+
451
+ ## Maintaining .meta.yaml sidecars
452
+
453
+ ### Checking coverage
454
+
455
+ Run `codexlr8 status .` (via shell) to see coverage:
456
+
457
+ ```
458
+ Files indexed: 42
459
+ Files with .meta.yaml: 15
460
+ Files without .meta.yaml: 27
461
+ ```
462
+
463
+ If more than 50% of indexed files lack a `.meta.yaml`, run `codexlr8 init .` to bootstrap the missing ones.
464
+
465
+ ### Filling in metadata
466
+
467
+ After modifying a file, check its `.meta.yaml` sidecar and update:
468
+
469
+ - **`summary`** — one sentence describing the file's purpose. Be specific: "User authentication: login, logout, password reset, session creation" not just "auth stuff".
470
+ - **`tags`** — 2-5 keywords for the module's domain: `[auth, login, session, security]`.
471
+ - **`public_api`** — list of exported function/class names. Update when you add or remove exports.
472
+ - **`invariants`** — any contract the caller must uphold: "db.connect() must be called first".
473
+
474
+ Example before:
475
+ ```yaml
476
+ public_api: []
477
+ dependencies: []
478
+ used_by: []
479
+ summary: ""
480
+ tags: []
481
+ ```
482
+
483
+ Example after:
484
+ ```yaml
485
+ public_api: [login, logout, reset_password]
486
+ dependencies: [models.user, utils.hashing, utils.db]
487
+ used_by: [main, api.auth_routes]
488
+ summary: "User authentication: login, logout, password reset, session creation"
489
+ tags: [auth, login, session, security]
490
+ invariants:
491
+ - "Passwords are always bcrypt-hashed before storage"
492
+ ```
493
+
494
+ **Only curate files you actually touch.** Don't try to backfill the entire codebase.
495
+
496
+ ## Excluding files
497
+
498
+ By default, test files (`tests/`, `test_*`, `*_test.*`), spec files, and vendored code are excluded from search results. Use `exclude` to filter more:
499
+
500
+ ```
501
+ codebase_search(query="auth", exclude=["vendor/*", "migrations/*"])
502
+ ```
503
+
504
+ Exclude patterns are globs that match file paths. Use `*` for wildcards.
505
+
506
+ ## Quick reference
507
+
508
+ | Task | Tool call |
509
+ |---|---|
510
+ | Find code for a feature | `codebase_search(query="...")` |
511
+ | Build/update index | `codebase_index(incremental=true)` |
512
+ | Check metadata coverage | Shell: `codexlr8 status .` |
513
+ | Bootstrap missing sidecars | Shell: `codexlr8 init .` |
514
+ | Rebuild full index | Shell: `codexlr8 index .` |
515
+ """
codexlr8/config.py ADDED
@@ -0,0 +1,47 @@
1
+ """Project configuration via .codexlr8.yaml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import yaml
8
+
9
+ CONFIG_FILE = ".codexlr8.yaml"
10
+
11
+
12
+ def load_config(project_path: str) -> dict:
13
+ """Load project configuration from .codexlr8.yaml if present."""
14
+ config_path = os.path.join(project_path, CONFIG_FILE)
15
+ if not os.path.exists(config_path):
16
+ return _defaults()
17
+ with open(config_path, "r", encoding="utf-8") as f:
18
+ user_config = yaml.safe_load(f) or {}
19
+ defaults = _defaults()
20
+ defaults.update(user_config)
21
+ return defaults
22
+
23
+
24
+ def _defaults() -> dict:
25
+ return {
26
+ "root": ".",
27
+ "include": [],
28
+ "exclude": [
29
+ "tests/*",
30
+ "test/*",
31
+ "spec/*",
32
+ "__tests__/*",
33
+ "test_*",
34
+ "*_test.*",
35
+ ],
36
+ "extensions": [
37
+ ".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".rb",
38
+ ".java", ".c", ".h", ".cpp", ".hpp", ".cc", ".hh",
39
+ ".cs", ".swift", ".kt", ".kts", ".scala", ".sh", ".bash",
40
+ ".sql", ".r", ".lua", ".pl", ".pm",
41
+ ],
42
+ "ignore_dirs": [
43
+ ".git", "__pycache__", "node_modules", ".venv", "venv",
44
+ ".tox", ".mypy_cache", ".pytest_cache", ".ruff_cache",
45
+ "dist", "build", ".eggs", "*.egg-info",
46
+ ],
47
+ }