From ea93bc2c4d7e542795c0b61069c7187485a1fb50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Thu, 2 Apr 2026 00:07:15 +0200 Subject: [PATCH 1/5] feat: discover command --- src/commands/discover.ts | 44 +++++++++++++++++++++ src/index.ts | 2 + src/tools/lightning/discover.ts | 69 +++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 src/commands/discover.ts create mode 100644 src/tools/lightning/discover.ts diff --git a/src/commands/discover.ts b/src/commands/discover.ts new file mode 100644 index 0000000..f442f61 --- /dev/null +++ b/src/commands/discover.ts @@ -0,0 +1,44 @@ +import { Command } from "commander"; +import { discover } from "../tools/lightning/discover.js"; +import { handleError, output } from "../utils.js"; + +export function registerDiscoverCommand(program: Command) { + program + .command("discover") + .description( + "Discover Lightning-payable API services from the 402index.io directory", + ) + .option("-q, --query ", "Search query") + .option( + "-C, --category ", + "Filter by category (e.g. ai, data, bitcoin, nostr)", + ) + .option( + "-p, --protocol ", + "Filter by protocol (L402, x402, MPP)", + ) + .option( + "--health ", + "Filter by health (Healthy, Degraded, Down, Unknown)", + "Healthy", + ) + .option( + "-s, --sort ", + "Sort by (Reliability, Latency, Price, Name)", + "Reliability", + ) + .option("-l, --limit ", "Number of results", "10") + .action(async (options) => { + await handleError(async () => { + const result = await discover({ + query: options.query, + category: options.category, + protocol: options.protocol, + health: options.health, + sort: options.sort, + limit: parseInt(options.limit, 10), + }); + output(result); + }); + }); +} diff --git a/src/index.ts b/src/index.ts index 47e716a..1626d5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { registerRequestInvoiceFromLightningAddressCommand } from "./commands/re import { registerFetch402Command } from "./commands/fetch.js"; import { registerConnectCommand } from "./commands/connect.js"; import { registerAuthCommand } from "./commands/auth.js"; +import { registerDiscoverCommand } from "./commands/discover.js"; const program = new Command(); @@ -95,6 +96,7 @@ registerRequestInvoiceFromLightningAddressCommand(program); // Register fetch command for payment-protected resources program.commandsGroup("HTTP 402 Payments (requires wallet connection):"); registerFetch402Command(program); +registerDiscoverCommand(program); // Register setup commands program.commandsGroup("Setup:"); diff --git a/src/tools/lightning/discover.ts b/src/tools/lightning/discover.ts new file mode 100644 index 0000000..8655223 --- /dev/null +++ b/src/tools/lightning/discover.ts @@ -0,0 +1,69 @@ +export interface DiscoverParams { + query?: string; + category?: string; + protocol?: string; + health?: string; + sort?: string; + limit?: number; +} + +export async function discover(params: DiscoverParams) { + const url = new URL("https://402index.io/api/v1/services"); + const requestedLimit = params.limit ?? 10; + + if (params.query) url.searchParams.set("q", params.query); + if (params.category) url.searchParams.set("category", params.category); + if (params.protocol) url.searchParams.set("protocol", params.protocol); + if (params.health) url.searchParams.set("health", params.health); + if (params.sort) url.searchParams.set("sort", params.sort); + + // Filter to BTC (Lightning) services server-side + url.searchParams.set("payment_asset", "BTC"); + url.searchParams.set("limit", String(requestedLimit)); + + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error( + `402index.io returned status ${response.status}: ${await response.text()}`, + ); + } + + const data = (await response.json()) as { + services: Array<{ + name: string; + description: string; + url: string; + protocol: string; + price_sats: number | null; + price_usd: number | null; + payment_network: string; + category: string; + provider: string; + health_status: string; + reliability_score: number | null; + latency_p50_ms: number | null; + http_method: string; + }>; + total: number; + limit: number; + offset: number; + }; + + return { + services: data.services.map((s) => ({ + name: s.name, + description: s.description, + url: s.url, + protocol: s.protocol, + price_sats: s.price_sats, + price_usd: s.price_usd, + category: s.category, + provider: s.provider, + health_status: s.health_status, + reliability_score: s.reliability_score, + latency_p50_ms: s.latency_p50_ms, + http_method: s.http_method, + })), + total: data.total, + }; +} From b68f020f6411b8a111e4ff7e4b212340933b78da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Thu, 9 Apr 2026 20:45:18 +0200 Subject: [PATCH 2/5] fix: use lowercase values for health/sort to match 402index API The API expects lowercase enum values (healthy, reliability, etc). Capitalized values were silently ignored, returning unfiltered results. Also improved the command description. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/discover.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/commands/discover.ts b/src/commands/discover.ts index f442f61..b24c2ca 100644 --- a/src/commands/discover.ts +++ b/src/commands/discover.ts @@ -6,7 +6,7 @@ export function registerDiscoverCommand(program: Command) { program .command("discover") .description( - "Discover Lightning-payable API services from the 402index.io directory", + "Search 402index.io for paid API services that accept bitcoin/lightning", ) .option("-q, --query ", "Search query") .option( @@ -19,13 +19,13 @@ export function registerDiscoverCommand(program: Command) { ) .option( "--health ", - "Filter by health (Healthy, Degraded, Down, Unknown)", - "Healthy", + "Filter by health (healthy, degraded, down, unknown)", + "healthy", ) .option( "-s, --sort ", - "Sort by (Reliability, Latency, Price, Name)", - "Reliability", + "Sort by (reliability, latency, price, name)", + "reliability", ) .option("-l, --limit ", "Number of results", "10") .action(async (options) => { From ded7daec05839c5b321d464baecb6b7e25785b4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Thu, 9 Apr 2026 21:11:33 +0200 Subject: [PATCH 3/5] chore: remove category filter from discover command Category names aren't discoverable from the CLI. Free-text search via -q covers the same use cases better. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/discover.ts | 5 ----- src/tools/lightning/discover.ts | 2 -- 2 files changed, 7 deletions(-) diff --git a/src/commands/discover.ts b/src/commands/discover.ts index b24c2ca..f5f199c 100644 --- a/src/commands/discover.ts +++ b/src/commands/discover.ts @@ -9,10 +9,6 @@ export function registerDiscoverCommand(program: Command) { "Search 402index.io for paid API services that accept bitcoin/lightning", ) .option("-q, --query ", "Search query") - .option( - "-C, --category ", - "Filter by category (e.g. ai, data, bitcoin, nostr)", - ) .option( "-p, --protocol ", "Filter by protocol (L402, x402, MPP)", @@ -32,7 +28,6 @@ export function registerDiscoverCommand(program: Command) { await handleError(async () => { const result = await discover({ query: options.query, - category: options.category, protocol: options.protocol, health: options.health, sort: options.sort, diff --git a/src/tools/lightning/discover.ts b/src/tools/lightning/discover.ts index 8655223..70fbd0f 100644 --- a/src/tools/lightning/discover.ts +++ b/src/tools/lightning/discover.ts @@ -1,6 +1,5 @@ export interface DiscoverParams { query?: string; - category?: string; protocol?: string; health?: string; sort?: string; @@ -12,7 +11,6 @@ export async function discover(params: DiscoverParams) { const requestedLimit = params.limit ?? 10; if (params.query) url.searchParams.set("q", params.query); - if (params.category) url.searchParams.set("category", params.category); if (params.protocol) url.searchParams.set("protocol", params.protocol); if (params.health) url.searchParams.set("health", params.health); if (params.sort) url.searchParams.set("sort", params.sort); From 2277fea7e8d1903a59a0bdb5e2c3f96df8b0ad39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Thu, 9 Apr 2026 21:12:31 +0200 Subject: [PATCH 4/5] fix: move discover command out of wallet-required group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit discover doesn't need a wallet connection — give it its own "Service Discovery" group in the help output. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/index.ts b/src/index.ts index 1626d5c..018a51f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -96,6 +96,9 @@ registerRequestInvoiceFromLightningAddressCommand(program); // Register fetch command for payment-protected resources program.commandsGroup("HTTP 402 Payments (requires wallet connection):"); registerFetch402Command(program); + +// Register service discovery +program.commandsGroup("Service Discovery:"); registerDiscoverCommand(program); // Register setup commands From 74fe7360b31f03f4b01be55f6ebf4373beda753a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Thu, 9 Apr 2026 21:15:10 +0200 Subject: [PATCH 5/5] fix: add 5s timeout to discover fetch Prevents agents from hanging indefinitely on a stalled request. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tools/lightning/discover.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/tools/lightning/discover.ts b/src/tools/lightning/discover.ts index 70fbd0f..6aa1db4 100644 --- a/src/tools/lightning/discover.ts +++ b/src/tools/lightning/discover.ts @@ -19,7 +19,19 @@ export async function discover(params: DiscoverParams) { url.searchParams.set("payment_asset", "BTC"); url.searchParams.set("limit", String(requestedLimit)); - const response = await fetch(url.toString()); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + let response: Response; + try { + response = await fetch(url.toString(), { signal: controller.signal }); + } catch (error: unknown) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error("Request to 402index.io timed out"); + } + throw error; + } finally { + clearTimeout(timer); + } if (!response.ok) { throw new Error( `402index.io returned status ${response.status}: ${await response.text()}`,