serpkin 0.0.0-stage → 0.2.1

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,139 @@
1
- # Temporary Holding Version
1
+ # serpkin
2
+
3
+ **The SEO teammate for Claude Code, Codex and Cursor.** Serpkin reads your site and your real search data, picks the one SEO fix worth doing next, and hands it to your coding agent with the files to change. After you ship, it checks the preview and measures whether the change helped.
4
+
5
+ ```sh
6
+ npx serpkin
7
+ ```
8
+
9
+ [Website](https://serpkin.com) · [Guides](https://serpkin.com/guides) · [Free SEO checker](https://serpkin.com/tools/seo-check) · [Claude Code](https://serpkin.com/integrations/claude-code) · [Cursor](https://serpkin.com/integrations/cursor) · [Codex](https://serpkin.com/integrations/codex)
10
+
11
+ ## Quick start
12
+
13
+ 1. In your website's repository, run `npx serpkin`. It signs you in, connects your agent and registers the site (about a minute).
14
+ 2. Ask your agent: _"What should I do for SEO?"_
15
+ 3. Your agent implements the mission. Before you merge, run `npx serpkin check <preview-url>` to catch SEO regressions.
16
+
17
+ Requires Node 22 or newer. No dependencies. There is a free plan (one site); see [pricing](https://serpkin.com/#pricing).
18
+
19
+ ## What you get
20
+
21
+ - **One mission at a time**, chosen from a crawl of your site and, once connected, your Google Search Console data. Not a list of 200 warnings.
22
+ - **Patch-ready instructions**: the files to edit (from your route map), code snippets for metadata, sitemaps, redirects and structured data, and a definition of done.
23
+ - **A preview check** that fails on regressions such as a new `noindex`, a removed title or canonical, or a broken sitemap.
24
+ - **Results**: Serpkin verifies the fix on production and reports what changed in search afterwards.
25
+
26
+ | Agent | How it connects |
27
+ | ----------- | -------------------------------------------------------------------- |
28
+ | Claude Code | Plugin with a skill, slash commands and hooks, or a plain MCP server |
29
+ | Cursor | `.cursor/mcp.json` plus a project rule |
30
+ | Codex | `~/.codex/config.toml` plus a section in `AGENTS.md` |
31
+ | Others | Any MCP client: `https://serpkin.com/api/mcp` with your agent key |
32
+
33
+ ## What `npx serpkin` does
34
+
35
+ 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.
36
+ 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.
37
+ 3. **Connects your agent.**
38
+ - **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.
39
+ - **Cursor**: merges into `.cursor/mcp.json` and writes the project rule `.cursor/rules/serpkin.mdc` with the SEO loop.
40
+ - **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).
41
+
42
+ 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.
43
+ 4. **Registers the site** and uploads a route map (URL path → source file), so missions can name the files to change.
44
+ 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`.
45
+
46
+ Then ask your agent: _"What should I do for SEO?"_
47
+
48
+ ## Claude Code plugin
49
+
50
+ ```sh
51
+ claude plugin marketplace add apwn/serpkin-plugin && claude plugin install serpkin@serpkin
52
+ ```
53
+
54
+ 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:
55
+
56
+ - 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.
57
+ - **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.
58
+ - the **Serpkin MCP server**. It reads `SERPKIN_API_KEY` and, for a self-hosted or local server, `SERPKIN_SERVER` (default `https://serpkin.com`).
59
+
60
+ - **hooks** (quiet by design, only in repositories with `.serpkin.json`):
61
+ - **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.
62
+ - **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>`.
63
+ - **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`.
64
+ - **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.
65
+
66
+ 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.
67
+
68
+ ## Patch-ready missions
69
+
70
+ `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.
71
+
72
+ ### Where the key goes
73
+
74
+ 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.
75
+
76
+ `.serpkin.json` (server and site id, no secrets) is written to the repo root. Commit it so CI knows which site to check.
77
+
78
+ ### Sites that aren't live yet
79
+
80
+ 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.
81
+
82
+ ## Commands
83
+
84
+ | Command | |
85
+ | ----------------------------- | ---------------------------------------------------------------------------------- |
86
+ | `serpkin` / `serpkin init` | Setup (above). Safe to re-run; the stored key and site are reused. |
87
+ | `serpkin check <preview-url>` | Compare a preview deploy with production. Exit code 1 on regressions, 2 on errors. |
88
+ | `serpkin verify` | Check ownership (verification file or DNS record). |
89
+ | `serpkin status` | Site, ownership, data level, next mission and credits. |
90
+ | `serpkin routes [--upload]` | Print the route map; `--upload` sends it. |
91
+ | `serpkin next [--oneline]` | The next mission in one line (fast, cached; `--oneline` is silent when offline). |
92
+
93
+ 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`.
94
+
95
+ 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.
96
+
97
+ **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.
98
+
99
+ For a local Serpkin server: `npx serpkin --server http://127.0.0.1:3000`.
100
+
101
+ ## Checks on pull requests
102
+
103
+ `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:
104
+
105
+ - HTTP errors (non-2xx, including protected previews)
106
+ - `noindex` added in the page's robots meta tag
107
+ - title, meta description or H1 removed
108
+ - canonical removed, invalid, or pointing to another site
109
+ - `robots.txt` failing or removed, `sitemap.xml` no longer reachable or valid
110
+
111
+ Problems that already exist in production are reported as warnings, not regressions.
112
+
113
+ **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.
114
+
115
+ Preview hosts must be the site's own domain or subdomains, or `*.vercel.app`, `*.netlify.app`, `*.pages.dev`.
116
+
117
+ ### GitHub Action
118
+
119
+ 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.
120
+
121
+ ## What leaves your machine
122
+
123
+ - **Sent to Serpkin:** your site URL, the answers you give during setup (the site description defaults to the one in `package.json`), and the route map (URL paths and the source file names that serve them, up to 2,000 routes). Your agent sends what it asks Serpkin for through MCP.
124
+ - **Never sent:** your source code, environment files or the contents of your repository. To detect the framework and build the route map, the CLI reads `package.json` and the file names in your routes folder, not their contents. It also updates your agent's config files (`.mcp.json`, `.cursor/`, `AGENTS.md`, `~/.codex/config.toml`) as described above.
125
+ - **Stored locally:** your agent key in `~/.config/serpkin/credentials.json` (mode 600), never in the repository.
126
+
127
+ Details: [privacy policy](https://serpkin.com/legal/privacy).
128
+
129
+ ## Troubleshooting
130
+
131
+ - **Serpkin can't reach your site**: Serpkin needs a public URL. Use a preview deploy with `--url`, not `localhost`.
132
+ - **Preview check returns 401**: your preview is behind deployment protection. Allow Serpkin's requests or check a public preview.
133
+ - **The agent doesn't see Serpkin**: make sure `SERPKIN_API_KEY` is exported in the shell your agent starts from, then restart the agent.
134
+ - **Start over**: re-run `npx serpkin`. It reuses the stored key and site and refreshes only its own blocks.
135
+
136
+ ## Support and license
137
+
138
+ Questions or bugs: [serpkin.com](https://serpkin.com) or the [plugin repository](https://github.com/apwn/serpkin-plugin/issues). MIT licensed (this CLI and the Claude Code plugin).
2
139
 
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.
@@ -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
+ }