99import glob
1010import json
1111import os
12+ import re
1213import sys
1314from contextlib import contextmanager
1415from pathlib import Path
2930 console = None
3031
3132
33+ # ── Security helpers ─────────────────────────────────────────────────
34+
35+ _ANSI_ESCAPE = re .compile (r'\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])' )
36+ _CTRL_CHARS = re .compile (r'[\x00-\x08\x0b-\x1f\x7f]' )
37+
38+ def _sanitize (s : str ) -> str :
39+ """Strip ANSI escapes and control characters from server-supplied strings."""
40+ return _CTRL_CHARS .sub ('' , _ANSI_ESCAPE .sub ('' , s ))
41+
42+ def _validate_search_response (result ):
43+ """Validate registry response shape before field access."""
44+ if not isinstance (result , dict ):
45+ error ("Unexpected response format from registry." )
46+ results = result .get ("results" , [])
47+ if not isinstance (results , list ):
48+ error ("Registry returned malformed results." )
49+ for r in results :
50+ if not isinstance (r , dict ):
51+ error ("Registry returned malformed result entry." )
52+ if not isinstance (result .get ("total" , 0 ), int ):
53+ result ["total" ] = len (results )
54+ if not isinstance (result .get ("offset" , 0 ), int ):
55+ result ["offset" ] = 0
56+
57+
3258# ── Helpers ──────────────────────────────────────────────────────────
3359
3460def info (msg ):
@@ -684,6 +710,78 @@ def cmd_skill_install(args):
684710 info (f"Installed { len (files )} files to { install_dir } " )
685711
686712
713+ def _format_search_results (results , total , offset ):
714+ """Format and print search results."""
715+ rows = []
716+ for r in results :
717+ name = _sanitize (r .get ("name" , "" ))
718+ desc = _sanitize (r .get ("description" , "" ))
719+ ep = r .get ("endpoints" )
720+ ep_str = f"{ ep } endpoints" if isinstance (ep , int ) else ""
721+ size = r .get ("size" , 0 )
722+ lean = r .get ("lean_size" )
723+ if isinstance (size , (int , float )) and isinstance (lean , (int , float )) and lean :
724+ ratio_str = f"{ size / lean :.1f} x compressed"
725+ else :
726+ ratio_str = ""
727+ skill = " [skill]" if r .get ("has_skill" ) else ""
728+ rows .append ((name , ep_str , ratio_str , desc , skill ))
729+
730+ name_w = max ((len (r [0 ]) for r in rows ), default = 0 )
731+ ep_w = max ((len (r [1 ]) for r in rows ), default = 0 )
732+ ratio_w = max ((len (r [2 ]) for r in rows ), default = 0 )
733+
734+ for name , ep_str , ratio_str , desc , skill in rows :
735+ print (f" { name :<{name_w }} { ep_str :>{ep_w }} { ratio_str :>{ratio_w }} { desc } { skill } " )
736+
737+ shown = offset + len (results )
738+ if shown < total :
739+ info (f"Showing { shown } /{ total } results. Use --offset { shown } for more." )
740+
741+
742+ def cmd_search (args ):
743+ """Search the LAP registry for APIs. No auth required."""
744+ from lap .cli .auth import api_request
745+ from urllib .parse import urlencode
746+
747+ query = " " .join (args .query )
748+ if not query .strip ():
749+ error ("Please provide a search query." )
750+
751+ params = {"q" : query }
752+ if args .tag :
753+ params ["tag" ] = args .tag
754+ if args .sort :
755+ params ["sort" ] = args .sort
756+ if args .limit is not None :
757+ params ["limit" ] = str (args .limit )
758+ if args .offset is not None :
759+ params ["offset" ] = str (args .offset )
760+
761+ try :
762+ result = api_request ("GET" , f"/v1/search?{ urlencode (params )} " )
763+ except SystemExit :
764+ raise
765+ except Exception as e :
766+ error (f"Search failed: { e } " )
767+
768+ _validate_search_response (result )
769+
770+ results = result .get ("results" , [])
771+ total = result .get ("total" , len (results ))
772+ offset = result .get ("offset" , 0 )
773+
774+ if args .json :
775+ print (json .dumps (result , indent = 2 , ensure_ascii = True ))
776+ return
777+
778+ if not results :
779+ info (f"No results for '{ query } '." )
780+ return
781+
782+ _format_search_results (results , total , offset )
783+
784+
687785def cmd_benchmark_skill (args ):
688786 """Benchmark skill token usage for a spec."""
689787 from lap .core .compilers import compile as compile_spec
@@ -936,6 +1034,15 @@ def main():
9361034 p .add_argument ("name" , help = "API name from the registry" )
9371035 p .add_argument ("--dir" , help = "Custom install directory (default: ~/.claude/skills/)" )
9381036
1037+ # search
1038+ p = sub .add_parser ("search" , help = "Search the LAP registry for APIs" )
1039+ p .add_argument ("query" , nargs = "+" , help = "Search query" )
1040+ p .add_argument ("--tag" , help = "Filter by tag" )
1041+ p .add_argument ("--sort" , choices = ["relevance" , "popularity" , "date" ], help = "Sort order" )
1042+ p .add_argument ("--limit" , type = int , help = "Max results (default: 50)" )
1043+ p .add_argument ("--offset" , type = int , help = "Pagination offset" )
1044+ p .add_argument ("--json" , action = "store_true" , help = "Output raw JSON" )
1045+
9391046 # benchmark-skill
9401047 p = sub .add_parser ("benchmark-skill" , help = "Benchmark skill token usage for a spec" )
9411048 p .add_argument ("spec" , help = "Path to API spec file" )
@@ -959,6 +1066,7 @@ def main():
9591066 "skill" : cmd_skill ,
9601067 "skill-batch" : cmd_skill_batch ,
9611068 "skill-install" : cmd_skill_install ,
1069+ "search" : cmd_search ,
9621070 "benchmark-skill" : cmd_benchmark_skill ,
9631071 "benchmark-skill-all" : cmd_benchmark_skill_all ,
9641072 }
0 commit comments