Skip to content

Commit 68dbeeb

Browse files
mickmickshclaude
andcommitted
feat: v0.4.7 -- search provider column, JSON url field, get command
- Add provider domain column to search text output (Python + TS CLI) - Add url field to JSON search results (registry) - Add `lapsh get <name>` command to download specs from registry - 71 search tests, 1016 total passing Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent dc7a0b6 commit 68dbeeb

7 files changed

Lines changed: 172 additions & 12 deletions

File tree

lap/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""LAP -- Lean API Platform. Token-efficient API specs for AI agents."""
22

3-
__version__ = "0.4.6"
3+
__version__ = "0.4.7"

lap/cli/main.py

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,8 @@ def _format_search_results(results, total, offset):
715715
rows = []
716716
for r in results:
717717
name = _sanitize(r.get("name", ""))
718+
prov = r.get("provider") or {}
719+
prov_str = _sanitize(prov.get("domain", "") or prov.get("display_name", "") or "")
718720
desc = _sanitize(r.get("description", ""))
719721
ep = r.get("endpoints")
720722
ep_str = f"{ep} endpoints" if isinstance(ep, int) else ""
@@ -725,20 +727,49 @@ def _format_search_results(results, total, offset):
725727
else:
726728
ratio_str = ""
727729
skill = " [skill]" if r.get("has_skill") else ""
728-
rows.append((name, ep_str, ratio_str, desc, skill))
730+
rows.append((name, prov_str, ep_str, ratio_str, desc, skill))
729731

730732
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+
prov_w = max((len(r[1]) for r in rows), default=0)
734+
ep_w = max((len(r[2]) for r in rows), default=0)
735+
ratio_w = max((len(r[3]) for r in rows), default=0)
733736

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}")
737+
for name, prov_str, ep_str, ratio_str, desc, skill in rows:
738+
print(f" {name:<{name_w}} {prov_str:<{prov_w}} {ep_str:>{ep_w}} {ratio_str:>{ratio_w}} {desc}{skill}")
736739

737740
shown = offset + len(results)
738741
if shown < total:
739742
info(f"Showing {shown}/{total} results. Use --offset {shown} for more.")
740743

741744

745+
from urllib.request import urlopen, Request as _UrlRequest
746+
747+
748+
def cmd_get(args):
749+
"""Download a LAP spec from the registry by name."""
750+
from lap.cli.auth import get_registry_url
751+
from urllib.parse import quote
752+
753+
name = args.name
754+
url = f"{get_registry_url()}/v1/apis/{quote(name, safe='')}"
755+
if getattr(args, "lean", False):
756+
url += "?format=lean"
757+
758+
try:
759+
req = _UrlRequest(url, headers={"Accept": "text/lap"})
760+
with urlopen(req) as resp:
761+
body = resp.read().decode("utf-8")
762+
except Exception as e:
763+
error(f"Failed to fetch '{name}': {e}")
764+
765+
if args.output:
766+
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
767+
Path(args.output).write_text(body, encoding="utf-8")
768+
info(f"Saved {name} to {args.output}")
769+
else:
770+
print(body)
771+
772+
742773
def cmd_search(args):
743774
"""Search the LAP registry for APIs. No auth required."""
744775
from lap.cli.auth import api_request
@@ -1034,6 +1065,12 @@ def main():
10341065
p.add_argument("name", help="API name from the registry")
10351066
p.add_argument("--dir", help="Custom install directory (default: ~/.claude/skills/)")
10361067

1068+
# get
1069+
p = sub.add_parser("get", help="Download a LAP spec from the registry")
1070+
p.add_argument("name", help="API name (e.g. stripe)")
1071+
p.add_argument("-o", "--output", help="Output file path")
1072+
p.add_argument("--lean", action="store_true", help="Download lean variant")
1073+
10371074
# search
10381075
p = sub.add_parser("search", help="Search the LAP registry for APIs")
10391076
p.add_argument("query", nargs="+", help="Search query")
@@ -1066,6 +1103,7 @@ def main():
10661103
"skill": cmd_skill,
10671104
"skill-batch": cmd_skill_batch,
10681105
"skill-install": cmd_skill_install,
1106+
"get": cmd_get,
10691107
"search": cmd_search,
10701108
"benchmark-skill": cmd_benchmark_skill,
10711109
"benchmark-skill-all": cmd_benchmark_skill_all,

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "lapsh"
7-
version = "0.4.6"
7+
version = "0.4.7"
88
description = "Lean API Platform -- Token-efficient API specs for AI agents"
99
readme = "README.md"
1010
license = "Apache-2.0"

sdks/typescript/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@lap-platform/lapsh",
3-
"version": "0.4.6",
3+
"version": "0.4.7",
44
"description": "TypeScript SDK for LAP (Lean API Platform) -- Parse and work with LAP API specifications",
55
"main": "dist/src/index.js",
66
"types": "dist/src/index.d.ts",

sdks/typescript/src/cli.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ Commands:
7070
skill-batch <dir> -o <outdir> Batch generate skills
7171
[--layer 1|2] [-v] Layer + verbose mode
7272
skill-install <name> [--dir <path>] Install skill from registry
73+
get <name> [-o output] [--lean] Download a LAP spec from the registry
7374
search <query> [--tag t] [--sort s] Search the LAP registry for APIs
7475
[--limit n] [--offset n] [--json] Pagination and JSON output
7576
@@ -434,22 +435,24 @@ async function cmdSearch(args: string[]): Promise<void> {
434435

435436
const rows = result.results.map(r => {
436437
const name = r.name || '';
438+
const prov = r.provider?.domain || r.provider?.display_name || '';
437439
const desc = r.description || '';
438440
const ep = typeof r.endpoints === 'number' ? `${r.endpoints} endpoints` : '';
439441
const size = r.size;
440442
const lean = r.lean_size;
441443
const ratio = (typeof size === 'number' && typeof lean === 'number' && lean)
442444
? `${(size / lean).toFixed(1)}x compressed` : '';
443445
const skill = r.has_skill ? ' [skill]' : '';
444-
return { name, ep, ratio, desc, skill };
446+
return { name, prov, ep, ratio, desc, skill };
445447
});
446448

447449
const nameW = Math.max(...rows.map(r => r.name.length));
450+
const provW = Math.max(...rows.map(r => r.prov.length));
448451
const epW = Math.max(...rows.map(r => r.ep.length));
449452
const ratioW = Math.max(...rows.map(r => r.ratio.length));
450453

451-
for (const { name, ep, ratio, desc, skill } of rows) {
452-
console.log(` ${name.padEnd(nameW)} ${ep.padStart(epW)} ${ratio.padStart(ratioW)} ${desc}${skill}`);
454+
for (const { name, prov, ep, ratio, desc, skill } of rows) {
455+
console.log(` ${name.padEnd(nameW)} ${prov.padEnd(provW)} ${ep.padStart(epW)} ${ratio.padStart(ratioW)} ${desc}${skill}`);
453456
}
454457

455458
const shown = (result.offset || 0) + result.results.length;
@@ -458,6 +461,51 @@ async function cmdSearch(args: string[]): Promise<void> {
458461
}
459462
}
460463

464+
async function cmdGet(args: string[]): Promise<void> {
465+
let name = '';
466+
let output = '';
467+
let lean = false;
468+
469+
for (let i = 0; i < args.length; i++) {
470+
if (args[i] === '-o' || args[i] === '--output') { output = args[++i]; }
471+
else if (args[i] === '--lean') { lean = true; }
472+
else if (!args[i].startsWith('-')) { name = args[i]; }
473+
}
474+
475+
if (!name) error('Missing API name. Usage: lapsh get <name> [-o output] [--lean]');
476+
477+
const registryUrl = getRegistryUrl();
478+
let url = `${registryUrl}/v1/apis/${encodeURIComponent(name)}`;
479+
if (lean) url += '?format=lean';
480+
481+
const http = await import('http');
482+
const https = await import('https');
483+
const fetcher = url.startsWith('https') ? https : http;
484+
485+
const body = await new Promise<string>((resolve, reject) => {
486+
const req = fetcher.get(url, { headers: { 'Accept': 'text/lap' } }, (res) => {
487+
if (res.statusCode && res.statusCode >= 400) {
488+
reject(new Error(`HTTP ${res.statusCode} fetching '${name}'`));
489+
res.resume();
490+
return;
491+
}
492+
const chunks: Buffer[] = [];
493+
res.on('data', (chunk: Buffer) => chunks.push(chunk));
494+
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
495+
});
496+
req.on('error', reject);
497+
});
498+
499+
if (output) {
500+
const dir = path.dirname(output);
501+
if (dir) fs.mkdirSync(dir, { recursive: true });
502+
fs.writeFileSync(output, body, 'utf-8');
503+
info(`Saved ${name} to ${output}`);
504+
} else {
505+
console.log(body);
506+
}
507+
}
508+
461509
async function cmdSkillInstall(args: string[]): Promise<void> {
462510
let name = '';
463511
let dir = '';
@@ -530,6 +578,9 @@ async function main(): Promise<void> {
530578
case 'skill-install':
531579
await cmdSkillInstall(args.slice(1));
532580
break;
581+
case 'get':
582+
await cmdGet(args.slice(1));
583+
break;
533584
case 'search':
534585
await cmdSearch(args.slice(1));
535586
break;

sdks/typescript/src/parser.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ export interface SearchResult {
305305
source_url: string;
306306
last_updated: string;
307307
is_community: boolean;
308+
url?: string;
308309
}
309310

310311
export interface SearchResponse {

tests/test_search.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
# Ensure project root is on path
1111
sys.path.insert(0, str(Path(__file__).parent.parent))
1212

13-
from lap.cli.main import _sanitize, _validate_search_response, _format_search_results, cmd_search
13+
from lap.cli.main import _sanitize, _validate_search_response, _format_search_results, cmd_search, cmd_get
1414

1515

1616
# ── _sanitize ────────────────────────────────────────────────────────
@@ -166,6 +166,7 @@ def _make_result(self, **overrides):
166166
"size": 1000,
167167
"lean_size": 250,
168168
"has_skill": True,
169+
"provider": {"slug": "stripe", "display_name": "Stripe", "domain": "stripe.com"},
169170
}
170171
base.update(overrides)
171172
return base
@@ -175,11 +176,30 @@ def test_basic_output(self, capsys):
175176
_format_search_results(results, total=1, offset=0)
176177
out = capsys.readouterr().out
177178
assert "stripe" in out
179+
assert "stripe.com" in out
178180
assert "42 endpoints" in out
179181
assert "4.0x compressed" in out
180182
assert "Payment processing" in out
181183
assert "[skill]" in out
182184

185+
def test_provider_domain_shown(self, capsys):
186+
results = [self._make_result(provider={"slug": "twilio", "display_name": "Twilio", "domain": "twilio.com"})]
187+
_format_search_results(results, total=1, offset=0)
188+
out = capsys.readouterr().out
189+
assert "twilio.com" in out
190+
191+
def test_provider_fallback_to_display_name(self, capsys):
192+
results = [self._make_result(provider={"slug": "x", "display_name": "MyAPI", "domain": ""})]
193+
_format_search_results(results, total=1, offset=0)
194+
out = capsys.readouterr().out
195+
assert "MyAPI" in out
196+
197+
def test_provider_missing_graceful(self, capsys):
198+
results = [self._make_result(provider=None)]
199+
_format_search_results(results, total=1, offset=0)
200+
out = capsys.readouterr().out
201+
assert "stripe" in out # name still shows
202+
183203
def test_no_skill_marker(self, capsys):
184204
results = [self._make_result(has_skill=False)]
185205
_format_search_results(results, total=1, offset=0)
@@ -423,3 +443,53 @@ def test_results_with_skill_shown(self, capsys):
423443
out = capsys.readouterr().out
424444
assert "twilio" in out
425445
assert "[skill]" in out
446+
447+
448+
# ── cmd_get ──────────────────────────────────────────────────────────
449+
450+
451+
def _make_get_args(**overrides):
452+
args = MagicMock()
453+
args.name = "stripe"
454+
args.output = None
455+
args.lean = False
456+
for k, v in overrides.items():
457+
setattr(args, k, v)
458+
return args
459+
460+
461+
class TestCmdGet:
462+
def test_get_prints_to_stdout(self, capsys):
463+
mock_resp = MagicMock()
464+
mock_resp.read.return_value = b"@api Stripe\n@base https://api.stripe.com"
465+
mock_resp.__enter__ = lambda s: s
466+
mock_resp.__exit__ = MagicMock(return_value=False)
467+
with patch("lap.cli.main.urlopen", return_value=mock_resp):
468+
cmd_get(_make_get_args())
469+
out = capsys.readouterr().out
470+
assert "@api Stripe" in out
471+
472+
def test_get_writes_to_file(self, tmp_path):
473+
out_file = str(tmp_path / "stripe.lap")
474+
mock_resp = MagicMock()
475+
mock_resp.read.return_value = b"@api Stripe"
476+
mock_resp.__enter__ = lambda s: s
477+
mock_resp.__exit__ = MagicMock(return_value=False)
478+
with patch("lap.cli.main.urlopen", return_value=mock_resp):
479+
cmd_get(_make_get_args(output=out_file))
480+
assert Path(out_file).read_text(encoding="utf-8") == "@api Stripe"
481+
482+
def test_get_lean_flag_adds_query_param(self):
483+
mock_resp = MagicMock()
484+
mock_resp.read.return_value = b"@api Stripe"
485+
mock_resp.__enter__ = lambda s: s
486+
mock_resp.__exit__ = MagicMock(return_value=False)
487+
with patch("lap.cli.main.urlopen", return_value=mock_resp) as mock_open:
488+
cmd_get(_make_get_args(lean=True))
489+
call_arg = mock_open.call_args[0][0]
490+
assert "format=lean" in call_arg.full_url
491+
492+
def test_get_network_error_exits(self):
493+
with patch("lap.cli.main.urlopen", side_effect=Exception("Connection refused")):
494+
with pytest.raises(SystemExit):
495+
cmd_get(_make_get_args())

0 commit comments

Comments
 (0)