rag-your-code 0.4.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.
ragyourcode/cli.py ADDED
@@ -0,0 +1,575 @@
1
+ """Command line and JSON-lines agent entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import math
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from . import config as config_module
12
+ from . import descriptions as descriptions_module
13
+ from .annotate import comment_for
14
+ from .agentic import research
15
+ from .config import BY_PATH, SETTINGS, Config, ConfigError
16
+ from .descriptions import DescriptionStore, guidance, index_descriptions_fingerprint
17
+ from .embeddings import embed, embedding_metadata
18
+ from .graph import build_graph, graph_from_dict, graph_search
19
+ from .indexer import StaleMonitor, build_units, fingerprint, index_config_fingerprint, read_index, snapshot_repository, write_index
20
+ from .search import build_search_index, context, search
21
+
22
+ # Derived from the settings table so the default is written down once.
23
+ # `tests/test_agent_protocol.py` imports these to assert the bound it enforces.
24
+ MAX_OPEN_BYTES = BY_PATH["agent.max_open_bytes"].default
25
+ MAX_OPEN_CHARS = BY_PATH["agent.max_open_chars"].default
26
+
27
+
28
+ def _default_index(root: Path) -> Path:
29
+ return root / ".rag-your-code" / "index.json"
30
+
31
+
32
+ def _refresh_index(root: Path, output: Path, full: bool = False, compact: bool | None = None, cfg: Config | None = None) -> dict:
33
+ cfg = cfg if cfg is not None else config_module.load(root)
34
+ previous_payload: dict = {}
35
+ previous_units = []
36
+ if output.exists() and not full:
37
+ try:
38
+ previous_payload, previous_units = read_index(output)
39
+ except (OSError, TypeError, ValueError, json.JSONDecodeError):
40
+ previous_payload, previous_units = {}, []
41
+ if previous_payload.get("embedding") != embedding_metadata(cfg["embedding.dimensions"]):
42
+ for unit in previous_units:
43
+ unit.vector = []
44
+ if compact is None:
45
+ compact = bool(previous_payload.get("vector_store"))
46
+ # Settings that decide what an index contains make the previous one an
47
+ # index of something else, not a stale index of this one. Deciding it here
48
+ # rather than only inside build_units is what lets the reported
49
+ # `incremental` describe what the run actually did: it claimed reuse on
50
+ # exactly the runs where the configuration change had forbidden it.
51
+ previous_config = index_config_fingerprint(previous_payload) if previous_payload else None
52
+ config_changed = previous_config is not None and previous_config != cfg.build_fingerprint
53
+ if config_changed:
54
+ previous_payload, previous_units = {}, []
55
+ diagnostics: list[dict] = []
56
+ # One snapshot for both halves: parsing from one walk and publishing hashes
57
+ # from another let a save landing between them poison incremental reuse.
58
+ snapshot = snapshot_repository(root, cfg)
59
+ store = descriptions_module.load(root)
60
+ units = build_units(
61
+ root,
62
+ previous_units=previous_units,
63
+ previous_files=previous_payload.get("files"),
64
+ diagnostics=diagnostics,
65
+ snapshot=snapshot,
66
+ cfg=cfg,
67
+ previous_config=None if config_changed else previous_config,
68
+ descriptions=store,
69
+ )
70
+ graph = build_graph(units)
71
+ write_index(output, root, units, graph.to_dict(), compact=compact, diagnostics=diagnostics, snapshot=snapshot, cfg=cfg, descriptions_fingerprint=store.fingerprint)
72
+ groups = store.classify(units)
73
+ return {
74
+ "indexed_units": len(units),
75
+ "graph_edges": len(graph.edges),
76
+ "warnings": len(diagnostics),
77
+ "incremental": bool(previous_units) and not full,
78
+ "rebuilt_for_config": config_changed,
79
+ "compact": bool(compact),
80
+ "described": len(groups["described"]),
81
+ "pending_descriptions": len(groups["missing"]) + len(groups["superseded"]),
82
+ "index": str(output),
83
+ "root": str(root),
84
+ "config": str(cfg.source) if cfg.source else None,
85
+ }
86
+
87
+
88
+ def _cmd_index(args: argparse.Namespace) -> int:
89
+ root = Path(args.root).resolve()
90
+ cfg = config_module.load(root)
91
+ output = Path(args.output) if args.output else _default_index(root)
92
+ print(json.dumps(_refresh_index(root, output, args.full, args.compact, cfg), ensure_ascii=False))
93
+ return 0
94
+
95
+
96
+ def _load(args: argparse.Namespace):
97
+ root = Path(args.root).resolve()
98
+ cfg = config_module.load(root)
99
+ store = descriptions_module.load(root)
100
+ path = Path(args.index) if args.index else _default_index(root)
101
+ payload, units = read_index(path)
102
+ try:
103
+ # Both authored inputs are invisible to a file fingerprint. A changed
104
+ # configuration means the index describes a different corpus; changed
105
+ # descriptions mean it serves text nobody wrote any more. Neither moves
106
+ # a tracked file, so each has to report itself.
107
+ stale = payload.get("fingerprint") != fingerprint(root, cfg)
108
+ stale = stale or index_config_fingerprint(payload) != cfg.build_fingerprint
109
+ payload["stale"] = stale or index_descriptions_fingerprint(payload) != store.fingerprint
110
+ except OSError:
111
+ payload["stale"] = True
112
+ return payload, units, graph_from_dict(units, payload.get("graph")), cfg, store
113
+
114
+
115
+ def _cmd_search(args: argparse.Namespace) -> int:
116
+ payload, units, graph, cfg, _ = _load(args)
117
+ limit = args.limit if args.limit is not None else cfg["search.limit"]
118
+ max_chars = args.max_chars if args.max_chars is not None else cfg["search.max_chars"]
119
+ weight = cfg["search.vector_weight"]
120
+ search_index = build_search_index(units)
121
+ results = (
122
+ graph_search(units, args.query, limit, args.hops, graph, search_index, vector_weight=weight)
123
+ if args.graph
124
+ else search(units, args.query, limit, search_index=search_index, vector_weight=weight)
125
+ )
126
+ if args.json:
127
+ print(json.dumps({"query": args.query, "mode": "graph" if args.graph else "hybrid", "stale": payload.get("stale", True), "degraded": payload.get("degraded"), "results": [result.to_dict() for result in results], "context": context(results, max_chars)}, ensure_ascii=False))
128
+ else:
129
+ if payload.get("stale"):
130
+ print("Warning: index is stale; run `rag-your-code index` to refresh.", file=sys.stderr)
131
+ print(context(results, max_chars) or "No matching code units.")
132
+ return 0
133
+
134
+
135
+ def _cmd_annotate(args: argparse.Namespace) -> int:
136
+ payload, units, _, _, _ = _load(args)
137
+ if payload.get("stale"):
138
+ print("Index is stale; run `rag-your-code index` before annotating.", file=sys.stderr)
139
+ return 2
140
+ output = Path(args.output) if args.output else Path(args.root) / ".rag-your-code" / "annotations.md"
141
+ output.parent.mkdir(parents=True, exist_ok=True)
142
+ lines = ["# RAG Your Code annotations", "", "Generated sidecar comments; source files are unchanged.", ""]
143
+ for unit in units:
144
+ lines.extend([f"## [{unit.serial:05d}] {unit.id}", "", f"- Location: `{unit.path}:{unit.start_line}-{unit.end_line}`", f"- Kind: `{unit.kind}`", f"- Comment: {comment_for(unit.description, unit.serial, unit.id)}", "", unit.description, ""])
145
+ output.write_text("\n".join(lines), encoding="utf-8")
146
+ print(json.dumps({"annotations": len(units), "output": str(output)}, ensure_ascii=False))
147
+ return 0
148
+
149
+
150
+ def _cmd_config(args: argparse.Namespace) -> int:
151
+ root = Path(args.root).resolve()
152
+ path = config_module.config_path(root)
153
+ if args.action == "path":
154
+ print(json.dumps({"path": str(path), "exists": path.is_file()}, ensure_ascii=False))
155
+ return 0
156
+ if args.action == "init":
157
+ if path.is_file() and not args.force:
158
+ print(f"error: {path.name} already exists; pass --force to overwrite", file=sys.stderr)
159
+ return 2
160
+ path.write_text(config_module.render_template(), encoding="utf-8", newline="\n")
161
+ print(json.dumps({"created": str(path), "settings": len(SETTINGS)}, ensure_ascii=False))
162
+ return 0
163
+ cfg = config_module.load(root)
164
+ if args.action == "get":
165
+ if args.name not in BY_PATH:
166
+ print(f"error: unknown setting {args.name}", file=sys.stderr)
167
+ return 2
168
+ print(json.dumps({"name": args.name, "value": cfg[args.name]}, ensure_ascii=False, default=list))
169
+ return 0
170
+ if args.action == "set":
171
+ setting = BY_PATH.get(args.name)
172
+ if setting is None:
173
+ print(f"error: unknown setting {args.name}", file=sys.stderr)
174
+ return 2
175
+ value = config_module.parse_literal(setting, args.value)
176
+ config_module.update_file(path, args.name, value)
177
+ print(json.dumps({"name": args.name, "value": value, "path": str(path), "rebuild_required": setting.affects_build}, ensure_ascii=False, default=list))
178
+ return 0
179
+ listing = [
180
+ {
181
+ "name": setting.path,
182
+ "value": cfg[setting.path],
183
+ "default": setting.default,
184
+ "customised": cfg[setting.path] != setting.default,
185
+ "affects_build": setting.affects_build,
186
+ "help": setting.help,
187
+ }
188
+ for setting in SETTINGS
189
+ ]
190
+ print(json.dumps({"source": str(cfg.source) if cfg.source else None, "build_fingerprint": cfg.build_fingerprint, "settings": listing}, ensure_ascii=False, indent=2, default=list))
191
+ return 0
192
+
193
+
194
+ def _describe_batch(units: list, store: DescriptionStore, cfg: Config, limit: int) -> dict:
195
+ """The work packet handed to an agent: what to describe, and how.
196
+
197
+ The source is included because the brief forbids describing behaviour the
198
+ source does not show, and an agent cannot honour that without seeing it.
199
+ The generated description goes along so the agent can tell what retrieval
200
+ already has and add what it lacks rather than paraphrasing it.
201
+ """
202
+ groups = store.classify(units)
203
+ pending = store.pending(units, limit)
204
+ languages = cfg["describe.languages"]
205
+ return {
206
+ "languages": list(languages),
207
+ "max_chars": cfg["describe.max_chars"],
208
+ "guidance": guidance(languages, cfg["describe.max_chars"]),
209
+ "described": len(groups["described"]),
210
+ "superseded": len(groups["superseded"]),
211
+ "missing": len(groups["missing"]),
212
+ "remaining": len(groups["missing"]) + len(groups["superseded"]),
213
+ "units": [
214
+ {
215
+ "id": unit.id,
216
+ "path": unit.path,
217
+ "language": unit.language,
218
+ "kind": unit.kind,
219
+ "qualified_name": unit.qualified_name,
220
+ "signature": unit.signature,
221
+ "start_line": unit.start_line,
222
+ "end_line": unit.end_line,
223
+ "generated_description": unit.description,
224
+ "source": unit.source,
225
+ }
226
+ for unit in pending
227
+ ],
228
+ }
229
+
230
+
231
+ def _store_descriptions(units: list, store: DescriptionStore, cfg: Config, items) -> dict:
232
+ """Validate and persist a batch, reporting every rejection with a reason.
233
+
234
+ Nothing is truncated to fit. A description silently cut at the limit would
235
+ lose exactly the trailing synonyms that make it worth writing, and the
236
+ agent would have no way to learn that it had happened.
237
+ """
238
+ if not isinstance(items, list):
239
+ raise ValueError("descriptions must be a list of {id, text} objects")
240
+ by_id = {unit.id: unit for unit in units}
241
+ max_chars = cfg["describe.max_chars"]
242
+ stored: list[str] = []
243
+ rejected: list[dict] = []
244
+ for item in items:
245
+ if not isinstance(item, dict):
246
+ rejected.append({"id": None, "reason": "not_an_object"})
247
+ continue
248
+ unit_id = str(item.get("id", ""))
249
+ text = str(item.get("text", "")).strip()
250
+ unit = by_id.get(unit_id)
251
+ if unit is None:
252
+ rejected.append({"id": unit_id, "reason": "unknown_unit"})
253
+ elif not text:
254
+ rejected.append({"id": unit_id, "reason": "empty"})
255
+ elif len(text) > max_chars:
256
+ rejected.append({"id": unit_id, "reason": "too_long", "length": len(text), "limit": max_chars})
257
+ else:
258
+ store.put(unit, text)
259
+ stored.append(unit_id)
260
+ if stored:
261
+ store.save(units)
262
+ groups = store.classify(units)
263
+ return {
264
+ "stored": len(stored),
265
+ "stored_ids": stored,
266
+ "rejected": rejected,
267
+ "remaining": len(groups["missing"]) + len(groups["superseded"]),
268
+ # The store is written but the published index still holds the previous
269
+ # text. Said as its own field rather than folded into `stale`, which
270
+ # answers a different question -- whether the index still describes the
271
+ # repository -- and is correctly False here.
272
+ "reindex_required": len(stored) > 0,
273
+ "path": str(store.path),
274
+ }
275
+
276
+
277
+ def _apply_descriptions(units: list, store: DescriptionStore, cfg: Config) -> int:
278
+ """Push newly stored text into the in-memory units, re-embedding as needed.
279
+
280
+ Without this an agent would describe a unit and keep retrieving against the
281
+ sentence it replaced until the next `refresh`, which is the slowest way to
282
+ discover that the work had an effect.
283
+ """
284
+ authored = store.applicable(units)
285
+ changed = 0
286
+ for unit in units:
287
+ text = authored.get(unit.id)
288
+ if text and unit.description != text:
289
+ unit.description = text
290
+ unit.vector = embed(comment_for(text, unit.serial, unit.id) + "\n" + unit.searchable_text, cfg["embedding.dimensions"])
291
+ changed += 1
292
+ return changed
293
+
294
+
295
+ def _cmd_describe(args: argparse.Namespace) -> int:
296
+ payload, units, _, cfg, store = _load(args)
297
+ if args.action == "status":
298
+ groups = store.classify(units)
299
+ print(json.dumps({
300
+ "units": len(units),
301
+ "described": len(groups["described"]),
302
+ "superseded": len(groups["superseded"]),
303
+ "missing": len(groups["missing"]),
304
+ "coverage": round(len(groups["described"]) / len(units), 4) if units else 0.0,
305
+ "path": str(store.path),
306
+ "exists": store.path.is_file(),
307
+ "stale_index": bool(payload.get("stale")),
308
+ }, ensure_ascii=False, indent=2))
309
+ return 0
310
+ if args.action == "export":
311
+ limit = args.limit if args.limit is not None else cfg["describe.batch"]
312
+ batch = _describe_batch(units, store, cfg, limit)
313
+ text = json.dumps(batch, ensure_ascii=False, indent=2)
314
+ if args.output:
315
+ Path(args.output).write_text(text + "\n", encoding="utf-8", newline="\n")
316
+ print(json.dumps({"exported": len(batch["units"]), "remaining": batch["remaining"], "output": args.output}, ensure_ascii=False))
317
+ else:
318
+ print(text)
319
+ return 0
320
+ incoming = json.loads(Path(args.file).read_text(encoding="utf-8"))
321
+ if isinstance(incoming, dict):
322
+ incoming = incoming.get("descriptions", [])
323
+ report = _store_descriptions(units, store, cfg, incoming)
324
+ print(json.dumps(report, ensure_ascii=False, indent=2))
325
+ return 0 if not report["rejected"] else 1
326
+
327
+
328
+ def _open_source(root: Path, relative_path: str, start_line=None, end_line=None, cfg: Config | None = None) -> dict:
329
+ """Open only files inside the indexed repository, with bounded output.
330
+
331
+ Two bounds, because a line count is not a size. The indexer skips sources
332
+ over `index.max_file_bytes`, but `open` accepts any in-tree path, and a
333
+ three-line file holding one two-megabyte line satisfied the old 200-line
334
+ bound while returning two megabytes on a single JSON line.
335
+ """
336
+ max_bytes = cfg["agent.max_open_bytes"] if cfg is not None else MAX_OPEN_BYTES
337
+ max_chars = cfg["agent.max_open_chars"] if cfg is not None else MAX_OPEN_CHARS
338
+ candidate = (root / relative_path).resolve()
339
+ try:
340
+ candidate.relative_to(root)
341
+ except ValueError:
342
+ return {"error": "path_outside_root"}
343
+ if not candidate.is_file():
344
+ return {"error": "file_not_found", "path": relative_path}
345
+ try:
346
+ if candidate.stat().st_size > max_bytes:
347
+ return {"error": "file_too_large", "path": relative_path, "limit_bytes": max_bytes}
348
+ lines = candidate.read_text(encoding="utf-8").splitlines()
349
+ except (OSError, UnicodeDecodeError):
350
+ return {"error": "file_unreadable", "path": relative_path}
351
+ first = max(1, int(start_line or 1))
352
+ last = min(len(lines), int(end_line or min(len(lines), first + 200)))
353
+ if first > last:
354
+ return {"error": "invalid_line_range"}
355
+ source = chr(10).join(lines[first - 1:last])
356
+ response = {"path": relative_path, "start_line": first, "end_line": last}
357
+ if len(source) > max_chars:
358
+ source = source[:max_chars]
359
+ response["truncated"] = True
360
+ response["truncated_at_chars"] = max_chars
361
+ response["source"] = source
362
+ return response
363
+
364
+
365
+ def _request_int(request: dict, key: str, default: int, minimum: int, maximum: int) -> int:
366
+ """Clamp a numeric request field into [minimum, maximum].
367
+
368
+ The clamp must survive non-finite floats. A host sending `1e400` produces
369
+ `inf`, and `int(inf)` raises OverflowError, which is neither TypeError nor
370
+ ValueError; it escaped the request loop and killed the daemon. Saturating at
371
+ the bound is the reading the caller intended anyway.
372
+ """
373
+ raw = request.get(key, default)
374
+ if isinstance(raw, float):
375
+ if raw != raw:
376
+ return default
377
+ if raw == math.inf:
378
+ return maximum
379
+ if raw == -math.inf:
380
+ return minimum
381
+ return min(maximum, max(minimum, int(raw)))
382
+
383
+
384
+ def _cmd_agent(args: argparse.Namespace) -> int:
385
+ """Serve one JSON request per line, suitable for a plugin subprocess."""
386
+ payload, units, graph, cfg, store = _load(args)
387
+ search_index = build_search_index(units)
388
+ root = Path(args.root).resolve()
389
+ weight = cfg["search.vector_weight"]
390
+ default_limit = cfg["search.limit"]
391
+ default_chars = cfg["search.max_chars"]
392
+ stale_monitor = StaleMonitor(root, payload, assume_checked=True, cfg=cfg, descriptions_fingerprint=store.fingerprint)
393
+ # Descriptions stored this session reach the live units immediately but not
394
+ # the published index, which is a different thing from the index being
395
+ # stale against the repository.
396
+ index_behind = False
397
+ for line in sys.stdin:
398
+ if not line.strip():
399
+ continue
400
+ try:
401
+ request = json.loads(line)
402
+ except json.JSONDecodeError as exc:
403
+ print(json.dumps({"error": "invalid_json", "message": str(exc)}, ensure_ascii=False), flush=True)
404
+ continue
405
+ if not isinstance(request, dict):
406
+ print(json.dumps({"error": "invalid_request", "message": "request must be a JSON object"}), flush=True)
407
+ continue
408
+ stale_monitor.check(force=request.get("action") == "stats")
409
+ try:
410
+ action = request.get("action", "search")
411
+ if action == "search":
412
+ query = str(request.get("query", ""))
413
+ limit = _request_int(request, "limit", default_limit, 0, 100)
414
+ hops = _request_int(request, "hops", 1, 0, 3)
415
+ use_graph = bool(request.get("graph", False))
416
+ results = (
417
+ graph_search(units, query, limit, hops, graph, search_index, vector_weight=weight)
418
+ if use_graph
419
+ else search(units, query, limit, search_index=search_index, vector_weight=weight)
420
+ )
421
+ response = {"stale": payload.get("stale", True), "results": [result.to_dict() for result in results], "context": context(results, _request_int(request, "max_chars", default_chars, 0, 100000))}
422
+ elif action == "research":
423
+ response = research(
424
+ units,
425
+ str(request.get("query", "")),
426
+ _request_int(request, "limit", default_limit, 0, 100),
427
+ _request_int(request, "hops", 1, 0, 3),
428
+ _request_int(request, "max_steps", 2, 1, 2),
429
+ float(request.get("confidence_threshold", 0.8)),
430
+ graph,
431
+ search_index,
432
+ vector_weight=weight,
433
+ )
434
+ response["stale"] = payload.get("stale", True)
435
+ elif action == "neighbors":
436
+ unit_id = str(request.get("id", ""))
437
+ neighbors = graph.neighbors(unit_id, hops=_request_int(request, "hops", 1, 0, 3), direction=str(request.get("direction", "both")))
438
+ response_neighbors = []
439
+ for unit, path in neighbors[: _request_int(request, "limit", default_limit, 0, 100)]:
440
+ data = unit.to_dict(include_vector=False)
441
+ response_neighbors.append({"path": path, "unit": data})
442
+ response = {"stale": payload.get("stale", True), "id": unit_id, "neighbors": response_neighbors}
443
+ elif action == "open":
444
+ response = _open_source(root, str(request.get("path", "")), request.get("start_line"), request.get("end_line"), cfg)
445
+ elif action == "describe_pending":
446
+ response = _describe_batch(units, store, cfg, _request_int(request, "limit", cfg["describe.batch"], 0, 200))
447
+ elif action == "describe_put":
448
+ response = _store_descriptions(units, store, cfg, request.get("descriptions", []))
449
+ # Applied to the live units immediately, so the next search in
450
+ # this session already retrieves on the new words rather than
451
+ # waiting for a refresh the agent has no reason to expect.
452
+ response["applied"] = _apply_descriptions(units, store, cfg)
453
+ if response["applied"]:
454
+ search_index = build_search_index(units)
455
+ index_behind = index_behind or response["reindex_required"]
456
+ elif action == "refresh":
457
+ output = Path(args.index) if args.index else _default_index(root)
458
+ response = _refresh_index(root, output, cfg=cfg)
459
+ payload, units, graph, cfg, store = _load(args)
460
+ search_index = build_search_index(units)
461
+ stale_monitor = StaleMonitor(root, payload, assume_checked=True, cfg=cfg, descriptions_fingerprint=store.fingerprint)
462
+ index_behind = False
463
+ elif action == "stats":
464
+ response = {"units": len(units), "files": len({unit.path for unit in units}), "edges": len(graph.edges), "warnings": len(payload.get("diagnostics", [])), "compact": bool(payload.get("vector_store")), "embedding": payload.get("embedding"), "config": str(cfg.source) if cfg.source else None, "described": len(store.applicable(units)), "index_behind": index_behind, "stale": payload.get("stale", True)}
465
+ else:
466
+ response = {"error": f"unsupported action: {action}"}
467
+ except (TypeError, ValueError) as exc:
468
+ response = {"error": "invalid_request", "message": str(exc)}
469
+ except Exception as exc:
470
+ # A daemon serving untrusted request lines must not be able to die
471
+ # because one of them was malformed. Enumerating the expected
472
+ # exception types WAS the defect: int(1e400) raises OverflowError,
473
+ # which is neither TypeError nor ValueError, so a single request
474
+ # terminated the process and every later request went unanswered.
475
+ # This reports the failure in-band rather than swallowing it -- the
476
+ # exception type is returned so a genuine defect stays diagnosable --
477
+ # and KeyboardInterrupt/SystemExit still stop the loop, being
478
+ # BaseException rather than Exception.
479
+ response = {"error": "request_failed", "type": type(exc).__name__, "message": str(exc)}
480
+ if "degraded" not in response:
481
+ response["degraded"] = payload.get("degraded")
482
+ print(json.dumps(response, ensure_ascii=False), flush=True)
483
+ return 0
484
+
485
+
486
+ def build_parser() -> argparse.ArgumentParser:
487
+ parser = argparse.ArgumentParser(prog="rag-your-code", description="Index and retrieve explainable code units locally.")
488
+ sub = parser.add_subparsers(dest="command", required=True)
489
+ index = sub.add_parser("index", help="scan a repository and build its local index")
490
+ index.add_argument("root", nargs="?", default=".")
491
+ index.add_argument("--output")
492
+ index.add_argument("--full", action="store_true", help="ignore an existing index and rebuild every file")
493
+ index.add_argument("--compact", action="store_true", default=None, help="store vectors in a float32 sidecar to reduce JSON size")
494
+ index.set_defaults(func=_cmd_index)
495
+ search_parser = sub.add_parser("search", help="retrieve code units")
496
+ search_parser.add_argument("query")
497
+ search_parser.add_argument("--root", default=".")
498
+ search_parser.add_argument("--index")
499
+ # These default to None rather than to a literal so that an unset flag means
500
+ # "whatever the repository configured", not "8". The effective value is
501
+ # reported by `rag-your-code config list`.
502
+ search_parser.add_argument("--limit", type=int, default=None, help=f"results to return (config search.limit, default {BY_PATH['search.limit'].default})")
503
+ search_parser.add_argument("--max-chars", type=int, default=None, help=f"context budget (config search.max_chars, default {BY_PATH['search.max_chars'].default})")
504
+ search_parser.add_argument("--json", action="store_true")
505
+ search_parser.add_argument("--graph", action="store_true", help="expand results through calls/imports/contains edges")
506
+ search_parser.add_argument("--hops", type=int, default=1)
507
+ search_parser.set_defaults(func=_cmd_search)
508
+ annotate = sub.add_parser("annotate", help="write numbered descriptive sidecar comments")
509
+ annotate.add_argument("--root", default=".")
510
+ annotate.add_argument("--index")
511
+ annotate.add_argument("--output")
512
+ annotate.set_defaults(func=_cmd_annotate)
513
+ agent = sub.add_parser("agent", help="serve JSON-lines requests for a coding agent")
514
+ agent.add_argument("--root", default=".")
515
+ agent.add_argument("--index")
516
+ agent.set_defaults(func=_cmd_agent)
517
+ config_parser = sub.add_parser("config", help="inspect or change rag-your-code.toml")
518
+ config_parser.add_argument("action", choices=("list", "get", "set", "init", "path"))
519
+ config_parser.add_argument("name", nargs="?", help="dotted setting name, e.g. search.vector_weight")
520
+ config_parser.add_argument("value", nargs="?", help="TOML literal, e.g. 0.25 or '[\".py\", \".vue\"]'")
521
+ config_parser.add_argument("--root", default=".")
522
+ config_parser.add_argument("--force", action="store_true", help="with init, overwrite an existing file")
523
+ config_parser.set_defaults(func=_cmd_config)
524
+ describe = sub.add_parser("describe", help="inspect or supply agent-authored unit descriptions")
525
+ describe.add_argument("action", choices=("status", "export", "import"))
526
+ describe.add_argument("file", nargs="?", help="with import, a JSON file of {id, text} objects")
527
+ describe.add_argument("--root", default=".")
528
+ describe.add_argument("--index")
529
+ describe.add_argument("--limit", type=int, default=None, help=f"with export, units per batch (config describe.batch, default {BY_PATH['describe.batch'].default})")
530
+ describe.add_argument("--output", help="with export, write the batch here instead of stdout")
531
+ describe.set_defaults(func=_cmd_describe)
532
+ return parser
533
+
534
+
535
+ def _use_utf8_streams() -> None:
536
+ """Pin the process streams to UTF-8, which is what the protocol promises.
537
+
538
+ The index and the JSON-lines agent protocol are UTF-8 by contract, but
539
+ Python decodes stdio with the OS locale codepage. On a non-UTF-8 console
540
+ (cp936, cp1252) that costs correctness twice: printing a response whose
541
+ source or docstring holds a character outside the codepage raises
542
+ UnicodeEncodeError and kills a long-lived ``agent`` subprocess, and a
543
+ request line written as UTF-8 by the host is mis-decoded into mojibake
544
+ that matches nothing and returns an empty, exit-0 result.
545
+ """
546
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
547
+ reconfigure = getattr(stream, "reconfigure", None)
548
+ if reconfigure is None: # pytest capture and other wrappers are not TextIOWrapper
549
+ continue
550
+ try:
551
+ reconfigure(encoding="utf-8", errors="replace")
552
+ except (AttributeError, OSError, ValueError):
553
+ # A stream that refuses reconfiguration (already detached, or a
554
+ # binary substitute) keeps its own encoding. Failing the whole
555
+ # command over stream setup would be worse than the mojibake.
556
+ continue
557
+
558
+
559
+ def main(argv: list[str] | None = None) -> int:
560
+ _use_utf8_streams()
561
+ args = build_parser().parse_args(argv)
562
+ try:
563
+ return args.func(args)
564
+ except ConfigError as exc:
565
+ # Surfaced separately from the generic handler because the fix is
566
+ # always in one named file: say which, so the message is actionable.
567
+ print(f"error: {config_module.CONFIG_FILENAME}: {exc}", file=sys.stderr)
568
+ return 2
569
+ except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
570
+ print(f"error: {exc}", file=sys.stderr)
571
+ return 2
572
+
573
+
574
+ if __name__ == "__main__":
575
+ raise SystemExit(main())