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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 2.1.0 - 2026-10-26

Add `s1 feedback` to report wrong results, errors and missing capabilities to the Search1API team, built on the public Agentback SDK. Supports `--category`, `--intent`, `--request-id`, `--model`, `--json` and `-` for stdin. When logged in, the report carries the signed account.

## 2.0.0 - 2026-10-12

Fix repeated browser OAuth login on Node 18 by closing callback connections before the temporary server port is reused. Add PR CI for Node 18 and 22.
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,26 @@ Check your remaining API credits.
s1 balance
```

### feedback

Report a wrong result, an error you could not resolve, or a missing capability to the Search1API team. Agents can use it too. When you are logged in, the report is linked to your account so the team can follow up.

```bash
s1 feedback "bingcn returned no results for a Chinese query"
s1 feedback --category feature_request --intent "monitor a site" "Need a webhook when a page changes"
echo "details..." | s1 feedback -
```

| Option | Description |
|--------|-------------|
| `-c, --category <category>` | `bug`, `feature_request`, `docs`, or `other` |
| `-i, --intent <text>` | What you were trying to do |
| `-r, --request-id <id>` | Request id of the call that had the problem |
| `-m, --model <name>` | Your model name, if you are an AI agent |
| `--json` | Print the receipt as JSON |

Do not include credentials or private data in the message.

### login

Authorize in your browser with OAuth 2.1 and PKCE.
Expand Down
30 changes: 28 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "search1api-cli",
"version": "2.0.0",
"version": "2.1.0",
"description": "CLI tool for Search1API - web search, news, crawl, sitemap, and trending",
"type": "module",
"exports": {
Expand Down Expand Up @@ -53,6 +53,7 @@
"scripts"
],
"dependencies": {
"agentback": "^0.2.0",
"chalk": "^5.4.1",
"commander": "^13.1.0",
"update-notifier": "^7.3.1"
Expand Down
41 changes: 41 additions & 0 deletions src/commands/feedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Command } from "commander";
import { Agentback, type Identity } from "agentback";
import { runFeedbackCommand } from "agentback/cli";
import { fetchWithAuth } from "../auth.js";
import { AGENTBACK_PROJECT_KEY, API_BASE } from "../config.js";

// Signed so Agentback can trust which Search1API user sent the report. Without
// a working credential the report is sent without an account.
async function identify(): Promise<Identity | undefined> {
try {
const res = await fetchWithAuth(`${API_BASE}/feedback/identity`, {
method: "GET",
signal: AbortSignal.timeout(3000),
});
if (!res.ok) return undefined;
const body = (await res.json()) as Partial<Identity>;
return typeof body.account === "string" &&
typeof body.signature === "string"
? { account: body.account, signature: body.signature }
: undefined;
} catch {
return undefined;
}
}

export function registerFeedbackCommand(program: Command): void {
program
.command("feedback")
.description("Report a problem with Search1API to its developers")
// runFeedbackCommand parses its own options and prints its own help.
.helpOption(false)
.allowUnknownOption()
.argument("[args...]")
.action(async (args: string[]) => {
process.exitCode = await runFeedbackCommand(
new Agentback({ projectKey: AGENTBACK_PROJECT_KEY }),
args,
{ program: "s1", identify }
);
});
}
4 changes: 4 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ const CONFIG_FILE = join(CONFIG_DIR, "config.json");

export const API_BASE =
process.env.SEARCH1API_API_BASE ?? "https://api.search1api.com";
// Agentback project public key for `s1 feedback`. It can only submit feedback,
// so it ships with the CLI like any other public client key.
export const AGENTBACK_PROJECT_KEY =
"af_97385a84ecf95968db2403eb6ef165ddcd8ee6716c1b2ceb72f1a35a69e52b8e";
export const API_RESOURCE_METADATA =
process.env.SEARCH1API_OAUTH_RESOURCE_METADATA ??
`${API_BASE}/.well-known/oauth-protected-resource`;
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { registerConfigCommand } from "./commands/config.js";
import { registerUsageCommand } from "./commands/usage.js";
import { registerLoginCommand } from "./commands/login.js";
import { registerUpdateCommand } from "./commands/update.js";
import { registerFeedbackCommand } from "./commands/feedback.js";

const program = new Command();

Expand All @@ -57,6 +58,7 @@ registerUsageCommand(program);
registerLoginCommand(program);
registerConfigCommand(program);
registerUpdateCommand(program, { pkg, isBinary });
registerFeedbackCommand(program);

if (process.argv.length <= 2) {
console.log(`
Expand All @@ -71,6 +73,7 @@ if (process.argv.length <= 2) {
${chalk.blue("s1 learn")} ${chalk.dim("<url>")} Turn a URL into an Agent Skill
${chalk.blue("s1 trending")} ${chalk.dim("<service>")} Trending on GitHub / HackerNews
${chalk.blue("s1 balance")} Check remaining credits
${chalk.blue("s1 feedback")} ${chalk.dim('"message"')} Report a problem to the Search1API team

${chalk.bold("Setup:")}
${chalk.blue("s1 login")} Authorize with OAuth in your browser
Expand Down
66 changes: 66 additions & 0 deletions tests/feedback.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Command } from "commander";

process.env.SEARCH1API_KEY = "test-key";
process.env.SEARCH1API_API_BASE = "https://api.example.test";

const { registerFeedbackCommand } = await import("../dist/commands/feedback.js");

async function runFeedback(args, identityResponse) {
const calls = [];
const originalFetch = globalThis.fetch;
const originalWrite = process.stdout.write;
let stdout = "";
globalThis.fetch = async (input, init) => {
const url = String(input);
calls.push({ url, init });
if (url === "https://api.example.test/feedback/identity") return identityResponse();
return Response.json({ id: "fb_1", status: "new" }, { status: 201 });
};
process.stdout.write = (text) => {
stdout += text;
return true;
};
try {
const program = new Command("s1").exitOverride();
registerFeedbackCommand(program);
await program.parseAsync(["feedback", ...args], { from: "user" });
return { calls, stdout, exitCode: process.exitCode };
} finally {
globalThis.fetch = originalFetch;
process.stdout.write = originalWrite;
process.exitCode = undefined;
}
}

test("feedback is sent under the signed-in Search1API account", async () => {
const { calls, stdout, exitCode } = await runFeedback(
["-c", "bug", "--intent", "find docs", "bingcn", "returned", "nothing"],
() => Response.json({ account: "user_123", signature: "a".repeat(64) })
);

assert.equal(exitCode, 0);
assert.match(stdout, /Feedback sent \(fb_1\)/);
assert.equal(new Headers(calls[0].init.headers).get("authorization"), "Bearer test-key");
assert.match(calls[1].url, /^https:\/\/agentback\.app\/f\/af_[a-f0-9]{64}$/);
assert.deepEqual(JSON.parse(calls[1].init.body), {
message: "bingcn returned nothing",
intent: "find docs",
category: "bug",
source: "cli",
account: "user_123",
account_signature: "a".repeat(64),
});
});

test("feedback is still sent when the account lookup fails", async () => {
const { calls, exitCode } = await runFeedback(["--json", "it broke"], () =>
Response.json({ error: "Unauthorized" }, { status: 401 })
);

assert.equal(exitCode, 0);
const body = JSON.parse(calls.at(-1).init.body);
assert.equal(body.account, undefined);
assert.equal(body.source, "cli");
});
Loading