Skip to content

Commit ee2b9a0

Browse files
kev1nclaude
andauthored
Let an agent report a bug without leaving the shell (#13)
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) <noreply@anthropic.com>
1 parent b2e9651 commit ee2b9a0

9 files changed

Lines changed: 306 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ anyapi run reddit.search --input '{"query":"anyapi","limit":5}'
3939
- `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.
4040
- `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.
4141
- `anyapi balance` - print the remaining USD balance.
42+
- `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.
43+
- `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.
4244
- `anyapi init [--all] [--yes]` - mint a trial key if none exists, install bundled agent skills, and show or apply MCP setup snippets.
4345
- `anyapi setup skills` - install only the bundled skills.
4446

‎__tests__/feedback.test.ts‎

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { PassThrough } from 'node:stream';
3+
import { AnyApiClient } from '../src/api.js';
4+
import { feedbackCommand, reportBugCommand } from '../src/feedback.js';
5+
import { CliError } from '../src/errors.js';
6+
import type { CommandContext } from '../src/io.js';
7+
import type { FetchLike } from '../src/types.js';
8+
9+
interface Captured {
10+
url: string;
11+
method?: string;
12+
body: Record<string, unknown>;
13+
authorization?: string;
14+
}
15+
16+
function capturingFetch(captured: Captured[], status = 201): FetchLike {
17+
return async (input, init) => {
18+
const headers = new Headers(init?.headers as HeadersInit);
19+
captured.push({
20+
url: String(input),
21+
method: init?.method,
22+
body: init?.body ? JSON.parse(String(init.body)) : {},
23+
authorization: headers.get('authorization') ?? undefined,
24+
});
25+
if (status >= 400) {
26+
return Response.json({ error: 'this account has reached its stored report limit', code: 'report_limit_reached' }, { status });
27+
}
28+
return Response.json({
29+
id: 'report-1', kind: 'bug', summary: 'x', createdAt: '2026-08-15T22:00:00Z',
30+
}, { status });
31+
};
32+
}
33+
34+
function context(fetchImpl: FetchLike): CommandContext {
35+
return {
36+
cwd: '/tmp',
37+
homeDir: '/tmp/anyapi-feedback-home',
38+
env: { ANYAPI_API_KEY: 'aa_live_test' },
39+
stdin: new PassThrough(),
40+
stdout: new PassThrough(),
41+
stderr: new PassThrough(),
42+
fetchImpl,
43+
};
44+
}
45+
46+
function output(ctx: CommandContext): string {
47+
return String(ctx.stdout.read() ?? '');
48+
}
49+
50+
describe('report commands', () => {
51+
it('files a bug on /bug-reports and feedback on /feedback', async () => {
52+
const captured: Captured[] = [];
53+
const ctx = context(capturingFetch(captured));
54+
await reportBugCommand(ctx, {}, 'reels_search returned no items', {});
55+
await feedbackCommand(ctx, {}, 'no SKU for Substack archives', {});
56+
expect(captured.map((c) => c.url)).toEqual([
57+
'https://api.getanyapi.com/v1/bug-reports',
58+
'https://api.getanyapi.com/v1/feedback',
59+
]);
60+
expect(captured.every((c) => c.method === 'POST')).toBe(true);
61+
});
62+
63+
// The route carries the kind. A body field would let a caller contradict it.
64+
it('never sends a kind in the body', async () => {
65+
const captured: Captured[] = [];
66+
await reportBugCommand(context(capturingFetch(captured)), {}, 'broken', {});
67+
expect(captured[0].body).not.toHaveProperty('kind');
68+
expect(captured[0].body).not.toHaveProperty('surface');
69+
});
70+
71+
it('sends every supplied option and omits the ones left out', async () => {
72+
const captured: Captured[] = [];
73+
await reportBugCommand(context(capturingFetch(captured)), {}, ' padded summary ', {
74+
details: 'ran twice',
75+
sku: 'instagram.reels_search',
76+
requestId: 'req_1',
77+
contact: 'agent@example.test',
78+
});
79+
expect(captured[0].body).toEqual({
80+
summary: 'padded summary',
81+
details: 'ran twice',
82+
sku: 'instagram.reels_search',
83+
requestId: 'req_1',
84+
contact: 'agent@example.test',
85+
});
86+
87+
const bare: Captured[] = [];
88+
await reportBugCommand(context(capturingFetch(bare)), {}, 'broken', {});
89+
expect(bare[0].body).toEqual({ summary: 'broken' });
90+
});
91+
92+
it('authenticates with the resolved key', async () => {
93+
const captured: Captured[] = [];
94+
await reportBugCommand(context(capturingFetch(captured)), {}, 'broken', {});
95+
expect(captured[0].authorization).toBe('Bearer aa_live_test');
96+
});
97+
98+
it('rejects a blank summary before spending a request', async () => {
99+
const captured: Captured[] = [];
100+
await expect(
101+
reportBugCommand(context(capturingFetch(captured)), {}, ' ', {}),
102+
).rejects.toBeInstanceOf(CliError);
103+
expect(captured).toHaveLength(0);
104+
});
105+
106+
it('prints the reference and nudges toward a request id only when none was given', async () => {
107+
const withId = context(capturingFetch([]));
108+
await reportBugCommand(withId, {}, 'broken', { requestId: 'req_1' });
109+
const withIdOut = output(withId);
110+
expect(withIdOut).toContain('report-1');
111+
expect(withIdOut).not.toContain('Tip:');
112+
113+
const withoutId = context(capturingFetch([]));
114+
await reportBugCommand(withoutId, {}, 'broken', {});
115+
expect(output(withoutId)).toContain('Tip:');
116+
});
117+
118+
it('surfaces the gateway limit message rather than a bare status', async () => {
119+
const ctx = context(capturingFetch([], 409));
120+
await expect(reportBugCommand(ctx, {}, 'broken', {})).rejects.toThrow(/stored report limit/);
121+
});
122+
});
123+
124+
describe('AnyApiClient.submitReport', () => {
125+
it('targets the route matching the kind', async () => {
126+
const seen: string[] = [];
127+
const client = new AnyApiClient({
128+
apiKey: 'aa_live_test',
129+
fetchImpl: async (input) => {
130+
seen.push(String(input));
131+
return Response.json({ id: 'r', kind: 'bug', summary: 's', createdAt: 'now' }, { status: 201 });
132+
},
133+
});
134+
await client.submitReport('bug', { summary: 's' });
135+
await client.submitReport('feedback', { summary: 's' });
136+
expect(seen).toEqual([
137+
'https://api.getanyapi.com/v1/bug-reports',
138+
'https://api.getanyapi.com/v1/feedback',
139+
]);
140+
});
141+
});

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "anyapi-cli",
3-
"version": "0.7.0",
3+
"version": "0.8.0",
44
"description": "Official CLI for AnyAPI, a unified marketplace for scraping and data APIs.",
55
"type": "module",
66
"bin": {

‎skills/anyapi-run/SKILL.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,25 @@ anyapi connect
100100
that one URL to your human; they approve continued spend and set a limit, then
101101
the same commands keep working with no key to swap by hand.
102102

103+
## When a result looks wrong
104+
105+
A run that succeeds but returns wrong, empty, or malformed data for input you
106+
believe is valid is a defect worth reporting. So is a misleading error, or a
107+
price that does not match the catalog. Filing is free and never charged:
108+
109+
```sh
110+
anyapi report-bug "reels_search returned no items for a query with results" \
111+
--sku instagram.reels_search --request-id <requestId from the failing run>
112+
```
113+
114+
`--request-id` is the single most useful thing to attach: it reaches the stored
115+
run and its upstream error body, so you never need to paste the payload. Use
116+
`anyapi feedback` instead for what is not a defect, such as an API you could not
117+
find in the catalog or a field missing from a result.
118+
119+
File it and carry on with the best alternative you have. Do not stop your
120+
human's task to ask permission first.
121+
103122
## Tips
104123

105124
- Prefer `--jq` for anything non-trivial; use `--fields`/`--max-items` for quick trims.

‎src/api.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import type {
66
CatalogResponse,
77
ClientRegistrationResponse,
88
DeviceAuthorizationResponse,
9+
FeedbackKind,
10+
FeedbackReportInput,
11+
FeedbackReportResponse,
912
FetchLike,
1013
OAuthMetadata,
1114
RunResult,
@@ -198,6 +201,27 @@ export class AnyApiClient {
198201
});
199202
}
200203

204+
// submitReport files a bug report or a piece of feedback. The kind selects the
205+
// route rather than riding in the body, so a caller cannot file one as the
206+
// other. Free: nothing is charged.
207+
async submitReport(
208+
kind: FeedbackKind,
209+
input: FeedbackReportInput,
210+
): Promise<FeedbackReportResponse> {
211+
const path = kind === 'bug' ? 'bug-reports' : 'feedback';
212+
return this.requestJson<FeedbackReportResponse>(`${this.restBaseUrl}/${path}`, {
213+
method: 'POST',
214+
headers: { ...this.authHeaders(), 'Content-Type': 'application/json' },
215+
body: JSON.stringify(compactObject({
216+
summary: input.summary,
217+
details: input.details,
218+
sku: input.sku,
219+
requestId: input.requestId,
220+
contact: input.contact,
221+
})),
222+
});
223+
}
224+
201225
private authHeaders(): Record<string, string> {
202226
return this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {};
203227
}

‎src/commands.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,9 @@ export async function setupSkillsCommand(ctx: CommandContext, options: { all?: b
255255
installed.forEach((line) => writeLine(ctx.stdout, `- ${line}`));
256256
}
257257

258-
async function requireApiKey(ctx: CommandContext, global: GlobalOptions): Promise<{ apiKey: string; config: AnyApiConfig }> {
258+
// Exported so sibling command modules resolve a key through exactly this path,
259+
// including its offer to mint a trial key rather than dead-ending.
260+
export async function requireApiKey(ctx: CommandContext, global: GlobalOptions): Promise<{ apiKey: string; config: AnyApiConfig }> {
259261
const auth = await resolveApiKey({
260262
apiKey: global.apiKey,
261263
env: ctx.env,

‎src/feedback.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { AnyApiClient } from './api.js';
2+
import { requireApiKey, type GlobalOptions } from './commands.js';
3+
import { CliError } from './errors.js';
4+
import { writeLine, type CommandContext } from './io.js';
5+
import type { FeedbackKind, FeedbackReportInput } from './types.js';
6+
7+
export interface ReportCliOptions {
8+
details?: string;
9+
sku?: string;
10+
requestId?: string;
11+
contact?: string;
12+
}
13+
14+
// reportBugCommand and feedbackCommand are the same submission on different
15+
// routes. The kind is not a flag: an agent picks the command, and the gateway
16+
// stamps the kind from the route it served.
17+
export async function reportBugCommand(
18+
ctx: CommandContext,
19+
global: GlobalOptions,
20+
summary: string,
21+
options: ReportCliOptions,
22+
): Promise<void> {
23+
await submit(ctx, global, 'bug', summary, options);
24+
}
25+
26+
export async function feedbackCommand(
27+
ctx: CommandContext,
28+
global: GlobalOptions,
29+
summary: string,
30+
options: ReportCliOptions,
31+
): Promise<void> {
32+
await submit(ctx, global, 'feedback', summary, options);
33+
}
34+
35+
async function submit(
36+
ctx: CommandContext,
37+
global: GlobalOptions,
38+
kind: FeedbackKind,
39+
summary: string,
40+
options: ReportCliOptions,
41+
): Promise<void> {
42+
const trimmed = summary.trim();
43+
if (trimmed === '') {
44+
throw new CliError('A summary is required. Example: anyapi report-bug "reels_search returned no items"');
45+
}
46+
const auth = await requireApiKey(ctx, global);
47+
const client = new AnyApiClient({ apiKey: auth.apiKey, fetchImpl: ctx.fetchImpl });
48+
const input: FeedbackReportInput = {
49+
summary: trimmed,
50+
details: options.details,
51+
sku: options.sku,
52+
requestId: options.requestId,
53+
contact: options.contact,
54+
};
55+
const report = await client.submitReport(kind, input);
56+
writeLine(ctx.stdout, `${kind === 'bug' ? 'Bug report' : 'Feedback'} filed. Reference: ${report.id}`);
57+
if (!options.requestId) {
58+
// The stored run is the single most useful attachment, so say so once rather
59+
// than leaving the next report as thin as this one.
60+
writeLine(ctx.stdout, 'Tip: pass --request-id from the run that went wrong so we can read its stored result.');
61+
}
62+
}

‎src/index.ts‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
type GlobalOptions,
1717
} from "./commands.js";
1818
import { connectCommand } from "./connect.js";
19+
import { feedbackCommand, reportBugCommand } from "./feedback.js";
1920
import { CliError } from "./errors.js";
2021
import { defaultContext } from "./io.js";
2122
import { normalizeRunCLIOptions } from "./run.js";
@@ -30,7 +31,7 @@ program
3031
"--api-key <apiKey>",
3132
"AnyAPI API key. Overrides ANYAPI_API_KEY and local config.",
3233
)
33-
.version("0.5.0");
34+
.version("0.8.0");
3435

3536
program
3637
.command("signup")
@@ -156,6 +157,37 @@ program
156157
.description("Print the remaining USD balance.")
157158
.action(() => run(() => balanceCommand(ctx, globalOptions())));
158159

160+
program
161+
.command("report-bug")
162+
.description(
163+
"Tell AnyAPI something is broken: a wrong or empty result, a misleading error, a price that looks off. Free.",
164+
)
165+
.argument("<summary>", "One line saying what went wrong.")
166+
.option("--details <details>", "What you expected, what you got, what you tried.")
167+
.option("--sku <sku>", "The SKU this is about, e.g. instagram.reels_search.")
168+
.option(
169+
"--request-id <requestId>",
170+
"The requestId or resultId from the run that went wrong. The most useful thing you can attach.",
171+
)
172+
.option("--contact <email>", "Email to reply to. Supply one if you are on a trial key.")
173+
.action((summary, options) =>
174+
run(() => reportBugCommand(ctx, globalOptions(), summary, options)),
175+
);
176+
177+
program
178+
.command("feedback")
179+
.description(
180+
"Tell AnyAPI something that is not a defect: a missing API, a missing field, confusing docs. Free.",
181+
)
182+
.argument("<summary>", "One line of feedback.")
183+
.option("--details <details>", "What you were trying to accomplish.")
184+
.option("--sku <sku>", "The SKU this is about, if any.")
185+
.option("--request-id <requestId>", "A related requestId or resultId, if any.")
186+
.option("--contact <email>", "Email to reply to. Supply one if you are on a trial key.")
187+
.action((summary, options) =>
188+
run(() => feedbackCommand(ctx, globalOptions(), summary, options)),
189+
);
190+
159191
program
160192
.command("connect")
161193
.description(

‎src/types.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,27 @@ export interface SignupResponse {
2929
notice?: string;
3030
}
3131

32+
// FeedbackKind picks the route a report is filed on. The CLI never sends it in
33+
// the body: the gateway stamps the kind from the endpoint it served.
34+
export type FeedbackKind = 'bug' | 'feedback';
35+
36+
export interface FeedbackReportInput {
37+
summary: string;
38+
details?: string;
39+
sku?: string;
40+
requestId?: string;
41+
contact?: string;
42+
}
43+
44+
export interface FeedbackReportResponse {
45+
id: string;
46+
kind: FeedbackKind;
47+
summary: string;
48+
sku?: string;
49+
requestId?: string;
50+
createdAt: string;
51+
}
52+
3253
export interface OAuthMetadata {
3354
authorization_endpoint?: string;
3455
device_authorization_endpoint?: string;

0 commit comments

Comments
 (0)