33from __future__ import annotations
44
55import functools
6+ import json as _json
67import os
78import sys
89from collections .abc import Callable
910from pathlib import Path
10- from typing import TypeVar
11+ from typing import Protocol , TextIO , TypeVar
1112
1213import 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+
99113def _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+
176358def _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