Skip to content

Commit dc9ec59

Browse files
mickmickshclaude
andcommitted
chore: bump to v0.4.5 with changelog
Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 0cb588a commit dc9ec59

10 files changed

Lines changed: 694 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ All notable changes to LAP (Lean API Platform) will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.4.5] - 2026-03-12
9+
10+
### Added
11+
- **Registry search**: `lapsh search <query>` -- search the LAP registry for APIs
12+
- Supports `--tag`, `--sort`, `--limit`, `--offset`, `--json` flags
13+
- Output sanitization against ANSI injection from server responses
14+
- Pagination hints when more results available
15+
- **TypeScript SDK search**: `LAPClient.search()` method for registry search
16+
- **Search test coverage**: 64 Python tests + 6 TypeScript tests
17+
18+
### Fixed
19+
- TS SDK `fromRegistry()` used wrong URL path (`/specs/` -> `/v1/apis/`)
20+
- TypeScript SDK `SearchResponse` type and parser exports
21+
822
## [0.4.2] - 2026-03-10
923

1024
### Fixed

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.4"
3+
__version__ = "0.4.5"

lap/cli/main.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import glob
1010
import json
1111
import os
12+
import re
1213
import sys
1314
from contextlib import contextmanager
1415
from pathlib import Path
@@ -29,6 +30,31 @@
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

3460
def 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+
687785
def 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
}

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.4"
7+
version = "0.4.5"
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.4",
3+
"version": "0.4.5",
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/client.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as fs from 'fs';
22
import * as http from 'http';
33
import * as https from 'https';
4-
import { parse, LAPSpec } from './parser';
4+
import { parse, LAPSpec, SearchResponse } from './parser';
55

66
export interface ToContextOptions {
77
lean?: boolean;
@@ -19,15 +19,32 @@ export class LAPClient {
1919
}
2020

2121
async fromRegistry(registryUrl: string, apiName: string): Promise<LAPSpec> {
22-
const url = `${registryUrl.replace(/\/$/, '')}/specs/${encodeURIComponent(apiName)}`;
22+
const url = `${registryUrl.replace(/\/$/, '')}/v1/apis/${encodeURIComponent(apiName)}`;
2323
const text = await this._fetch(url);
2424
return parse(text);
2525
}
2626

27-
private _fetch(url: string): Promise<string> {
27+
async search(
28+
registryUrl: string,
29+
query: string,
30+
options?: { tag?: string; sort?: string; limit?: number; offset?: number }
31+
): Promise<SearchResponse> {
32+
const params = new URLSearchParams({ q: query });
33+
if (options?.tag) params.set('tag', options.tag);
34+
if (options?.sort) params.set('sort', options.sort);
35+
if (options?.limit !== undefined) params.set('limit', String(options.limit));
36+
if (options?.offset !== undefined) params.set('offset', String(options.offset));
37+
38+
const url = `${registryUrl.replace(/\/$/, '')}/v1/search?${params}`;
39+
const text = await this._fetch(url, { Accept: 'application/json' });
40+
return JSON.parse(text) as SearchResponse;
41+
}
42+
43+
private _fetch(url: string, headers?: Record<string, string>): Promise<string> {
2844
return new Promise((resolve, reject) => {
2945
const mod = url.startsWith('https') ? https : http;
30-
mod.get(url, res => {
46+
const options = headers ? { headers } : {};
47+
mod.get(url, options, res => {
3148
let data = '';
3249
res.on('data', chunk => data += chunk);
3350
res.on('end', () => {

sdks/typescript/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export {
66
ResponseSchema,
77
ResponseField,
88
ErrorSchema,
9+
SearchResult,
10+
SearchResponse,
911
} from './parser';
1012

1113
export { LAPClient, toContext, ToContextOptions } from './client';

sdks/typescript/src/parser.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,32 @@ function parseErrors(line: string): ErrorSchema[] {
290290
});
291291
}
292292

293+
export interface SearchResult {
294+
name: string;
295+
description: string;
296+
version: string;
297+
base_url: string;
298+
endpoints: number;
299+
tags: string[];
300+
size: number;
301+
lean_size: number | null;
302+
has_skill: boolean;
303+
skill_size: number | null;
304+
provider: { slug: string; display_name: string; domain: string };
305+
source_url: string;
306+
last_updated: string;
307+
is_community: boolean;
308+
}
309+
310+
export interface SearchResponse {
311+
query: string;
312+
results: SearchResult[];
313+
total: number;
314+
limit: number;
315+
offset: number;
316+
has_more: boolean;
317+
}
318+
293319
export function parse(text: string): LAPSpec {
294320
const lines = text.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('//'));
295321

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, it, before, after } from 'node:test';
2+
import * as assert from 'node:assert';
3+
import * as http from 'node:http';
4+
import { LAPClient } from '../src/client';
5+
6+
describe('LAPClient.search', () => {
7+
let server: http.Server;
8+
let baseUrl: string;
9+
let captured: { url?: string; headers?: http.IncomingHttpHeaders };
10+
let mockStatus = 200;
11+
let mockBody = JSON.stringify({
12+
query: 'test',
13+
results: [],
14+
total: 0,
15+
limit: 20,
16+
offset: 0,
17+
has_more: false,
18+
});
19+
20+
before(async () => {
21+
server = http.createServer((req, res) => {
22+
captured = { url: req.url, headers: req.headers };
23+
res.writeHead(mockStatus, { 'Content-Type': 'application/json' });
24+
res.end(mockBody);
25+
});
26+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
27+
const addr = server.address() as { port: number };
28+
baseUrl = `http://127.0.0.1:${addr.port}`;
29+
});
30+
31+
after(() => {
32+
server.close();
33+
});
34+
35+
it('builds correct URL with query only', async () => {
36+
const client = new LAPClient();
37+
await client.search(baseUrl, 'stripe');
38+
assert.ok(captured.url);
39+
assert.strictEqual(captured.url, '/v1/search?q=stripe');
40+
});
41+
42+
it('builds correct URL with all options', async () => {
43+
const client = new LAPClient();
44+
await client.search(baseUrl, 'stripe', { tag: 'pay', sort: 'popularity', limit: 5, offset: 10 });
45+
assert.ok(captured.url);
46+
const params = new URL(captured.url, baseUrl).searchParams;
47+
assert.strictEqual(params.get('q'), 'stripe');
48+
assert.strictEqual(params.get('tag'), 'pay');
49+
assert.strictEqual(params.get('sort'), 'popularity');
50+
assert.strictEqual(params.get('limit'), '5');
51+
assert.strictEqual(params.get('offset'), '10');
52+
});
53+
54+
it('sends Accept: application/json header', async () => {
55+
const client = new LAPClient();
56+
await client.search(baseUrl, 'test');
57+
assert.ok(captured.headers);
58+
assert.strictEqual(captured.headers.accept, 'application/json');
59+
});
60+
61+
it('parses JSON response correctly', async () => {
62+
const canned = {
63+
query: 'stripe',
64+
results: [{ name: 'Stripe', description: 'Payments', endpoints: 587 }],
65+
total: 1,
66+
limit: 20,
67+
offset: 0,
68+
has_more: false,
69+
};
70+
mockBody = JSON.stringify(canned);
71+
const client = new LAPClient();
72+
const result = await client.search(baseUrl, 'stripe');
73+
assert.strictEqual(result.query, 'stripe');
74+
assert.strictEqual(result.results.length, 1);
75+
assert.strictEqual(result.results[0].name, 'Stripe');
76+
assert.strictEqual(result.total, 1);
77+
// Reset
78+
mockBody = JSON.stringify({ query: 'test', results: [], total: 0, limit: 20, offset: 0, has_more: false });
79+
});
80+
81+
it('rejects on server error', async () => {
82+
mockStatus = 500;
83+
const client = new LAPClient();
84+
await assert.rejects(() => client.search(baseUrl, 'fail'), /500/);
85+
mockStatus = 200;
86+
});
87+
88+
it('strips trailing slash from registryUrl', async () => {
89+
const client = new LAPClient();
90+
await client.search(baseUrl + '/', 'test');
91+
assert.ok(captured.url);
92+
assert.ok(!captured.url.startsWith('//'), 'URL should not have double slash');
93+
assert.ok(captured.url.startsWith('/v1/search'), 'URL should start with /v1/search');
94+
});
95+
});

0 commit comments

Comments
 (0)