Skip to content

Commit 589b07d

Browse files
committed
Add machine-readable search interfaces for ccc
1 parent 51ea6ef commit 589b07d

2 files changed

Lines changed: 413 additions & 2 deletions

File tree

src/cocoindex_code/cli.py

Lines changed: 203 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
from __future__ import annotations
44

55
import functools
6+
import json as _json
67
import os
78
import sys
89
from collections.abc import Callable
910
from pathlib import Path
10-
from typing import TypeVar
11+
from typing import Protocol, TextIO, TypeVar
1112

1213
import typer as _typer
1314

@@ -96,6 +97,19 @@ def require_project_root() -> Path:
9697
_F = TypeVar("_F", bound=Callable[..., object])
9798

9899

100+
class _SearchCallable(Protocol):
101+
def __call__(
102+
self,
103+
project_root: str,
104+
query: str,
105+
languages: list[str] | None = None,
106+
paths: list[str] | None = None,
107+
limit: int = 5,
108+
offset: int = 0,
109+
on_waiting: Callable[[], None] | None = None,
110+
) -> SearchResponse: ...
111+
112+
99113
def _catch_daemon_start_error(func: _F) -> _F:
100114
"""Decorator that catches ``DaemonStartError`` and exits with a clean message.
101115
@@ -173,6 +187,174 @@ def print_search_results(response: SearchResponse) -> None:
173187
_typer.echo(r.content)
174188

175189

190+
def search_response_json_payload(response: SearchResponse) -> dict[str, object]:
191+
"""Build the machine-readable search response payload."""
192+
return {
193+
"success": response.success,
194+
"results": [
195+
{
196+
"file_path": r.file_path,
197+
"language": r.language,
198+
"content": r.content,
199+
"start_line": r.start_line,
200+
"end_line": r.end_line,
201+
"score": r.score,
202+
}
203+
for r in response.results
204+
],
205+
"total_returned": response.total_returned,
206+
"offset": response.offset,
207+
"message": response.message,
208+
}
209+
210+
211+
def print_search_results_json(response: SearchResponse) -> None:
212+
"""Print search results as machine-readable JSON."""
213+
payload = search_response_json_payload(response)
214+
_typer.echo(_json.dumps(payload, indent=2))
215+
216+
217+
def _jsonrpc_id(value: object) -> str | int | None:
218+
if value is None or isinstance(value, str):
219+
return value
220+
if isinstance(value, int) and not isinstance(value, bool):
221+
return value
222+
raise ValueError("JSON-RPC id must be a string, integer, or null")
223+
224+
225+
def _jsonrpc_success(request_id: str | int | None, result: object) -> dict[str, object]:
226+
return {
227+
"jsonrpc": "2.0",
228+
"id": request_id,
229+
"result": result,
230+
}
231+
232+
233+
def _jsonrpc_error(
234+
request_id: str | int | None,
235+
code: int,
236+
message: str,
237+
) -> dict[str, object]:
238+
return {
239+
"jsonrpc": "2.0",
240+
"id": request_id,
241+
"error": {
242+
"code": code,
243+
"message": message,
244+
},
245+
}
246+
247+
248+
def _required_str(params: dict[str, object], name: str) -> str:
249+
value = params.get(name)
250+
if not isinstance(value, str) or not value:
251+
raise ValueError(f"params.{name} must be a non-empty string")
252+
return value
253+
254+
255+
def _optional_str_list(params: dict[str, object], name: str) -> list[str] | None:
256+
value = params.get(name)
257+
if value is None:
258+
return None
259+
if not isinstance(value, list):
260+
raise ValueError(f"params.{name} must be a list of strings")
261+
result: list[str] = []
262+
for item in value:
263+
if not isinstance(item, str):
264+
raise ValueError(f"params.{name} must be a list of strings")
265+
result.append(item)
266+
return result
267+
268+
269+
def _positive_int_param(params: dict[str, object], name: str, default: int) -> int:
270+
value = params.get(name)
271+
if value is None:
272+
return default
273+
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
274+
raise ValueError(f"params.{name} must be a positive integer")
275+
return value
276+
277+
278+
def _non_negative_int_param(params: dict[str, object], name: str, default: int) -> int:
279+
value = params.get(name)
280+
if value is None:
281+
return default
282+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
283+
raise ValueError(f"params.{name} must be a non-negative integer")
284+
return value
285+
286+
287+
def handle_bridge_jsonrpc_request(
288+
request: object,
289+
search_func: _SearchCallable,
290+
) -> tuple[dict[str, object], bool]:
291+
"""Handle one JSON-RPC bridge request."""
292+
request_id: str | int | None = None
293+
try:
294+
if not isinstance(request, dict):
295+
return _jsonrpc_error(None, -32600, "Invalid Request"), False
296+
raw_id = request.get("id")
297+
request_id = _jsonrpc_id(raw_id)
298+
if request.get("jsonrpc") != "2.0":
299+
return _jsonrpc_error(request_id, -32600, "Invalid Request"), False
300+
method = request.get("method")
301+
if not isinstance(method, str):
302+
return _jsonrpc_error(request_id, -32600, "Invalid Request"), False
303+
params_obj = request.get("params", {})
304+
if not isinstance(params_obj, dict):
305+
return _jsonrpc_error(request_id, -32602, "Invalid params"), False
306+
params = {str(k): v for k, v in params_obj.items()}
307+
308+
if method == "ping":
309+
return _jsonrpc_success(request_id, {"ok": True}), False
310+
if method == "shutdown":
311+
return _jsonrpc_success(request_id, {"ok": True}), True
312+
if method != "search":
313+
return _jsonrpc_error(request_id, -32601, f"Method not found: {method}"), False
314+
315+
response = search_func(
316+
project_root=_required_str(params, "project_root"),
317+
query=_required_str(params, "query"),
318+
languages=_optional_str_list(params, "languages"),
319+
paths=_optional_str_list(params, "paths"),
320+
limit=_positive_int_param(params, "limit", 10),
321+
offset=_non_negative_int_param(params, "offset", 0),
322+
)
323+
return _jsonrpc_success(request_id, search_response_json_payload(response)), False
324+
except ValueError as e:
325+
return _jsonrpc_error(request_id, -32602, str(e)), False
326+
except RuntimeError as e:
327+
return _jsonrpc_error(request_id, -32000, str(e)), False
328+
329+
330+
def run_jsonrpc_bridge(
331+
input_stream: TextIO = sys.stdin,
332+
output_stream: TextIO = sys.stdout,
333+
search_func: _SearchCallable | None = None,
334+
) -> None:
335+
"""Run the JSON-RPC bridge over newline-delimited stdin/stdout."""
336+
if search_func is None:
337+
from . import client as _client
338+
339+
search_func = _client.search
340+
341+
for line in input_stream:
342+
stripped = line.strip()
343+
if not stripped:
344+
continue
345+
try:
346+
request = _json.loads(stripped)
347+
except _json.JSONDecodeError:
348+
response = _jsonrpc_error(None, -32700, "Parse error")
349+
should_exit = False
350+
else:
351+
response, should_exit = handle_bridge_jsonrpc_request(request, search_func)
352+
output_stream.write(_json.dumps(response, separators=(",", ":")) + "\n")
353+
output_stream.flush()
354+
if should_exit:
355+
break
356+
357+
176358
def _run_index_with_progress(project_root: str) -> None:
177359
"""Run indexing with streaming progress display. Exits on failure."""
178360
from rich.console import Console as _Console
@@ -543,6 +725,7 @@ def search(
543725
offset: int = _typer.Option(0, "--offset", help="Number of results to skip"),
544726
limit: int = _typer.Option(10, "--limit", help="Maximum results to return"),
545727
refresh: bool = _typer.Option(False, "--refresh", help="Refresh index before searching"),
728+
json_output: bool = _typer.Option(False, "--json", help="Print results as JSON"),
546729
) -> None:
547730
"""Semantic search across the codebase."""
548731
project_root = str(require_project_root())
@@ -568,7 +751,25 @@ def search(
568751
limit=limit,
569752
offset=offset,
570753
)
571-
print_search_results(resp)
754+
if json_output:
755+
print_search_results_json(resp)
756+
else:
757+
print_search_results(resp)
758+
759+
760+
@app.command()
761+
def bridge(
762+
jsonrpc: bool = _typer.Option(
763+
False,
764+
"--jsonrpc",
765+
help="Run a JSON-RPC bridge over stdin/stdout",
766+
),
767+
) -> None:
768+
"""Run a long-lived bridge for external tools."""
769+
if not jsonrpc:
770+
_typer.echo("Error: pass --jsonrpc to select the bridge protocol.", err=True)
771+
raise _typer.Exit(code=1)
772+
run_jsonrpc_bridge()
572773

573774

574775
@app.command()

0 commit comments

Comments
 (0)