Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ anyapi run reddit.search --input '{"query":"anyapi","limit":5}'
- `anyapi run <sku> [--input '<json>'] [-i file] [--idempotency-key <key>] [--jq <expr>] [--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 <expr>] [--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 <summary> [--details <text>] [--sku <sku>] [--request-id <id>] [--contact <email>]` - 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 <summary> [--details <text>] [--sku <sku>] [--request-id <id>] [--contact <email>]` - 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.

Expand Down
141 changes: 141 additions & 0 deletions __tests__/feedback.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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',
]);
});
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
19 changes: 19 additions & 0 deletions skills/anyapi-run/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <requestId from the failing run>
```

`--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.
Expand Down
24 changes: 24 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import type {
CatalogResponse,
ClientRegistrationResponse,
DeviceAuthorizationResponse,
FeedbackKind,
FeedbackReportInput,
FeedbackReportResponse,
FetchLike,
OAuthMetadata,
RunResult,
Expand Down Expand Up @@ -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<FeedbackReportResponse> {
const path = kind === 'bug' ? 'bug-reports' : 'feedback';
return this.requestJson<FeedbackReportResponse>(`${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<string, string> {
return this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {};
}
Expand Down
4 changes: 3 additions & 1 deletion src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
62 changes: 62 additions & 0 deletions src/feedback.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await submit(ctx, global, 'bug', summary, options);
}

export async function feedbackCommand(
ctx: CommandContext,
global: GlobalOptions,
summary: string,
options: ReportCliOptions,
): Promise<void> {
await submit(ctx, global, 'feedback', summary, options);
}

async function submit(
ctx: CommandContext,
global: GlobalOptions,
kind: FeedbackKind,
summary: string,
options: ReportCliOptions,
): Promise<void> {
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.');
}
}
34 changes: 33 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -30,7 +31,7 @@ program
"--api-key <apiKey>",
"AnyAPI API key. Overrides ANYAPI_API_KEY and local config.",
)
.version("0.5.0");
.version("0.8.0");

program
.command("signup")
Expand Down Expand Up @@ -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("<summary>", "One line saying what went wrong.")
.option("--details <details>", "What you expected, what you got, what you tried.")
.option("--sku <sku>", "The SKU this is about, e.g. instagram.reels_search.")
.option(
"--request-id <requestId>",
"The requestId or resultId from the run that went wrong. The most useful thing you can attach.",
)
.option("--contact <email>", "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("<summary>", "One line of feedback.")
.option("--details <details>", "What you were trying to accomplish.")
.option("--sku <sku>", "The SKU this is about, if any.")
.option("--request-id <requestId>", "A related requestId or resultId, if any.")
.option("--contact <email>", "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(
Expand Down
21 changes: 21 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading