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;