From 30bbd4a81e1c5404f201b00cb56b1d0684000ff5 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sat, 5 Sep 2026 01:40:33 -0700 Subject: [PATCH] Let a catalog search name a scope without a query The gateway's ranked search accepts any non-empty combination of q, category and platform, so /catalog/search?platform=reddit is a 200. The CLI could not express that: `search` took the query positionally and `AnyApiClient.search` unconditionally set `q`, so "every reddit API" was reachable over MCP and over curl but not over `anyapi search`. The query argument is now optional and `--category`/`--platform` join it, following `list --category` rather than inventing a new flag shape. `anyapi search reddit` is unchanged. An absent query is omitted from the query string rather than sent empty, and a search naming none of the three is rejected in the client with a clear message instead of being sent to the gateway to earn a 400. The bundled anyapi-discover skill and the README document the scope form, because they document this command's usage. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 1 + __tests__/bundled-skills.test.ts | 1 + __tests__/discovery.test.ts | 24 ++++++++++++++++++++++++ skills/anyapi-discover/SKILL.md | 4 ++++ src/api.ts | 13 +++++++++++-- src/commands.ts | 8 ++++++-- src/index.ts | 6 ++++-- 7 files changed, 51 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 9fdb4fb..b5a134e 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ anyapi run reddit.search --input '{"query":"anyapi","limit":5}' - `anyapi login` - sign in to an AnyAPI account immediately with the OAuth 2.0 device flow. The CLI prints a verification URL and user code, opens the complete URL when possible, and waits without binding a localhost callback. - `anyapi login --api-key aa_live_...` - manual-key compatibility path: store an existing dashboard key locally without starting OAuth. - `anyapi search ` - search the public catalog and print SKU, name, and USD price terms. + `--category ` and `--platform ` scope the search, and either one works without a query. - `anyapi list [--category ]` - list catalog APIs. - `anyapi describe ` - print the authenticated API definition, including opaque schemas and gateway-published USD pricing, lane order, and failover metadata. - `anyapi run [--input ''] [-i file] [--idempotency-key ] [--jq ] [--fields a,b] [--max-items N] [--summary] [-o path] [--json]` - run an API. Always saves the full result; shape flags trim only the stdout view. diff --git a/__tests__/bundled-skills.test.ts b/__tests__/bundled-skills.test.ts index 04b2171..0f46495 100644 --- a/__tests__/bundled-skills.test.ts +++ b/__tests__/bundled-skills.test.ts @@ -12,6 +12,7 @@ describe('bundled agent skills', () => { it('document the current discovery, execution, and onboarding commands', () => { const discover = readSkill('anyapi-discover'); expect(discover).toContain('anyapi search '); + expect(discover).toContain('anyapi search --category --platform '); expect(discover).toContain('anyapi list --category '); expect(discover).toContain('anyapi describe '); expect(discover).toContain('dedicated ranked discovery search'); diff --git a/__tests__/discovery.test.ts b/__tests__/discovery.test.ts index 3a0241c..efb2bb5 100644 --- a/__tests__/discovery.test.ts +++ b/__tests__/discovery.test.ts @@ -162,6 +162,30 @@ describe('customer-safe discovery reader', () => { expectCustomerSafe(response); }); + it('scopes a search without a query and omits q rather than sending it empty', async () => { + let requested = ''; + const client = clientFor({ results: [], total: 0, ranking: 'semantic' }, (url) => { requested = url; }); + + await client.search({ platform: 'reddit' }); + + let url = new URL(requested); + expect(url.pathname).toBe('/catalog/search'); + expect(Object.fromEntries(url.searchParams)).toEqual({ platform: 'reddit' }); + + await client.search({ category: 'social', limit: 5 }); + + url = new URL(requested); + expect(Object.fromEntries(url.searchParams)).toEqual({ category: 'social', limit: '5' }); + }); + + it('rejects a search naming no query, category, or platform without calling the gateway', async () => { + let called = false; + const client = clientFor({ results: [] }, () => { called = true; }); + + await expect(client.search({})).rejects.toThrow('Search needs a query, --category, or --platform.'); + expect(called).toBe(false); + }); + it('reads authenticated detail responses and preserves schemas as opaque JSON', async () => { let authorization = ''; const body = { diff --git a/skills/anyapi-discover/SKILL.md b/skills/anyapi-discover/SKILL.md index 29543d7..ad6358a 100644 --- a/skills/anyapi-discover/SKILL.md +++ b/skills/anyapi-discover/SKILL.md @@ -15,6 +15,7 @@ Use this before running an unknown task. Search or list first, then describe the ```sh anyapi search "reddit posts" +anyapi search --platform reddit anyapi list --category social anyapi describe reddit.search ``` @@ -32,6 +33,9 @@ Search and list are public. Describe is authenticated because it returns the ful ## Key options - `anyapi search ` uses the dedicated ranked discovery search. +- `anyapi search --category --platform ` scopes the search. Any + non-empty combination of the query, `--category` and `--platform` works, so a + scope with no query lists every API on that platform or in that category. - `anyapi list --category ` narrows by category. - `anyapi describe ` prints input schema, output schema, and USD pricing. - Discovery pricing is always nested under `pricing`; ranked search reports diff --git a/src/api.ts b/src/api.ts index e1ad269..ad6a5d9 100644 --- a/src/api.ts +++ b/src/api.ts @@ -128,11 +128,20 @@ export class AnyApiClient { return readCatalogResponse(body); } - async search(options: { query: string; category?: string; platform?: string; limit?: number }): Promise { + // search accepts any non-empty combination of query, category and platform, + // matching what the gateway's ranked search allows. An absent query is omitted + // rather than sent empty, and a request naming none of the three is rejected + // here instead of earning a gateway 400. + async search(options: { query?: string; category?: string; platform?: string; limit?: number }): Promise { + if (!options.query && !options.category && !options.platform) { + throw new CliError('Search needs a query, --category, or --platform.'); + } const url = new URL(this.catalogUrl); url.pathname = `${url.pathname.replace(/\/$/, '')}/search`; url.search = ''; - url.searchParams.set('q', options.query); + if (options.query) { + url.searchParams.set('q', options.query); + } if (options.category) { url.searchParams.set('category', options.category); } diff --git a/src/commands.ts b/src/commands.ts index 58b5c97..fc8de68 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -78,9 +78,13 @@ export async function loginCommand( writeLine(ctx.stdout, 'AnyAPI key saved to ~/.anyapi/config.json.'); } -export async function searchCommand(ctx: CommandContext, query: string): Promise { +export async function searchCommand( + ctx: CommandContext, + query: string | undefined, + options: { category?: string; platform?: string } = {}, +): Promise { const client = new AnyApiClient({ fetchImpl: ctx.fetchImpl }); - const results = await client.search({ query }); + const results = await client.search({ query, category: options.category, platform: options.platform }); writeCatalogTable(ctx, results.results); } diff --git a/src/index.ts b/src/index.ts index 2363005..2cfb4c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,8 +58,10 @@ program program .command("search") .description("Search the public AnyAPI catalog.") - .argument("", "Search query.") - .action((query) => run(() => searchCommand(ctx, query))); + .argument("[query]", "Search query. Optional when --category or --platform is given.") + .option("--category ", "Filter by category.") + .option("--platform ", "Filter by platform.") + .action((query, options) => run(() => searchCommand(ctx, query, options))); program .command("list")