spook-cli 0.1.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.
Files changed (3) hide show
  1. package/README.md +81 -0
  2. package/bin/spook.js +358 -0
  3. package/package.json +39 -0
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # Spook CLI
2
+
3
+ Agent-first CLI for the [Spook](https://www.tryspook.com) agent API. Spook is an
4
+ SEO autopilot: it finds winnable Google & ChatGPT queries, writes SEO content,
5
+ and publishes it automatically. This CLI is a thin, zero-dependency wrapper over
6
+ Spook's REST agent API (`/api/agent`, described at `/openapi.json`), so terminal
7
+ agents (Claude Code, Cursor, CI) and shell scripts can drive Spook. Everything
8
+ prints JSON.
9
+
10
+ ## Install
11
+
12
+ Requires Node.js 18+.
13
+
14
+ ```bash
15
+ npm install -g spook-cli
16
+ spook help
17
+ ```
18
+
19
+ ## Get an API key
20
+
21
+ 1. Sign in to Spook and open **Settings → Agent API**.
22
+ 2. Generate a key (`spook_agent_...`) and save it:
23
+
24
+ ```bash
25
+ spook auth login --key spook_agent_xxxxxxxx
26
+ spook auth whoami
27
+ ```
28
+
29
+ The key is stored in `~/.spook/config.json` (chmod 600). You can instead set
30
+ `SPOOK_API_KEY` in the environment. Point at a different host with `--base` or
31
+ `SPOOK_BASE_URL` (default `https://www.tryspook.com`).
32
+
33
+ ## Try it free (no key)
34
+
35
+ ```bash
36
+ spook analyze example.com # quick-win queries any domain already ranks for
37
+ ```
38
+
39
+ ## Commands
40
+
41
+ ```
42
+ Sites
43
+ spook sites List your sites
44
+ spook site <siteId> Site details
45
+ spook sites:create --name <n> --domain <d> [--publish nextjs] [--webhook <url>]
46
+ spook publishing <siteId> --integration nextjs [--mode draft|live]
47
+ spook autopilot <siteId> on|off
48
+ spook metrics <siteId> [--days 28]
49
+ spook checkout Stripe Checkout URL to add a paid site
50
+
51
+ Content
52
+ spook opportunities <siteId> [--questions] [--limit 20]
53
+ spook articles <siteId> [--limit 20]
54
+ spook article <articleId>
55
+ spook generate <siteId> "<query>" [--callback <https-url>]
56
+ spook publish <articleId>
57
+
58
+ Any tool (incl. the free AI writers)
59
+ spook tools List every tool + input schema
60
+ spook run <tool> '<json>' e.g. spook run blog-outline-generator '{"topic":"cold brew"}'
61
+ ```
62
+
63
+ ## Examples
64
+
65
+ ```bash
66
+ # Stand up a site, let Spook run it
67
+ spook sites:create --name "Acme" --domain acme.com --publish nextjs
68
+ spook opportunities <siteId> --limit 10
69
+ spook generate <siteId> "how to brew cold brew coffee"
70
+ spook article <articleId> # review the draft
71
+ spook publish <articleId>
72
+ spook autopilot <siteId> on
73
+
74
+ # Use a free AI tool
75
+ spook run content-brief-generator '{"keyword":"cold brew ratios"}'
76
+ ```
77
+
78
+ ## MCP
79
+
80
+ Prefer MCP? Spook is also an MCP server at `https://www.tryspook.com/api/mcp` —
81
+ point any MCP client there. The CLI and MCP expose the same tools.
package/bin/spook.js ADDED
@@ -0,0 +1,358 @@
1
+ #!/usr/bin/env node
2
+ // Spook CLI — agent-first command line for the Spook agent API.
3
+ // Zero dependencies; Node 18+ (uses global fetch). Thin wrapper over the REST
4
+ // endpoints at <base>/api/agent (see <base>/openapi.json). Prints JSON so it
5
+ // composes in shells and is trivial for terminal agents (Claude Code, Cursor,
6
+ // CI) to parse.
7
+
8
+ import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ const DEFAULT_BASE = "https://www.tryspook.com";
13
+ const CONFIG_DIR = join(homedir(), ".spook");
14
+ const CONFIG_PATH = join(CONFIG_DIR, "config.json");
15
+ const VERSION = "0.1.0";
16
+
17
+ // ── config ────────────────────────────────────────────────────────────────
18
+ function readConfig() {
19
+ try {
20
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
21
+ } catch {
22
+ return {};
23
+ }
24
+ }
25
+ function writeConfig(cfg) {
26
+ mkdirSync(CONFIG_DIR, { recursive: true });
27
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
28
+ }
29
+ function config() {
30
+ const file = readConfig();
31
+ return {
32
+ apiKey: process.env.SPOOK_API_KEY || file.apiKey || "",
33
+ baseUrl: (process.env.SPOOK_BASE_URL || file.baseUrl || DEFAULT_BASE).replace(/\/$/, ""),
34
+ };
35
+ }
36
+
37
+ // ── output ────────────────────────────────────────────────────────────────
38
+ function out(data) {
39
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
40
+ }
41
+ function fail(message, extra) {
42
+ process.stderr.write(
43
+ JSON.stringify({ error: message, ...(extra || {}) }, null, 2) + "\n",
44
+ );
45
+ process.exit(1);
46
+ }
47
+
48
+ // ── arg parsing ─────────────────────────────────────────────────────────────
49
+ // Splits argv into { _: positionals, flags: {name: value|true} }.
50
+ function parseArgs(argv) {
51
+ const _ = [];
52
+ const flags = {};
53
+ for (let i = 0; i < argv.length; i++) {
54
+ const a = argv[i];
55
+ if (a.startsWith("--")) {
56
+ const key = a.slice(2);
57
+ const next = argv[i + 1];
58
+ if (next === undefined || next.startsWith("--")) {
59
+ flags[key] = true;
60
+ } else {
61
+ flags[key] = next;
62
+ i++;
63
+ }
64
+ } else {
65
+ _.push(a);
66
+ }
67
+ }
68
+ return { _, flags };
69
+ }
70
+
71
+ // ── HTTP ────────────────────────────────────────────────────────────────────
72
+ // Non-throwing: returns { status, json }. status 0 == network error.
73
+ async function tryRequest(path, { method = "POST", body, auth = true } = {}) {
74
+ const { apiKey, baseUrl } = config();
75
+ const headers = { "Content-Type": "application/json" };
76
+ if (auth && apiKey) headers.Authorization = `Bearer ${apiKey}`;
77
+ let res;
78
+ try {
79
+ res = await fetch(`${baseUrl}${path}`, {
80
+ method,
81
+ headers,
82
+ body: body ? JSON.stringify(body) : undefined,
83
+ });
84
+ } catch (e) {
85
+ return { status: 0, json: { error: `Network error reaching ${baseUrl}${path}`, detail: String(e) } };
86
+ }
87
+ const text = await res.text();
88
+ let json;
89
+ try {
90
+ json = text ? JSON.parse(text) : {};
91
+ } catch {
92
+ json = { raw: text };
93
+ }
94
+ return { status: res.status, json };
95
+ }
96
+
97
+ // Throwing variant: exits non-zero on any error.
98
+ async function request(path, opts = {}) {
99
+ const { status, json } = await tryRequest(path, opts);
100
+ if (status === 0 || status >= 400) {
101
+ fail(json.error || `Request failed (${status})`, { status, code: json.code });
102
+ }
103
+ return json;
104
+ }
105
+
106
+ // POST a tool at /api/agent/<tool>, returning its `result`.
107
+ async function runTool(tool, args) {
108
+ const json = await request(`/api/agent/${tool}`, { body: args });
109
+ return json.result !== undefined ? json.result : json;
110
+ }
111
+
112
+ // Run any tool by name — tries the agent surface, then the free-tools surface
113
+ // (/api/tools/<slug>), so deterministic tools work here too.
114
+ async function runAnyTool(tool, args) {
115
+ let r = await tryRequest(`/api/agent/${tool}`, { body: args });
116
+ if (r.status === 404) r = await tryRequest(`/api/tools/${tool}`, { body: args });
117
+ if (r.status === 0 || r.status >= 400) {
118
+ fail(r.json.error || `Request failed (${r.status})`, { status: r.status, code: r.json.code });
119
+ }
120
+ return r.json.result !== undefined ? r.json.result : r.json;
121
+ }
122
+
123
+ function requireKey() {
124
+ if (!config().apiKey) {
125
+ fail(
126
+ "No API key. Run `spook auth login --key spook_agent_...` or set SPOOK_API_KEY. Generate a key in Spook → Settings → Agent API.",
127
+ );
128
+ }
129
+ }
130
+
131
+ // ── commands ──────────────────────────────────────────────────────────────
132
+ const HELP = `Spook CLI ${VERSION} — agent-first CLI for the Spook agent API
133
+
134
+ Usage: spook <command> [args] [--flags]
135
+
136
+ Auth
137
+ auth login --key <spook_agent_...> [--base <url>] Save your API key
138
+ auth whoami Verify the key (lists sites)
139
+ auth logout Remove saved config
140
+
141
+ Free (no key)
142
+ analyze <domain> Quick-win queries for any domain
143
+
144
+ Sites
145
+ sites List your sites
146
+ site <siteId> Site details
147
+ sites:create --name <n> --domain <d> [--description <t>]
148
+ [--publish <nextjs|webhook|wordpress|ghost|shopify|rest>] [--webhook <url>]
149
+ publishing <siteId> --integration <type> [--mode <draft|live>] [--webhook <url>]
150
+ autopilot <siteId> <on|off>
151
+ metrics <siteId> [--days <n>]
152
+ checkout Get a Stripe Checkout URL to add a paid site
153
+
154
+ Content
155
+ opportunities <siteId> [--questions] [--limit <n>] Winnable queries
156
+ articles <siteId> [--limit <n>] Recent articles
157
+ article <articleId> One article (full body + SEO)
158
+ generate <siteId> <query...> [--callback <https-url>]
159
+ publish <articleId>
160
+
161
+ Any tool (incl. the free AI tools)
162
+ tools List every tool + schema
163
+ run <tool> [jsonBody] Run any tool by name
164
+ e.g. spook run blog-outline-generator '{"topic":"cold brew"}'
165
+
166
+ Global: --base <url> and SPOOK_BASE_URL override the API host (default ${DEFAULT_BASE}).
167
+ SPOOK_API_KEY overrides the saved key. Output is JSON.`;
168
+
169
+ async function main() {
170
+ const argv = process.argv.slice(2);
171
+ const { _, flags } = parseArgs(argv);
172
+ const cmd = _[0];
173
+
174
+ if (!cmd || cmd === "help" || flags.help || cmd === "--help") return out({ help: HELP });
175
+ if (cmd === "version" || flags.version) return out({ version: VERSION });
176
+
177
+ // Allow --base to override for a single invocation.
178
+ if (typeof flags.base === "string") process.env.SPOOK_BASE_URL = flags.base;
179
+
180
+ switch (cmd) {
181
+ case "auth": {
182
+ const sub = _[1];
183
+ if (sub === "login") {
184
+ const key = typeof flags.key === "string" ? flags.key : "";
185
+ if (!key) fail("Pass --key spook_agent_...");
186
+ const cfg = readConfig();
187
+ cfg.apiKey = key;
188
+ if (typeof flags.base === "string") cfg.baseUrl = flags.base;
189
+ writeConfig(cfg);
190
+ return out({ ok: true, savedTo: CONFIG_PATH, baseUrl: config().baseUrl });
191
+ }
192
+ if (sub === "logout") {
193
+ if (existsSync(CONFIG_PATH)) rmSync(CONFIG_PATH);
194
+ return out({ ok: true });
195
+ }
196
+ if (sub === "whoami") {
197
+ requireKey();
198
+ const r = await request(`/api/agent/list_sites`, { body: {} });
199
+ const sites = r.result?.sites ?? [];
200
+ return out({ authenticated: true, sites: sites.length, baseUrl: config().baseUrl });
201
+ }
202
+ return fail("Usage: spook auth <login|whoami|logout>");
203
+ }
204
+
205
+ case "analyze": {
206
+ const domain = _[1];
207
+ if (!domain) fail("Usage: spook analyze <domain>");
208
+ return out(await runTool("analyze_domain", { domain }));
209
+ }
210
+
211
+ case "sites":
212
+ requireKey();
213
+ return out(await runTool("list_sites", {}));
214
+
215
+ case "sites:create": {
216
+ requireKey();
217
+ const { name, domain } = flags;
218
+ if (typeof name !== "string" || typeof domain !== "string") {
219
+ fail("Usage: spook sites:create --name <n> --domain <d>");
220
+ }
221
+ return out(
222
+ await runTool("create_site", {
223
+ name,
224
+ domain,
225
+ description: typeof flags.description === "string" ? flags.description : undefined,
226
+ brandVoice: typeof flags.voice === "string" ? flags.voice : undefined,
227
+ niche: typeof flags.niche === "string" ? flags.niche : undefined,
228
+ publishIntegration: typeof flags.publish === "string" ? flags.publish : undefined,
229
+ publishWebhookUrl: typeof flags.webhook === "string" ? flags.webhook : undefined,
230
+ }),
231
+ );
232
+ }
233
+
234
+ case "site": {
235
+ requireKey();
236
+ const siteId = _[1];
237
+ if (!siteId) fail("Usage: spook site <siteId>");
238
+ return out(await runTool("get_site", { siteId }));
239
+ }
240
+
241
+ case "publishing": {
242
+ requireKey();
243
+ const siteId = _[1];
244
+ if (!siteId) fail("Usage: spook publishing <siteId> --integration <type>");
245
+ return out(
246
+ await runTool("update_publishing", {
247
+ siteId,
248
+ publishIntegration: typeof flags.integration === "string" ? flags.integration : undefined,
249
+ publishMode: typeof flags.mode === "string" ? flags.mode : undefined,
250
+ publishWebhookUrl: typeof flags.webhook === "string" ? flags.webhook : undefined,
251
+ publishWebhookSecret: typeof flags.secret === "string" ? flags.secret : undefined,
252
+ }),
253
+ );
254
+ }
255
+
256
+ case "autopilot": {
257
+ requireKey();
258
+ const siteId = _[1];
259
+ const state = _[2];
260
+ if (!siteId || !["on", "off"].includes(state)) {
261
+ fail("Usage: spook autopilot <siteId> <on|off>");
262
+ }
263
+ return out(await runTool("set_autopilot", { siteId, enabled: state === "on" }));
264
+ }
265
+
266
+ case "metrics": {
267
+ requireKey();
268
+ const siteId = _[1];
269
+ if (!siteId) fail("Usage: spook metrics <siteId> [--days <n>]");
270
+ return out(
271
+ await runTool("get_site_metrics", {
272
+ siteId,
273
+ days: typeof flags.days === "string" ? Number(flags.days) : undefined,
274
+ }),
275
+ );
276
+ }
277
+
278
+ case "checkout":
279
+ requireKey();
280
+ return out(await runTool("create_checkout", {}));
281
+
282
+ case "opportunities": {
283
+ requireKey();
284
+ const siteId = _[1];
285
+ if (!siteId) fail("Usage: spook opportunities <siteId> [--questions] [--limit <n>]");
286
+ return out(
287
+ await runTool("list_opportunities", {
288
+ siteId,
289
+ questionsOnly: flags.questions === true,
290
+ limit: typeof flags.limit === "string" ? Number(flags.limit) : undefined,
291
+ }),
292
+ );
293
+ }
294
+
295
+ case "articles": {
296
+ requireKey();
297
+ const siteId = _[1];
298
+ if (!siteId) fail("Usage: spook articles <siteId> [--limit <n>]");
299
+ return out(
300
+ await runTool("list_articles", {
301
+ siteId,
302
+ limit: typeof flags.limit === "string" ? Number(flags.limit) : undefined,
303
+ }),
304
+ );
305
+ }
306
+
307
+ case "article": {
308
+ requireKey();
309
+ const articleId = _[1];
310
+ if (!articleId) fail("Usage: spook article <articleId>");
311
+ return out(await runTool("get_article", { articleId }));
312
+ }
313
+
314
+ case "generate": {
315
+ requireKey();
316
+ const siteId = _[1];
317
+ const query = _.slice(2).join(" ");
318
+ if (!siteId || !query) fail('Usage: spook generate <siteId> "<query>" [--callback <url>]');
319
+ return out(
320
+ await runTool("generate_article", {
321
+ siteId,
322
+ query,
323
+ callbackUrl: typeof flags.callback === "string" ? flags.callback : undefined,
324
+ }),
325
+ );
326
+ }
327
+
328
+ case "publish": {
329
+ requireKey();
330
+ const articleId = _[1];
331
+ if (!articleId) fail("Usage: spook publish <articleId>");
332
+ return out(await runTool("publish_article", { articleId }));
333
+ }
334
+
335
+ case "tools":
336
+ return out(await request(`/api/agent`, { method: "GET", auth: false }));
337
+
338
+ case "run": {
339
+ const tool = _[1];
340
+ if (!tool) fail('Usage: spook run <tool> [jsonBody]');
341
+ let body = {};
342
+ const raw = _[2];
343
+ if (raw) {
344
+ try {
345
+ body = JSON.parse(raw);
346
+ } catch {
347
+ fail("Invalid JSON body. Wrap it in single quotes, e.g. '{\"topic\":\"x\"}'");
348
+ }
349
+ }
350
+ return out(await runAnyTool(tool, body));
351
+ }
352
+
353
+ default:
354
+ return fail(`Unknown command: ${cmd}. Run \`spook help\`.`);
355
+ }
356
+ }
357
+
358
+ main().catch((e) => fail("Unexpected error", { detail: String(e?.stack || e) }));
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "spook-cli",
3
+ "version": "0.1.0",
4
+ "description": "Agent-first CLI for the Spook agent API (SEO autopilot: find winnable queries, generate & publish SEO content).",
5
+ "type": "module",
6
+ "bin": {
7
+ "spook": "bin/spook.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "keywords": [
17
+ "spook",
18
+ "cli",
19
+ "agent",
20
+ "seo",
21
+ "content",
22
+ "ai",
23
+ "mcp"
24
+ ],
25
+ "author": "Spook (https://www.tryspook.com)",
26
+ "homepage": "https://www.tryspook.com",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/VictorKildahl/spook.git",
30
+ "directory": "cli"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/VictorKildahl/spook/issues"
34
+ },
35
+ "license": "MIT",
36
+ "publishConfig": {
37
+ "access": "public"
38
+ }
39
+ }