From e2108fc302e6ca605e40f3f2373efed2c8da4405 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sat, 15 Aug 2026 15:37:19 -0700 Subject: [PATCH] Let an agent report a bug without leaving the shell The gateway now accepts bug reports and feedback (anyapi#650), so give the CLI the two commands that reach them. Two commands rather than one with a flag: the route stamps the kind, so neither takes a kind argument and a caller cannot mislabel its own message. --request-id deliberately does not default to the newest saved run. Silently attaching a possibly unrelated call would degrade the exact field the feature exists to improve, so the commands print a one-line nudge instead. anyapi-run gains the section that tells an agent what to do when a result looks wrong, which is the whole point: an agent that hits bad data had nowhere to say so. --version reported 0.5.0 while the package was 0.7.0. That is the first thing anyone triaging one of these reports would check, so it is fixed here rather than left to misreport itself in the reports this release enables. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 + __tests__/feedback.test.ts | 141 +++++++++++++++++++++++++++++++++++++ package.json | 2 +- skills/anyapi-run/SKILL.md | 19 +++++ src/api.ts | 24 +++++++ src/commands.ts | 4 +- src/feedback.ts | 62 ++++++++++++++++ src/index.ts | 34 ++++++++- src/types.ts | 21 ++++++ 9 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 __tests__/feedback.test.ts create mode 100644 src/feedback.ts diff --git a/README.md b/README.md index d0e64d9..b7ece3f 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ anyapi run reddit.search --input '{"query":"anyapi","limit":5}' - `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. - `anyapi view [path] [--last [sku]] [--jq ] [--fields a,b] [--max-items N] [--summary] [--json]` - re-shape a saved run file locally. Zero network, zero cost. - `anyapi balance` - print the remaining USD balance. +- `anyapi report-bug [--details ] [--sku ] [--request-id ] [--contact ]` - tell us something is broken: a wrong or empty result, a misleading error, a price that looks off. Free, never charged. `--request-id` from the failing run is the most useful thing you can attach, because it reaches the stored run and its upstream error body. +- `anyapi feedback [--details ] [--sku ] [--request-id ] [--contact ]` - tell us something that is not a defect: a missing API, a missing field, confusing docs. Free, never charged. - `anyapi init [--all] [--yes]` - mint a trial key if none exists, install bundled agent skills, and show or apply MCP setup snippets. - `anyapi setup skills` - install only the bundled skills. diff --git a/__tests__/feedback.test.ts b/__tests__/feedback.test.ts new file mode 100644 index 0000000..f4464d7 --- /dev/null +++ b/__tests__/feedback.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; +import { PassThrough } from 'node:stream'; +import { AnyApiClient } from '../src/api.js'; +import { feedbackCommand, reportBugCommand } from '../src/feedback.js'; +import { CliError } from '../src/errors.js'; +import type { CommandContext } from '../src/io.js'; +import type { FetchLike } from '../src/types.js'; + +interface Captured { + url: string; + method?: string; + body: Record; + authorization?: string; +} + +function capturingFetch(captured: Captured[], status = 201): FetchLike { + return async (input, init) => { + const headers = new Headers(init?.headers as HeadersInit); + captured.push({ + url: String(input), + method: init?.method, + body: init?.body ? JSON.parse(String(init.body)) : {}, + authorization: headers.get('authorization') ?? undefined, + }); + if (status >= 400) { + return Response.json({ error: 'this account has reached its stored report limit', code: 'report_limit_reached' }, { status }); + } + return Response.json({ + id: 'report-1', kind: 'bug', summary: 'x', createdAt: '2026-08-15T22:00:00Z', + }, { status }); + }; +} + +function context(fetchImpl: FetchLike): CommandContext { + return { + cwd: '/tmp', + homeDir: '/tmp/anyapi-feedback-home', + env: { ANYAPI_API_KEY: 'aa_live_test' }, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + fetchImpl, + }; +} + +function output(ctx: CommandContext): string { + return String(ctx.stdout.read() ?? ''); +} + +describe('report commands', () => { + it('files a bug on /bug-reports and feedback on /feedback', async () => { + const captured: Captured[] = []; + const ctx = context(capturingFetch(captured)); + await reportBugCommand(ctx, {}, 'reels_search returned no items', {}); + await feedbackCommand(ctx, {}, 'no SKU for Substack archives', {}); + expect(captured.map((c) => c.url)).toEqual([ + 'https://api.getanyapi.com/v1/bug-reports', + 'https://api.getanyapi.com/v1/feedback', + ]); + expect(captured.every((c) => c.method === 'POST')).toBe(true); + }); + + // The route carries the kind. A body field would let a caller contradict it. + it('never sends a kind in the body', async () => { + const captured: Captured[] = []; + await reportBugCommand(context(capturingFetch(captured)), {}, 'broken', {}); + expect(captured[0].body).not.toHaveProperty('kind'); + expect(captured[0].body).not.toHaveProperty('surface'); + }); + + it('sends every supplied option and omits the ones left out', async () => { + const captured: Captured[] = []; + await reportBugCommand(context(capturingFetch(captured)), {}, ' padded summary ', { + details: 'ran twice', + sku: 'instagram.reels_search', + requestId: 'req_1', + contact: 'agent@example.test', + }); + expect(captured[0].body).toEqual({ + summary: 'padded summary', + details: 'ran twice', + sku: 'instagram.reels_search', + requestId: 'req_1', + contact: 'agent@example.test', + }); + + const bare: Captured[] = []; + await reportBugCommand(context(capturingFetch(bare)), {}, 'broken', {}); + expect(bare[0].body).toEqual({ summary: 'broken' }); + }); + + it('authenticates with the resolved key', async () => { + const captured: Captured[] = []; + await reportBugCommand(context(capturingFetch(captured)), {}, 'broken', {}); + expect(captured[0].authorization).toBe('Bearer aa_live_test'); + }); + + it('rejects a blank summary before spending a request', async () => { + const captured: Captured[] = []; + await expect( + reportBugCommand(context(capturingFetch(captured)), {}, ' ', {}), + ).rejects.toBeInstanceOf(CliError); + expect(captured).toHaveLength(0); + }); + + it('prints the reference and nudges toward a request id only when none was given', async () => { + const withId = context(capturingFetch([])); + await reportBugCommand(withId, {}, 'broken', { requestId: 'req_1' }); + const withIdOut = output(withId); + expect(withIdOut).toContain('report-1'); + expect(withIdOut).not.toContain('Tip:'); + + const withoutId = context(capturingFetch([])); + await reportBugCommand(withoutId, {}, 'broken', {}); + expect(output(withoutId)).toContain('Tip:'); + }); + + it('surfaces the gateway limit message rather than a bare status', async () => { + const ctx = context(capturingFetch([], 409)); + await expect(reportBugCommand(ctx, {}, 'broken', {})).rejects.toThrow(/stored report limit/); + }); +}); + +describe('AnyApiClient.submitReport', () => { + it('targets the route matching the kind', async () => { + const seen: string[] = []; + const client = new AnyApiClient({ + apiKey: 'aa_live_test', + fetchImpl: async (input) => { + seen.push(String(input)); + return Response.json({ id: 'r', kind: 'bug', summary: 's', createdAt: 'now' }, { status: 201 }); + }, + }); + await client.submitReport('bug', { summary: 's' }); + await client.submitReport('feedback', { summary: 's' }); + expect(seen).toEqual([ + 'https://api.getanyapi.com/v1/bug-reports', + 'https://api.getanyapi.com/v1/feedback', + ]); + }); +}); diff --git a/package.json b/package.json index ffaf7d4..d9c4980 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "anyapi-cli", - "version": "0.7.0", + "version": "0.8.0", "description": "Official CLI for AnyAPI, a unified marketplace for scraping and data APIs.", "type": "module", "bin": { diff --git a/skills/anyapi-run/SKILL.md b/skills/anyapi-run/SKILL.md index eacc450..7f67e51 100644 --- a/skills/anyapi-run/SKILL.md +++ b/skills/anyapi-run/SKILL.md @@ -100,6 +100,25 @@ anyapi connect that one URL to your human; they approve continued spend and set a limit, then the same commands keep working with no key to swap by hand. +## When a result looks wrong + +A run that succeeds but returns wrong, empty, or malformed data for input you +believe is valid is a defect worth reporting. So is a misleading error, or a +price that does not match the catalog. Filing is free and never charged: + +```sh +anyapi report-bug "reels_search returned no items for a query with results" \ + --sku instagram.reels_search --request-id +``` + +`--request-id` is the single most useful thing to attach: it reaches the stored +run and its upstream error body, so you never need to paste the payload. Use +`anyapi feedback` instead for what is not a defect, such as an API you could not +find in the catalog or a field missing from a result. + +File it and carry on with the best alternative you have. Do not stop your +human's task to ask permission first. + ## Tips - Prefer `--jq` for anything non-trivial; use `--fields`/`--max-items` for quick trims. diff --git a/src/api.ts b/src/api.ts index 0262087..316baa2 100644 --- a/src/api.ts +++ b/src/api.ts @@ -6,6 +6,9 @@ import type { CatalogResponse, ClientRegistrationResponse, DeviceAuthorizationResponse, + FeedbackKind, + FeedbackReportInput, + FeedbackReportResponse, FetchLike, OAuthMetadata, RunResult, @@ -198,6 +201,27 @@ export class AnyApiClient { }); } + // submitReport files a bug report or a piece of feedback. The kind selects the + // route rather than riding in the body, so a caller cannot file one as the + // other. Free: nothing is charged. + async submitReport( + kind: FeedbackKind, + input: FeedbackReportInput, + ): Promise { + const path = kind === 'bug' ? 'bug-reports' : 'feedback'; + return this.requestJson(`${this.restBaseUrl}/${path}`, { + method: 'POST', + headers: { ...this.authHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify(compactObject({ + summary: input.summary, + details: input.details, + sku: input.sku, + requestId: input.requestId, + contact: input.contact, + })), + }); + } + private authHeaders(): Record { return this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}; } diff --git a/src/commands.ts b/src/commands.ts index 317ce12..58b5c97 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -255,7 +255,9 @@ export async function setupSkillsCommand(ctx: CommandContext, options: { all?: b installed.forEach((line) => writeLine(ctx.stdout, `- ${line}`)); } -async function requireApiKey(ctx: CommandContext, global: GlobalOptions): Promise<{ apiKey: string; config: AnyApiConfig }> { +// Exported so sibling command modules resolve a key through exactly this path, +// including its offer to mint a trial key rather than dead-ending. +export async function requireApiKey(ctx: CommandContext, global: GlobalOptions): Promise<{ apiKey: string; config: AnyApiConfig }> { const auth = await resolveApiKey({ apiKey: global.apiKey, env: ctx.env, diff --git a/src/feedback.ts b/src/feedback.ts new file mode 100644 index 0000000..15b97ec --- /dev/null +++ b/src/feedback.ts @@ -0,0 +1,62 @@ +import { AnyApiClient } from './api.js'; +import { requireApiKey, type GlobalOptions } from './commands.js'; +import { CliError } from './errors.js'; +import { writeLine, type CommandContext } from './io.js'; +import type { FeedbackKind, FeedbackReportInput } from './types.js'; + +export interface ReportCliOptions { + details?: string; + sku?: string; + requestId?: string; + contact?: string; +} + +// reportBugCommand and feedbackCommand are the same submission on different +// routes. The kind is not a flag: an agent picks the command, and the gateway +// stamps the kind from the route it served. +export async function reportBugCommand( + ctx: CommandContext, + global: GlobalOptions, + summary: string, + options: ReportCliOptions, +): Promise { + await submit(ctx, global, 'bug', summary, options); +} + +export async function feedbackCommand( + ctx: CommandContext, + global: GlobalOptions, + summary: string, + options: ReportCliOptions, +): Promise { + await submit(ctx, global, 'feedback', summary, options); +} + +async function submit( + ctx: CommandContext, + global: GlobalOptions, + kind: FeedbackKind, + summary: string, + options: ReportCliOptions, +): Promise { + const trimmed = summary.trim(); + if (trimmed === '') { + throw new CliError('A summary is required. Example: anyapi report-bug "reels_search returned no items"'); + } + const auth = await requireApiKey(ctx, global); + const client = new AnyApiClient({ apiKey: auth.apiKey, fetchImpl: ctx.fetchImpl }); + const input: FeedbackReportInput = { + summary: trimmed, + details: options.details, + sku: options.sku, + requestId: options.requestId, + contact: options.contact, + }; + const report = await client.submitReport(kind, input); + writeLine(ctx.stdout, `${kind === 'bug' ? 'Bug report' : 'Feedback'} filed. Reference: ${report.id}`); + if (!options.requestId) { + // The stored run is the single most useful attachment, so say so once rather + // than leaving the next report as thin as this one. + writeLine(ctx.stdout, 'Tip: pass --request-id from the run that went wrong so we can read its stored result.'); + } +} diff --git a/src/index.ts b/src/index.ts index fd3c40a..2363005 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { type GlobalOptions, } from "./commands.js"; import { connectCommand } from "./connect.js"; +import { feedbackCommand, reportBugCommand } from "./feedback.js"; import { CliError } from "./errors.js"; import { defaultContext } from "./io.js"; import { normalizeRunCLIOptions } from "./run.js"; @@ -30,7 +31,7 @@ program "--api-key ", "AnyAPI API key. Overrides ANYAPI_API_KEY and local config.", ) - .version("0.5.0"); + .version("0.8.0"); program .command("signup") @@ -156,6 +157,37 @@ program .description("Print the remaining USD balance.") .action(() => run(() => balanceCommand(ctx, globalOptions()))); +program + .command("report-bug") + .description( + "Tell AnyAPI something is broken: a wrong or empty result, a misleading error, a price that looks off. Free.", + ) + .argument("", "One line saying what went wrong.") + .option("--details
", "What you expected, what you got, what you tried.") + .option("--sku ", "The SKU this is about, e.g. instagram.reels_search.") + .option( + "--request-id ", + "The requestId or resultId from the run that went wrong. The most useful thing you can attach.", + ) + .option("--contact ", "Email to reply to. Supply one if you are on a trial key.") + .action((summary, options) => + run(() => reportBugCommand(ctx, globalOptions(), summary, options)), + ); + +program + .command("feedback") + .description( + "Tell AnyAPI something that is not a defect: a missing API, a missing field, confusing docs. Free.", + ) + .argument("", "One line of feedback.") + .option("--details
", "What you were trying to accomplish.") + .option("--sku ", "The SKU this is about, if any.") + .option("--request-id ", "A related requestId or resultId, if any.") + .option("--contact ", "Email to reply to. Supply one if you are on a trial key.") + .action((summary, options) => + run(() => feedbackCommand(ctx, globalOptions(), summary, options)), + ); + program .command("connect") .description( diff --git a/src/types.ts b/src/types.ts index fb106ab..5d5a7d3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,27 @@ export interface SignupResponse { notice?: string; } +// FeedbackKind picks the route a report is filed on. The CLI never sends it in +// the body: the gateway stamps the kind from the endpoint it served. +export type FeedbackKind = 'bug' | 'feedback'; + +export interface FeedbackReportInput { + summary: string; + details?: string; + sku?: string; + requestId?: string; + contact?: string; +} + +export interface FeedbackReportResponse { + id: string; + kind: FeedbackKind; + summary: string; + sku?: string; + requestId?: string; + createdAt: string; +} + export interface OAuthMetadata { authorization_endpoint?: string; device_authorization_endpoint?: string;