serpkin 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Serpkin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,97 @@
1
- # Temporary Holding Version
1
+ # serpkin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The SEO teammate for your coding agent. One command connects your repository, your agent and Serpkin.
4
+
5
+ ```sh
6
+ npx serpkin
7
+ ```
8
+
9
+ Requires Node 22 or newer. No dependencies.
10
+
11
+ ## What `npx serpkin` does
12
+
13
+ 1. **Signs you in.** Opens `https://serpkin.com/cli` with a code like `BCDF-GHJK`. Confirm it matches your terminal and approve. That creates an agent key named "Serpkin CLI on <your machine>". You can revoke it in Settings → Agent setup.
14
+ 2. **Detects your framework.** Next.js (app and pages router) fully, plus Astro, SvelteKit, Nuxt and Remix as best effort. In a monorepo it asks which app under `apps/*` or `packages/*` is the website.
15
+ 3. **Connects your agent.**
16
+ - **Claude Code**: offers to install the **Serpkin plugin** (see below). Alternatively it merges `serpkin` into `.mcp.json` (other servers are kept) or runs `claude mcp add`. Without a terminal it writes `.mcp.json` and prints the plugin command. Pass `--plugin` to install the plugin without asking.
17
+ - **Cursor**: merges into `.cursor/mcp.json` and writes the project rule `.cursor/rules/serpkin.mdc` with the SEO loop.
18
+ - **Codex**: appends `[mcp_servers.serpkin]` to `~/.codex/config.toml` once and adds a short "Serpkin SEO" section to the project's `AGENTS.md` (it asks first, and creates the file if it's missing).
19
+
20
+ The rule and the `AGENTS.md` section sit between `<!-- serpkin:start -->` and `<!-- serpkin:end -->`. Re-running init refreshes only that block and keeps everything else.
21
+ 4. **Registers the site** and uploads a route map (URL path → source file), so missions can name the files to change.
22
+ 5. **Sets up ownership.** Either Google Search Console (opens the browser) or a `public/serpkin-verification.txt` file. Deploy it, then run `npx serpkin verify`.
23
+
24
+ Then ask your agent: _"What should I do for SEO?"_
25
+
26
+ ## Claude Code plugin
27
+
28
+ ```sh
29
+ claude plugin marketplace add apwn/serpkin-plugin && claude plugin install serpkin@serpkin
30
+ ```
31
+
32
+ Inside a session you can run `/plugin marketplace add apwn/serpkin-plugin`, then `/plugin install serpkin@serpkin`. The plugin lives in [`plugin/serpkin`](plugin/serpkin) and includes:
33
+
34
+ - the **`serpkin-seo` skill**, which runs the loop: next mission → implement → run checks → check the preview → submit → verify the preview → verify production after you deploy. It treats page text and search data as untrusted and never deploys.
35
+ - **commands**: `/serpkin:next` gets the next mission and implements it. `/serpkin:ship <preview-url>` submits it, checks the preview and verifies. `/serpkin:check <preview-url>` runs `npx serpkin check`. `/serpkin:status` shows the site status.
36
+ - the **Serpkin MCP server**. It reads `SERPKIN_API_KEY` and, for a self-hosted or local server, `SERPKIN_SERVER` (default `https://serpkin.com`).
37
+
38
+ - **hooks** (quiet by design, only in repositories with `.serpkin.json`):
39
+ - **Session start**: one line with the next mission and "run /serpkin:next". It is fetched with a 2.5-second timeout and cached for 6 hours per site (failures for 30 minutes), and it says nothing when you are offline, signed out or the project isn't linked.
40
+ - **After a push or deploy** (`git push`, `vercel deploy`/`--prod`, `netlify deploy`, `wrangler deploy`, `fly deploy`, `firebase deploy`, `railway up`, `npm|pnpm|yarn|bun run deploy`): one reminder per session to run `/serpkin:check <preview-url>`.
41
+ - **Turn them off** with `SERPKIN_HOOKS=off` in your shell or in Claude Code's `settings.json` `env`. Keep just one with `SERPKIN_HOOKS=session` or `SERPKIN_HOOKS=deploy`.
42
+ - **Safety**: the hooks run only the plugin's own scripts (never a binary or script from the repository), never print or cache your key, and follow the same server rules as the CLI: a server named only in `.serpkin.json` is used only when it is `https://serpkin.com` or a server you already signed in to, and `SERPKIN_API_KEY` is only sent to `SERPKIN_SERVER` (or `https://serpkin.com`). The command text of a push or deploy is never echoed.
43
+
44
+ Clients without plugins get the same loop as MCP prompts: `next_mission`, `ship_mission` and `check_preview`. The Cursor rule and the Codex `AGENTS.md` section carry a lighter version of the hooks: at session start, ask Serpkin for the next mission and mention it in one line; after a push or deploy, suggest `npx serpkin check` once.
45
+
46
+ ## Patch-ready missions
47
+
48
+ `serpkin_next_mission` and `serpkin_get_mission` return an `implementation` block. It lists ordered `steps`, each with a `file` from your route map, an `action` (edit, create or run), an `intent` and, where Serpkin can generate it, a `snippet` (metadata export, `app/sitemap.ts`, `app/robots.ts`, a JSON-LD component with the required properties, a `redirects()` entry and more). It also lists `verify` commands and a `done_when` list that mirrors the acceptance checks. Snippets are templates for the agent to adapt. Page and search text only appears as clipped, escaped `const UNTRUSTED_…` strings.
49
+
50
+ ### Where the key goes
51
+
52
+ The key is **never written into your repository.** It is stored in `~/.config/serpkin/credentials.json` (mode 600, keyed by server), and project files use an environment reference (`${SERPKIN_API_KEY}` / `${env:SERPKIN_API_KEY}` / `bearer_token_env_var`). Add the printed `export SERPKIN_API_KEY=…` line to your shell profile so your agent can read it.
53
+
54
+ `.serpkin.json` (server and site id, no secrets) is written to the repo root. Commit it so CI knows which site to check.
55
+
56
+ ### Sites that aren't live yet
57
+
58
+ Serpkin can't reach `localhost`. Deploy a preview (for example a `vercel.app` URL) and run `npx serpkin init --url <preview-url>`. Foundation checks work on the preview; production verification comes once the domain is live. Run `init --url https://your-domain` then.
59
+
60
+ ## Commands
61
+
62
+ | Command | |
63
+ | ----------------------------- | ---------------------------------------------------------------------------------- |
64
+ | `serpkin` / `serpkin init` | Setup (above). Safe to re-run; the stored key and site are reused. |
65
+ | `serpkin check <preview-url>` | Compare a preview deploy with production. Exit code 1 on regressions, 2 on errors. |
66
+ | `serpkin verify` | Check ownership (verification file or DNS record). |
67
+ | `serpkin status` | Site, ownership, data level, next mission and credits. |
68
+ | `serpkin routes [--upload]` | Print the route map; `--upload` sends it. |
69
+ | `serpkin next [--oneline]` | The next mission in one line (fast, cached; `--oneline` is silent when offline). |
70
+
71
+ Options: `--url`, `--plugin`, `--server <origin>` (default `$SERPKIN_SERVER`, then `.serpkin.json`, then `https://serpkin.com`), `--agent claude,cursor,codex`, `--app apps/web`, `--ownership file|search-console|skip`, `--description`, `--audience`, `--country`, `--language`, `--login`, `--paths /,/pricing`, `--site <id>`, `--json`, `--yes`, `--no-browser`.
72
+
73
+ Without a terminal (CI) or with `--yes`, the CLI never prompts. When it needs something it can't default, it stops and names the flag to pass.
74
+
75
+ **Which server gets your key.** `.serpkin.json` is repository content, so it can't send your key anywhere new. A server named only there is used when it is `https://serpkin.com` or a server you already signed in to. Otherwise the CLI asks, and without a terminal it stops until you pass `--server` or set `SERPKIN_SERVER`. `SERPKIN_API_KEY` is only ever sent to `SERPKIN_SERVER`, or to `https://serpkin.com` when that is unset.
76
+
77
+ For a local Serpkin server: `npx serpkin --server http://127.0.0.1:3000`.
78
+
79
+ ## Checks on pull requests
80
+
81
+ `serpkin check` compares up to 25 paths (default: the homepage plus up to 10 known pages) on the preview and on production, and fails on regressions:
82
+
83
+ - HTTP errors (non-2xx, including protected previews)
84
+ - `noindex` added in the page's robots meta tag
85
+ - title, meta description or H1 removed
86
+ - canonical removed, invalid, or pointing to another site
87
+ - `robots.txt` failing or removed, `sitemap.xml` no longer reachable or valid
88
+
89
+ Problems that already exist in production are reported as warnings, not regressions.
90
+
91
+ **Preview noindex rule.** Vercel adds `X-Robots-Tag: noindex` to every non-production deployment (other hosts do similar). Header-level noindex on the preview is therefore reported once as a warning, never as a regression. A `noindex` in the page's own `<meta name="robots">` is page code and fails the check. Likewise a preview `robots.txt` that blocks everything is a warning, since many apps do that deliberately on previews.
92
+
93
+ Preview hosts must be the site's own domain or subdomains, or `*.vercel.app`, `*.netlify.app`, `*.pages.dev`.
94
+
95
+ ### GitHub Action
96
+
97
+ Copy [`templates/github-action.yml`](templates/github-action.yml) to `.github/workflows/serpkin.yml` then add a repository secret `SERPKIN_API_KEY` (create a key with `npx serpkin` or in Settings → Agent setup) and a repository variable `SERPKIN_SITE_ID` (the `site_id` from `.serpkin.json`). The workflow doesn't check out the pull request, so code or config from a PR never runs while the key is available, and it pins the CLI version. It runs on `deployment_status` events, which Vercel sends when a preview finishes deploying. It checks the deployment's `target_url` and fails the job when the preview introduces a regression. If your previews use Vercel Deployment Protection, Serpkin's requests get HTTP 401. Allow them through, or the check reports the preview as unreachable.
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { readFileSync } from "node:fs";
4
+ import { out, c, CliError } from "../lib/ui.mjs";
5
+ import { init, check, verify, status, routes } from "../lib/commands.mjs";
6
+ import { next } from "../lib/next.mjs";
7
+ import { pluginInstallCommand } from "../lib/agents.mjs";
8
+
9
+ const VERSION = JSON.parse(
10
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
11
+ ).version;
12
+ const HELP = `${c.bold("serpkin")} — the SEO teammate for your coding agent
13
+
14
+ ${c.bold("Usage")}
15
+ npx serpkin [init] Sign in, connect your agent, register the site, upload routes
16
+ npx serpkin check <url> Compare a preview deploy with production; exit 1 on regressions
17
+ npx serpkin verify Check site ownership (verification file or DNS)
18
+ npx serpkin status Site, ownership, next mission and plan
19
+ npx serpkin next [--oneline] The next mission in one line (fast, cached; silent when offline)
20
+ npx serpkin routes Print the route map (--upload to send it)
21
+
22
+ ${c.bold("Options")}
23
+ --url <url> Production URL (or a preview URL if the site isn't live yet)
24
+ --server <origin> Serpkin server (default: $SERPKIN_SERVER, .serpkin.json, https://serpkin.com)
25
+ --agent <list> claude, cursor, codex (comma-separated)
26
+ --plugin Claude Code: install the Serpkin plugin without asking
27
+ --app <dir> App directory in a monorepo, e.g. apps/web
28
+ --ownership <how> file, search-console or skip
29
+ --description, --audience, --country, --language Site details for registration
30
+ --login Sign in again even if a key is stored
31
+ --paths </a,/b> check: paths to compare (max 25; default: homepage + known pages)
32
+ --site <id> Site id (default: .serpkin.json or $SERPKIN_SITE_ID)
33
+ --upload routes: upload the route map
34
+ --json Machine-readable output
35
+ --yes, -y Accept defaults; never prompt
36
+ --no-browser Don't open a browser
37
+ --help, -h | --version, -v
38
+
39
+ Claude Code plugin (SEO skill + /serpkin:next, :ship, :check, :status):
40
+ ${pluginInstallCommand()}
41
+
42
+ Keys are stored in ~/.config/serpkin/credentials.json (or read from SERPKIN_API_KEY), never in the repo.`;
43
+
44
+ async function main(argv) {
45
+ const { values: f, positionals } = parseArgs({
46
+ args: argv,
47
+ allowPositionals: true,
48
+ options: {
49
+ url: { type: "string" },
50
+ server: { type: "string" },
51
+ agent: { type: "string" },
52
+ app: { type: "string" },
53
+ ownership: { type: "string" },
54
+ description: { type: "string" },
55
+ audience: { type: "string" },
56
+ country: { type: "string" },
57
+ language: { type: "string" },
58
+ paths: { type: "string" },
59
+ site: { type: "string" },
60
+ login: { type: "boolean" },
61
+ upload: { type: "boolean" },
62
+ oneline: { type: "boolean" },
63
+ refresh: { type: "boolean" },
64
+ plugin: { type: "boolean" },
65
+ json: { type: "boolean" },
66
+ yes: { type: "boolean", short: "y" },
67
+ "no-browser": { type: "boolean" },
68
+ help: { type: "boolean", short: "h" },
69
+ version: { type: "boolean", short: "v" },
70
+ },
71
+ });
72
+ const flags = { ...f, browser: !f["no-browser"] };
73
+ if (flags.version) return void out.line(VERSION);
74
+ const [cmd = "init", ...rest] = positionals;
75
+ if (flags.help || cmd === "help") return void out.line(HELP);
76
+ switch (cmd) {
77
+ case "init":
78
+ await init(flags);
79
+ return 0;
80
+ case "check":
81
+ return check(rest[0], flags);
82
+ case "verify":
83
+ return verify(flags);
84
+ case "status":
85
+ return status(flags);
86
+ case "routes":
87
+ return routes(flags);
88
+ case "next":
89
+ return next(flags);
90
+ default:
91
+ throw new CliError(
92
+ `Unknown command "${cmd}". Run \`npx serpkin --help\`.`,
93
+ );
94
+ }
95
+ }
96
+
97
+ main(process.argv.slice(2)).then(
98
+ (code) => process.exit(code ?? 0),
99
+ (e) => {
100
+ if (
101
+ e?.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" ||
102
+ e?.code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE"
103
+ ) {
104
+ out.fail(`${e.message} Run \`npx serpkin --help\`.`);
105
+ process.exit(2);
106
+ }
107
+ out.fail(
108
+ e instanceof CliError
109
+ ? e.message
110
+ : `Unexpected error: ${e?.message ?? e}`,
111
+ );
112
+ process.exit(e instanceof CliError ? e.exitCode : 2);
113
+ },
114
+ );
package/lib/agents.mjs ADDED
@@ -0,0 +1,192 @@
1
+ // Coding-agent detection and MCP configuration. Formats match Settings → Agent setup in the web app.
2
+ // No secret is ever written into the project: project files reference the SERPKIN_API_KEY variable.
3
+ import { readFile, stat, mkdir, appendFile } from "node:fs/promises";
4
+ import { join, delimiter } from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import { statSync } from "node:fs";
7
+ import { home, writeFileAtomic } from "./config.mjs";
8
+
9
+ const exists = (p) =>
10
+ stat(p).then(
11
+ () => true,
12
+ () => false,
13
+ );
14
+ export const AGENTS = {
15
+ claude: "Claude Code",
16
+ cursor: "Cursor",
17
+ codex: "Codex",
18
+ };
19
+
20
+ export function onPath(cmd, env = process.env) {
21
+ const exts =
22
+ process.platform === "win32"
23
+ ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")
24
+ : [""];
25
+ for (const dir of (env.PATH ?? "").split(delimiter).filter(Boolean))
26
+ for (const ext of exts)
27
+ if (statSync(join(dir, cmd + ext), { throwIfNoEntry: false })?.isFile())
28
+ return true;
29
+ return false;
30
+ }
31
+
32
+ /** Which agents look present for this project/machine. */
33
+ export async function detectAgents(cwd, env = process.env) {
34
+ const found = [];
35
+ if ((await exists(join(cwd, ".mcp.json"))) || onPath("claude", env))
36
+ found.push("claude");
37
+ if (await exists(join(cwd, ".cursor"))) found.push("cursor");
38
+ if (await exists(join(home(env), ".codex"))) found.push("codex");
39
+ return found;
40
+ }
41
+
42
+ export const claudeEntry = (mcpUrl) => ({
43
+ type: "http",
44
+ url: mcpUrl,
45
+ headers: { Authorization: "Bearer ${SERPKIN_API_KEY}" },
46
+ });
47
+ export const cursorEntry = (mcpUrl) => ({
48
+ url: mcpUrl,
49
+ headers: { Authorization: "Bearer ${env:SERPKIN_API_KEY}" },
50
+ });
51
+ export const codexBlock = (mcpUrl) =>
52
+ `[mcp_servers.serpkin]\nurl = "${mcpUrl}"\nbearer_token_env_var = "SERPKIN_API_KEY"\n`;
53
+
54
+ /**
55
+ * Merge `mcpServers.serpkin` into a JSON config file, keeping every other server and key.
56
+ * Returns "created" | "updated" | "unchanged"; refuses (throws) on a file it cannot parse.
57
+ */
58
+ export async function mergeMcpJson(path, entry) {
59
+ let raw;
60
+ try {
61
+ raw = await readFile(path, "utf8");
62
+ } catch (e) {
63
+ if (e.code !== "ENOENT") throw e;
64
+ }
65
+ let config = {};
66
+ if (raw !== undefined && raw.trim()) {
67
+ try {
68
+ config = JSON.parse(raw);
69
+ } catch {
70
+ const err = new Error(
71
+ `${path} isn't valid JSON, so it was left untouched.`,
72
+ );
73
+ err.code = "INVALID_JSON";
74
+ throw err;
75
+ }
76
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
77
+ const err = new Error(
78
+ `${path} isn't a JSON object, so it was left untouched.`,
79
+ );
80
+ err.code = "INVALID_JSON";
81
+ throw err;
82
+ }
83
+ }
84
+ const servers =
85
+ config.mcpServers && typeof config.mcpServers === "object"
86
+ ? config.mcpServers
87
+ : {};
88
+ if (JSON.stringify(servers.serpkin) === JSON.stringify(entry))
89
+ return "unchanged";
90
+ const status =
91
+ raw === undefined ? "created" : servers.serpkin ? "updated" : "added";
92
+ config.mcpServers = { ...servers, serpkin: entry };
93
+ await writeFileAtomic(path, JSON.stringify(config, null, 2) + "\n");
94
+ return status;
95
+ }
96
+
97
+ /** Append the Codex block once. An existing [mcp_servers.serpkin] table is never rewritten. */
98
+ export async function appendCodexConfig(path, mcpUrl) {
99
+ let raw = "";
100
+ try {
101
+ raw = await readFile(path, "utf8");
102
+ } catch (e) {
103
+ if (e.code !== "ENOENT") throw e;
104
+ }
105
+ if (/^\s*\[mcp_servers\.serpkin\]\s*$/m.test(raw)) {
106
+ const url = raw.match(
107
+ /\[mcp_servers\.serpkin\][^[]*?url\s*=\s*"([^"]*)"/,
108
+ )?.[1];
109
+ return url && url !== mcpUrl ? "different" : "unchanged";
110
+ }
111
+ await mkdir(join(path, ".."), { recursive: true });
112
+ const sep = raw && !raw.endsWith("\n") ? "\n\n" : raw ? "\n" : "";
113
+ await appendFile(path, sep + codexBlock(mcpUrl));
114
+ return raw ? "added" : "created";
115
+ }
116
+
117
+ export const codexConfigPath = (env = process.env) =>
118
+ join(home(env), ".codex", "config.toml");
119
+
120
+ /** `claude mcp add` stores the key in the user's local Claude config, never in the repo. */
121
+ export function claudeMcpAdd(mcpUrl, apiKey) {
122
+ const r = spawnSync(
123
+ "claude",
124
+ [
125
+ "mcp",
126
+ "add",
127
+ "--transport",
128
+ "http",
129
+ "serpkin",
130
+ mcpUrl,
131
+ "--header",
132
+ `Authorization: Bearer ${apiKey}`,
133
+ ],
134
+ { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
135
+ );
136
+ const text = `${r.stdout ?? ""}${r.stderr ?? ""}`;
137
+ if (r.status === 0) return { ok: true };
138
+ return {
139
+ ok: false,
140
+ exists: /already exists/i.test(text),
141
+ message: text.trim().split("\n")[0] || "claude mcp add failed",
142
+ };
143
+ }
144
+
145
+ /* Claude Code plugin (skills, /serpkin:* commands and the MCP server in one install). */
146
+ export const PLUGIN_ID = "serpkin@serpkin";
147
+ /**
148
+ * Public GitHub repository that hosts .claude-plugin/marketplace.json. This is the one place the
149
+ * name lives: the --help text and every printed install command read it from here. The repository
150
+ * is generated by cli/scripts/export-plugin-repo.mjs. Override for forks or local testing.
151
+ */
152
+ export const PLUGIN_MARKETPLACE_REPO = "apwn/serpkin-plugin";
153
+ export const pluginMarketplace = (env = process.env) =>
154
+ env.SERPKIN_PLUGIN_MARKETPLACE || PLUGIN_MARKETPLACE_REPO;
155
+ export const pluginInstallCommand = (env = process.env) =>
156
+ `claude plugin marketplace add ${pluginMarketplace(env)} && claude plugin install ${PLUGIN_ID}`;
157
+ export const pluginSessionCommands = (env = process.env) => [
158
+ `/plugin marketplace add ${pluginMarketplace(env)}`,
159
+ `/plugin install ${PLUGIN_ID}`,
160
+ ];
161
+
162
+ /**
163
+ * Run `claude plugin marketplace add` then `claude plugin install` (user scope).
164
+ * `run` is injectable for tests. Returns {ok, message}.
165
+ */
166
+ export function installClaudePlugin(
167
+ env = process.env,
168
+ run = (args) =>
169
+ spawnSync("claude", args, {
170
+ encoding: "utf8",
171
+ stdio: ["ignore", "pipe", "pipe"],
172
+ env,
173
+ }),
174
+ ) {
175
+ const steps = [
176
+ ["plugin", "marketplace", "add", pluginMarketplace(env)],
177
+ ["plugin", "install", PLUGIN_ID],
178
+ ];
179
+ for (const args of steps) {
180
+ const r = run(args);
181
+ const text = `${r.stdout ?? ""}${r.stderr ?? ""}`;
182
+ // An already-added marketplace or installed plugin is fine.
183
+ if (r.status !== 0 && !/already (added|installed|exists)/i.test(text))
184
+ return {
185
+ ok: false,
186
+ message:
187
+ text.trim().split("\n").filter(Boolean).pop() ||
188
+ `claude ${args.join(" ")} failed`,
189
+ };
190
+ }
191
+ return { ok: true };
192
+ }
package/lib/api.mjs ADDED
@@ -0,0 +1,147 @@
1
+ // HTTP client for the Serpkin server. `fetchImpl` is injectable so tests can route to handlers directly.
2
+ import { CliError } from "./ui.mjs";
3
+
4
+ export class ApiError extends CliError {
5
+ constructor(message, status, code) {
6
+ super(message, 2);
7
+ this.status = status;
8
+ this.code = code;
9
+ }
10
+ }
11
+
12
+ export function createClient({
13
+ server,
14
+ apiKey = undefined,
15
+ fetchImpl = globalThis.fetch,
16
+ userAgent = "serpkin-cli",
17
+ }) {
18
+ async function request(
19
+ method,
20
+ path,
21
+ body,
22
+ { auth = true, raw = false } = {},
23
+ ) {
24
+ const headers = { accept: "application/json", "user-agent": userAgent };
25
+ if (body !== undefined) headers["content-type"] = "application/json";
26
+ if (auth) {
27
+ if (!apiKey)
28
+ throw new CliError(
29
+ "Not signed in. Run `npx serpkin` (or set SERPKIN_API_KEY).",
30
+ );
31
+ headers.authorization = `Bearer ${apiKey}`;
32
+ }
33
+ let r;
34
+ try {
35
+ r = await fetchImpl(server + path, {
36
+ method,
37
+ headers,
38
+ body: body === undefined ? undefined : JSON.stringify(body),
39
+ signal: AbortSignal.timeout(150_000),
40
+ });
41
+ } catch (e) {
42
+ throw new CliError(
43
+ `Couldn't reach ${server} (${e?.cause?.code ?? e?.name ?? "network error"}).`,
44
+ );
45
+ }
46
+ const text = await r.text();
47
+ let data;
48
+ try {
49
+ data = text ? JSON.parse(text) : {};
50
+ } catch {
51
+ data = { error: { message: text.slice(0, 200) } };
52
+ }
53
+ if (raw) return { status: r.status, data };
54
+ if (!r.ok) {
55
+ const err = data?.error;
56
+ const message =
57
+ (typeof err === "object"
58
+ ? err?.message
59
+ : (data?.error_description ?? err)) ??
60
+ `Request failed with HTTP ${r.status}.`;
61
+ if (r.status === 401)
62
+ throw new ApiError(
63
+ "Your Serpkin key is invalid or revoked. Run `npx serpkin init --login` to sign in again.",
64
+ 401,
65
+ "UNAUTHORIZED",
66
+ );
67
+ throw new ApiError(
68
+ message,
69
+ r.status,
70
+ typeof err === "object" ? err?.code : err,
71
+ );
72
+ }
73
+ return data;
74
+ }
75
+ const api = {
76
+ server,
77
+ get: (path, o) => request("GET", path, undefined, o),
78
+ post: (path, body, o) => request("POST", path, body, o),
79
+ /** REST tool route: the server fills project_id from the key. */
80
+ async tool(name, input = {}) {
81
+ const r = await request("POST", "/api/tool", { tool: name, input });
82
+ if (r?.status === "failed")
83
+ throw new ApiError(
84
+ r.error?.message ?? `${name} failed.`,
85
+ 400,
86
+ r.error?.code,
87
+ );
88
+ return r;
89
+ },
90
+ };
91
+ return api;
92
+ }
93
+
94
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
95
+
96
+ /**
97
+ * Device-code sign-in. `onCode` shows the code/URL; `wait` is injectable for tests.
98
+ * Resolves with {api_key, key_prefix, project_id, origin, mcp_url}.
99
+ */
100
+ export async function deviceLogin(
101
+ api,
102
+ { clientName, hostname, onCode, wait = sleep },
103
+ ) {
104
+ const d = await api.post(
105
+ "/api/cli/device",
106
+ { client_name: clientName, hostname },
107
+ { auth: false },
108
+ );
109
+ await onCode?.(d);
110
+ let interval = Number(d.interval) || 5;
111
+ const deadline = Date.now() + (Number(d.expires_in) || 600) * 1000;
112
+ while (Date.now() < deadline) {
113
+ await wait(interval * 1000);
114
+ const { status, data } = await api.post(
115
+ "/api/cli/token",
116
+ { device_code: d.device_code },
117
+ { auth: false, raw: true },
118
+ );
119
+ if (status === 200 && data.api_key) return data;
120
+ switch (data?.error) {
121
+ case "authorization_pending":
122
+ continue;
123
+ case "slow_down":
124
+ interval = Number(data.interval) || interval + 5;
125
+ continue;
126
+ case "access_denied":
127
+ throw new CliError(
128
+ "Sign-in was denied in the browser. Nothing was changed.",
129
+ );
130
+ case "expired_token":
131
+ throw new CliError(
132
+ "The sign-in code expired. Run `npx serpkin` again.",
133
+ );
134
+ default:
135
+ if (status === 429) {
136
+ interval += 5;
137
+ continue;
138
+ }
139
+ throw new CliError(
140
+ data?.error_description ??
141
+ data?.error?.message ??
142
+ `Sign-in failed (HTTP ${status}).`,
143
+ );
144
+ }
145
+ }
146
+ throw new CliError("The sign-in code expired. Run `npx serpkin` again.");
147
+ }