n-seo 0.1.1 → 0.3.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 (61) hide show
  1. package/.claude/skills/n-seo-add-site/SKILL.md +125 -0
  2. package/.claude/skills/n-seo-deploy/SKILL.md +171 -0
  3. package/.claude/skills/n-seo-review/SKILL.md +106 -0
  4. package/.claude/skills/n-seo-setup/SKILL.md +139 -0
  5. package/.claude/skills/n-seo-ship/SKILL.md +122 -0
  6. package/.claude/skills/n-seo-triage/SKILL.md +90 -0
  7. package/.claude/skills/orient/SKILL.md +54 -0
  8. package/README.md +190 -49
  9. package/bin/n-seo.mjs +177 -10
  10. package/docs/ADDING-A-SITE.md +4 -0
  11. package/docs/DEPLOY.md +4 -0
  12. package/docs/FAQ.md +46 -6
  13. package/docs/INSTANCE.md +54 -0
  14. package/docs/MCP.md +25 -9
  15. package/docs/PRD.md +29 -9
  16. package/docs/SCHEDULING.md +46 -5
  17. package/docs/SETUP-GOOGLE.md +8 -1
  18. package/ingest/__pycache__/analyze_metadata.cpython-312.pyc +0 -0
  19. package/ingest/__pycache__/google_auth.cpython-312.pyc +0 -0
  20. package/ingest/__pycache__/http_util.cpython-312.pyc +0 -0
  21. package/ingest/__pycache__/seo_config.cpython-312.pyc +0 -0
  22. package/ingest/analyze_ga4.py +2 -2
  23. package/ingest/analyze_gsc.py +2 -2
  24. package/ingest/analyze_metadata.py +2 -2
  25. package/ingest/analyze_trends.py +2 -2
  26. package/ingest/google_auth.py +53 -20
  27. package/ingest/pull_ga4.py +1 -1
  28. package/ingest/pull_gsc.py +1 -1
  29. package/ingest/pull_index_status.py +1 -1
  30. package/ingest/pull_timeseries.py +3 -3
  31. package/ingest/seo_config.py +19 -3
  32. package/ops/__pycache__/daily.cpython-312.pyc +0 -0
  33. package/ops/__pycache__/daily_diff.cpython-312.pyc +0 -0
  34. package/ops/__pycache__/demo_data.cpython-312.pyc +0 -0
  35. package/ops/__pycache__/export_static.cpython-312.pyc +0 -0
  36. package/ops/__pycache__/llm.cpython-312.pyc +0 -0
  37. package/ops/__pycache__/publish.cpython-312.pyc +0 -0
  38. package/ops/daily.py +2 -2
  39. package/ops/daily_diff.py +9 -8
  40. package/ops/demo_data.py +23 -17
  41. package/ops/doctor.py +30 -7
  42. package/ops/export_static.py +10 -5
  43. package/ops/hn_digest.py +1 -1
  44. package/ops/indexnow.py +3 -3
  45. package/ops/llm.py +23 -1
  46. package/ops/opportunity_scan.py +4 -4
  47. package/ops/py.mjs +40 -0
  48. package/ops/reddit_digest.py +1 -1
  49. package/ops/templates/n-seo-daily-task.xml +65 -0
  50. package/package.json +24 -9
  51. package/probes/__pycache__/site_probe.cpython-312.pyc +0 -0
  52. package/probes/site_probe.py +1 -1
  53. package/public/fonts/OFL.txt +105 -0
  54. package/public/fonts/montserrat-latin-var.woff2 +0 -0
  55. package/public/styles.css +18 -1
  56. package/src/actions.ts +1 -1
  57. package/src/config.ts +1 -1
  58. package/src/mcp.ts +2 -2
  59. package/src/server.tsx +22 -1
  60. package/src/settings.tsx +3 -3
  61. package/src/views.tsx +79 -15
package/bin/n-seo.mjs CHANGED
@@ -14,6 +14,7 @@
14
14
  * n-seo upgrade git pull + npm ci + npm run check, with a rollback hint
15
15
  */
16
16
  import { spawnSync, execFileSync } from "node:child_process";
17
+ import { pythonBin } from "../ops/py.mjs";
17
18
  import { createHash } from "node:crypto";
18
19
  import fs from "node:fs";
19
20
  import path from "node:path";
@@ -27,7 +28,9 @@ const USAGE = `n-seo ${PKG.version} — an SEO on your machine
27
28
 
28
29
  usage: n-seo <command> [--instance <dir>] [args...]
29
30
 
30
- init [dir] scaffold an instance directory (default: cwd); never overwrites
31
+ init [dir] scaffold a standalone instance directory (default: cwd); never
32
+ overwrites, and refuses to scaffold inside a website's repo
33
+ (--force overrides)
31
34
  start run the dashboard for the instance
32
35
  dev dashboard with reload on engine code changes
33
36
  daily the morning run (args pass through: --only, --skip, --list, …)
@@ -121,6 +124,15 @@ function tsxBin() {
121
124
  }
122
125
  }
123
126
 
127
+ /* npm, npx and similar arrive as `.cmd` batch shims on Windows, which
128
+ * spawnSync cannot execute without a shell — a bare `npm` fails there with
129
+ * ENOENT. (The Python interpreter has the same problem; ops/py.mjs solves
130
+ * it, and this file imports pythonBin from there so the CLI and the npm
131
+ * scripts resolve the interpreter identically.) */
132
+ function npmBin(name = "npm") {
133
+ return process.platform === "win32" ? `${name}.cmd` : name;
134
+ }
135
+
124
136
  function missingDeps(cmd, what) {
125
137
  const installed = ROOT.includes(`${path.sep}node_modules${path.sep}`);
126
138
  console.error(`${cmd} needs ${what}.`);
@@ -132,8 +144,87 @@ function missingDeps(cmd, what) {
132
144
 
133
145
  /* ---------- init ---------- */
134
146
 
135
- function init(dirArg) {
147
+ /** Files that mean "this directory is an application", not a place to keep
148
+ * search-ops files. n-seo is a standalone project: it writes a config, a
149
+ * queue, drafts and a data/ tree that the daily run rewrites every morning.
150
+ * Dropped into a website's repo those files get committed, deployed, and
151
+ * eventually served — and `n-seo upgrade` starts fighting the site's own
152
+ * package.json. The instance sits beside the sites it watches, never inside
153
+ * one. */
154
+ const APP_MARKERS = [
155
+ "package.json", "index.html", "next.config.js", "next.config.mjs", "next.config.ts",
156
+ "vite.config.js", "vite.config.ts", "astro.config.mjs", "nuxt.config.ts",
157
+ "svelte.config.js", "gatsby-config.js", "remix.config.js", "angular.json",
158
+ "Gemfile", "go.mod", "Cargo.toml", "pyproject.toml", "composer.json",
159
+ "hugo.toml", "config.toml", "_config.yml", "wp-config.php", "Dockerfile",
160
+ ];
161
+
162
+ /** The nearest ancestor holding a .git, or null. */
163
+ function gitRootOf(dir) {
164
+ let cur = path.resolve(dir);
165
+ for (;;) {
166
+ if (fs.existsSync(path.join(cur, ".git"))) return cur;
167
+ const up = path.dirname(cur);
168
+ if (up === cur) return null;
169
+ cur = up;
170
+ }
171
+ }
172
+
173
+ /** Why this directory is the wrong place for an instance, or null if it is
174
+ * fine.
175
+ *
176
+ * `forChild` means the instance will be a NEW directory created inside
177
+ * `dir`, which changes one answer: a repository root is a fine place to put
178
+ * an instance (it becomes its own repo) but a terrible parent for one (the
179
+ * new directory lands inside that repo's working tree).
180
+ *
181
+ * Re-running init on an existing instance is always allowed. */
182
+ function wrongPlaceFor(dir, forChild = false) {
183
+ if (fs.existsSync(path.join(dir, "n-seo.config.json"))) return null;
184
+ // In-place mode: the engine checkout is the instance. It has a package.json
185
+ // and a .git of its own, both of which would otherwise trip every check.
186
+ if (!forChild && path.resolve(dir) === ROOT) return null;
187
+ const marker = APP_MARKERS.find((f) => fs.existsSync(path.join(dir, f)));
188
+ if (marker) {
189
+ return forChild
190
+ ? `${dir} contains ${marker} — an instance created here would sit inside an application`
191
+ : `${dir} contains ${marker} — that is an application, not an n-seo instance`;
192
+ }
193
+ const root = gitRootOf(dir);
194
+ const insideSomeoneElsesRepo = root && (forChild || root !== path.resolve(dir));
195
+ if (insideSomeoneElsesRepo && !fs.existsSync(path.join(root, "n-seo.config.json"))) {
196
+ return `${dir} is inside the git repository at ${root} — the instance would be committed to it`;
197
+ }
198
+ return null;
199
+ }
200
+
201
+ function init(dirArg, force = false) {
136
202
  const dir = path.resolve(dirArg ?? process.cwd());
203
+ // A directory that does not exist yet inherits its parent's problem: a new
204
+ // folder created inside a site repo is still inside that site repo.
205
+ const wrong = fs.existsSync(dir) ? wrongPlaceFor(dir) : wrongPlaceFor(path.dirname(dir), true);
206
+ if (wrong && !force) {
207
+ console.error(`n-seo init: refusing to scaffold here.
208
+
209
+ ${wrong}
210
+
211
+ n-seo is a standalone project, not something you add to a website. It keeps a
212
+ config, an action queue, content drafts and a data/ tree that the daily run
213
+ rewrites every morning; inside a site repo those get committed and deployed.
214
+ The instance watches your sites over the Search Console API — it never needs
215
+ to live in their code.
216
+
217
+ Do this instead:
218
+
219
+ cd ~ # anywhere outside your site repos
220
+ n-seo init my-sites
221
+ cd my-sites
222
+
223
+ Then list the sites you own in my-sites/n-seo.config.json.
224
+
225
+ If you really mean it here, pass --force.`);
226
+ return 2;
227
+ }
137
228
  fs.mkdirSync(dir, { recursive: true });
138
229
  const name = path.basename(dir);
139
230
  const put = (rel, content) => {
@@ -160,6 +251,59 @@ function init(dirArg) {
160
251
  const src = path.join(ROOT, rel);
161
252
  put(rel, fs.existsSync(src) ? fs.readFileSync(src, "utf8") : `# ${rel}\n`);
162
253
  }
254
+ // The skills are the product's front door: with them in place the instance
255
+ // is a directory an agent can be pointed at, and setup / triage / shipping
256
+ // become a conversation instead of a docs-reading exercise. The engine's
257
+ // own contributor skills (ndx-*) stay behind — they are for working on
258
+ // n-seo, not with it. Placeholders become this install's real paths so the
259
+ // commands in them are copy-pasteable.
260
+ const INSTANCE_SKILLS = ["orient", "n-seo-setup", "n-seo-add-site", "n-seo-triage", "n-seo-ship", "n-seo-review", "n-seo-deploy"];
261
+ let skillsCopied = 0;
262
+ for (const skill of INSTANCE_SKILLS) {
263
+ const src = path.join(ROOT, ".claude", "skills", skill, "SKILL.md");
264
+ if (!fs.existsSync(src)) continue;
265
+ const body = fs
266
+ .readFileSync(src, "utf8")
267
+ .replaceAll("<engine checkout>/bin/n-seo.mjs", path.join(ROOT, "bin", "n-seo.mjs"))
268
+ .replaceAll("<instance dir>", dir);
269
+ put(path.join(".claude", "skills", skill, "SKILL.md"), body);
270
+ skillsCopied++;
271
+ }
272
+
273
+ put("CLAUDE.md", `# ${name} — an n-seo instance
274
+
275
+ This directory is an n-seo instance: config, the curated action queue, content
276
+ drafts, and the data the daily run writes. It is **not** a website. The sites
277
+ it watches live in their own repositories and are read over the Search Console
278
+ and GA4 APIs.
279
+
280
+ Engine (the code): ${ROOT}
281
+ Docs: ${path.join(ROOT, "docs")}
282
+
283
+ ## Working here
284
+
285
+ Skills in \`.claude/skills/\` cover the routine work — start with
286
+ \`/orient\` in a fresh session, then \`/n-seo-triage\` for what to do today,
287
+ \`/n-seo-ship\` to implement one card, \`/n-seo-review\` for the weekly pass.
288
+
289
+ ## Rules that are not negotiable
290
+
291
+ - **28-day metadata freeze.** After a page's title or description changes,
292
+ leave that page's metadata alone for 28 days. Title churn reads as
293
+ manipulation and resets Google's evaluation.
294
+ - **At most ~8 title/description changes a week** across all sites.
295
+ - **Impact numbers order the queue. They are not forecasts.** Never report
296
+ one as an expected result.
297
+ - **Decisions ride the trailing 90 days.** The 16-month data is for totals
298
+ and history only.
299
+ - **Shipped work becomes \`watching\`, never deleted** — that is how the loop
300
+ closes.
301
+ - **Machine proposals never self-promote.** They wait in the holding area on
302
+ /actions until a human accepts them.
303
+ - **Site changes ship as branches and pull requests** in the site's own repo,
304
+ never committed straight to its main branch.
305
+ `);
306
+
163
307
  const envExample = path.join(ROOT, ".env.example");
164
308
  put(".env", fs.existsSync(envExample) ? fs.readFileSync(envExample, "utf8") : "");
165
309
  put(".gitignore", "data/\nsite/\n.env\n__pycache__/\nnode_modules/\n");
@@ -193,15 +337,38 @@ n-seo upgrade # update the engine; your files here are untouched
193
337
 
194
338
  Edit \`n-seo.config.json\` for sites, Google access, modules and hooks.
195
339
  Docs: ${path.join(ROOT, "docs")}
340
+
341
+ ## Or just ask
342
+
343
+ n-seo is built to be driven by an AI agent. Open this directory in Claude Code
344
+ (or any agent that reads \`.claude/skills/\`) and talk to it:
345
+
346
+ "set this up for my sites" → /n-seo-setup
347
+ "what should I work on today?" → /n-seo-triage
348
+ "do the first one" → /n-seo-ship
349
+ "how did last month go?" → /n-seo-review
350
+
351
+ A read-only MCP server is wired up in \`.mcp.json\`, so the agent reads the
352
+ same queue and metrics the dashboard shows. It can propose and implement;
353
+ it cannot publish, post, or change your sites behind your back.
196
354
  `);
197
355
  console.log(`
198
356
  instance ready at ${dir}
357
+ a standalone project — your websites stay in their own repos, untouched
199
358
 
200
359
  next:
201
360
  cd ${dir}
202
- edit n-seo.config.json (sites, google.serviceAccountKey — see ${path.join(ROOT, "docs", "SETUP-GOOGLE.md")})
203
- n-seo doctor
204
- n-seo demo && n-seo start (or n-seo daily once the key is in place)
361
+ n-seo demo && n-seo start see the dashboard working on synthetic data
362
+ edit n-seo.config.json your sites + google.serviceAccountKey
363
+ n-seo doctor checks the setup and names what is missing
364
+
365
+ or hand it to an agent — ${skillsCopied} skills are installed in .claude/skills/:
366
+ cd ${dir} && claude
367
+ "set this up for my sites" → /n-seo-setup walks the whole thing, including
368
+ the Google service account and both grants
369
+ later: "what should I work on today?" → /n-seo-triage
370
+
371
+ setup guide: ${path.join(ROOT, "docs", "SETUP-GOOGLE.md")}
205
372
  `);
206
373
  return 0;
207
374
  }
@@ -229,14 +396,14 @@ function upgrade(instance) {
229
396
  }
230
397
  if (sha256(path.join(ROOT, "package-lock.json")) !== lockBefore || !fs.existsSync(path.join(ROOT, "node_modules"))) {
231
398
  console.log("lockfile changed — npm ci");
232
- r = spawnSync("npm", ["ci"], { cwd: ROOT, stdio: "inherit" });
399
+ r = spawnSync(npmBin(), ["ci"], { cwd: ROOT, stdio: "inherit" });
233
400
  if (r.status !== 0) {
234
401
  console.error(`npm ci failed. Roll back with:\n git -C ${ROOT} reset --hard ${before}`);
235
402
  return r.status ?? 1;
236
403
  }
237
404
  }
238
405
  console.log("running the engine's checks");
239
- r = spawnSync("npm", ["run", "check"], { cwd: ROOT, stdio: "inherit", env: { ...process.env, N_SEO_INSTANCE: instance } });
406
+ r = spawnSync(npmBin(), ["run", "check"], { cwd: ROOT, stdio: "inherit", env: { ...process.env, N_SEO_INSTANCE: instance } });
240
407
  if (r.status !== 0) {
241
408
  console.error(`\nengine checks FAILED at ${after ?? "?"}. Previous commit: ${before ?? "?"}. Roll back with:\n git -C ${ROOT} reset --hard ${before}\n (then npm ci in ${ROOT} if the lockfile moved)`);
242
409
  return 1;
@@ -249,12 +416,12 @@ function upgrade(instance) {
249
416
 
250
417
  const { cmd, instance: flag, rest } = parseArgs(process.argv.slice(2));
251
418
  const instance = resolveInstance(flag);
252
- const py = process.env.PYTHON ?? "python3";
419
+ const py = pythonBin();
253
420
  let code = 0;
254
421
 
255
422
  switch (cmd) {
256
423
  case "init":
257
- code = init(rest[0] ?? flag ?? process.cwd());
424
+ code = init(rest.find((a) => !a.startsWith("-")) ?? flag ?? process.cwd(), rest.includes("--force"));
258
425
  break;
259
426
  case "start":
260
427
  case "dev":
@@ -274,7 +441,7 @@ switch (cmd) {
274
441
  case "check":
275
442
  // The self-test needs devDependencies, which an npm install omits.
276
443
  code = resolvePkgDir("typescript")
277
- ? run("npm", ["run", "check", "--silent", "--", ...rest], instance)
444
+ ? run(npmBin(), ["run", "check", "--silent", "--", ...rest], instance)
278
445
  : missingDeps(cmd, "the engine's dev dependencies (it is the engine's own test suite)");
279
446
  break;
280
447
  case "daily":
@@ -3,6 +3,10 @@
3
3
  One entry in `n-seo.config.json`. There are no per-script site lists:
4
4
  every pull, probe, audit, page and export reads the same `sites` array.
5
5
 
6
+ > Or say *"add example.com"* in the instance directory: the `n-seo-add-site`
7
+ > skill picks the right property form, finds the numeric GA4 id, proposes a
8
+ > brand regex, and reminds you about both access grants.
9
+
6
10
  ```json
7
11
  {
8
12
  "host": "docs.example.com",
package/docs/DEPLOY.md CHANGED
@@ -7,6 +7,10 @@ it sleeps, it travels, and the run silently skips a day.
7
7
 
8
8
  This document is about moving it to a box that is always on.
9
9
 
10
+ > Or say *"get this off my laptop"* in the instance directory — the
11
+ > `n-seo-deploy` skill runs this procedure, and refuses to expose the
12
+ > dashboard port to the internet whatever you ask it for.
13
+
10
14
  ## The shape
11
15
 
12
16
  One host runs two long-lived things — the dashboard and a scheduler that
package/docs/FAQ.md CHANGED
@@ -5,6 +5,25 @@ Nothing. The Search Console and GA4 APIs are free within quotas that ordinary
5
5
  use never approaches. The optional LLM module runs whatever local command you
6
6
  give it; if that is a paid CLI, that is your cost, not the tool's.
7
7
 
8
+ **Can I install it inside my website's repository?**
9
+ No — and `n-seo init` refuses to, so you cannot do it by accident. n-seo is a
10
+ standalone project. It writes a config, an action queue, content drafts and a
11
+ `data/` tree that the daily run rewrites every morning; inside a site repo all
12
+ of that gets committed, and usually deployed. It reads your sites through the
13
+ Search Console and GA4 APIs, so it has no reason to sit in their code. Put the
14
+ instance anywhere else — `~/my-sites` is fine — and list the sites you own in
15
+ its config. One instance can watch as many sites as you like. `--force`
16
+ overrides the check if you have a reason.
17
+
18
+ **Do I have to read all these docs?**
19
+ No. `n-seo init` installs seven skills into your instance, so setup and daily
20
+ use are a conversation: open the directory in Claude Code (or any agent that
21
+ reads `.claude/skills/`) and say "set this up for my sites", "what should I
22
+ work on today?", "do the first one". The agent reads the queue over a
23
+ read-only MCP server, follows the operating rules in the generated
24
+ `CLAUDE.md`, and cannot publish or change a site on its own. The docs are the
25
+ reference behind the skills, not a prerequisite.
26
+
8
27
  **Does it change my site?**
9
28
  No. It reads your data, probes your pages, and produces a queue of actions
10
29
  with evidence and specs. You make the change in your own repo and ship it.
@@ -28,8 +47,19 @@ proposals and verdicts to the opportunity scan and briefings to the digests.
28
47
  Nothing it produces is applied automatically.
29
48
 
30
49
  **Does it run on Windows?**
31
- Not supported. The scripts assume `curl`, `openssl`, POSIX paths and one of
32
- launchd / cron / systemd. WSL2 is likely to work but is untested.
50
+ Yes, since 0.2.0, and CI runs the full test suite on Windows on every commit
51
+ rather than taking the claim on trust. You need Node 20+ and Python 3.10+;
52
+ `curl` ships with Windows 10 and later. There is no `openssl` requirement on
53
+ any platform any more — the service-account JWT is signed with node's crypto
54
+ module. Schedule the daily run with Task Scheduler, using the task definition
55
+ in `ops/templates/n-seo-daily-task.xml` (see [SCHEDULING.md](SCHEDULING.md)).
56
+
57
+ Two differences worth knowing. Commands in the `hooks` block go to `cmd.exe`
58
+ rather than `/bin/sh`, so write them in its syntax or point them at a script.
59
+ And use `n-seo daily` (or `npm run daily`) rather than `python3 ops/daily.py`:
60
+ Windows installs Python as `python`, and the `python3.exe` it ships is a stub
61
+ that opens the Microsoft Store instead of running anything. The CLI finds the
62
+ real interpreter for you, and `$PYTHON` overrides it.
33
63
 
34
64
  **Where does my data go?**
35
65
  Into `data/` on the machine that runs the pipeline, as JSON. The only network
@@ -78,12 +108,22 @@ shown flat rather than omitted so you can tell "connected and quiet" from
78
108
  "missing from the pipeline."
79
109
 
80
110
  **The indexing page lists a "Soft 404" for a page that is fine.**
81
- Check the "last crawled" column. A verdict is only as current as the crawl
82
- behind it; the page flags verdicts older than 90 days as stale. Request
83
- indexing and re-check rather than chasing a template bug.
111
+ Check the "last crawled" column first. A verdict is only as current as the
112
+ crawl behind it, and the page flags verdicts older than 90 days as stale — a
113
+ page rewritten since then is being judged on content Google has not seen. For
114
+ a stale verdict, request indexing once and let the recrawl settle it.
115
+
116
+ For a *fresh* soft 404, requesting indexing is the wrong move: Google already
117
+ fetched the page, got a 200, and decided the content was an error or empty.
118
+ Asking again re-runs the same judgement and spends the daily quota. Fetch the
119
+ URL yourself and pick the real cause — the page renders its content in
120
+ JavaScript and the crawler saw an empty shell, or it is genuinely thin, or it
121
+ is an error page returning 200 and should return 404 or 410 and leave the
122
+ sitemap. Fix that, then request indexing. n-seo's indexing page names the
123
+ check to run for each verdict.
84
124
 
85
125
  **A daily step failed. What now?**
86
- Open `/logs` (or `data/daily-ops.log`), then `python3 ops/doctor.py`. A 401
126
+ Open `/logs` (or `data/daily-ops.log`), then run `n-seo doctor`. A 401
87
127
  or 403 from Google means the service account lost access or the key file
88
128
  moved. A run that fails every network step at once was offline; the next run
89
129
  will recover. Steps are independent — one failing does not stop the others.
package/docs/INSTANCE.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Running n-seo as an engine + instance
2
2
 
3
+ > **An instance is a standalone project.** It is never a subdirectory of a
4
+ > website you are optimising. n-seo reads your sites through the Search
5
+ > Console and GA4 APIs; it has no reason to live in their code, and living
6
+ > there means committing and deploying its config, its queue and a `data/`
7
+ > tree that is rewritten every morning.
8
+
9
+ ```
10
+ ~/
11
+ ├── code/
12
+ │ ├── my-website/ a site you own — untouched
13
+ │ └── docs-site/ another one — untouched
14
+ └── my-sites/ the instance
15
+ ├── n-seo.config.json both sites listed here
16
+ ├── config/backlog.json the curated queue
17
+ ├── content/ drafts, campaigns
18
+ ├── .claude/skills/ the operating skills
19
+ ├── CLAUDE.md the operating rules
20
+ └── data/ the daily run's output, gitignored
21
+ ```
22
+
23
+ One instance watches as many sites as you own, and `n-seo init` refuses to
24
+ scaffold into an application directory or a git repository it does not own
25
+ (`--force` overrides).
26
+
3
27
  n-seo can run two ways. In the simplest, you clone the repo, put your config
4
28
  in the checkout and run it there. In the second, the checkout is an **engine**
5
29
  you never edit, and everything that is yours — config, queue, content, data,
@@ -93,6 +117,36 @@ and `content/campaigns/README.md`, `.env` (from `.env.example`), a
93
117
  `.gitignore` that excludes `data/`, `site/` and `.env`, a `.mcp.json` that
94
118
  spawns the engine's MCP server with `N_SEO_INSTANCE` set, and a short README.
95
119
 
120
+ It also makes the instance agent-ready, which is how most people should drive
121
+ it:
122
+
123
+ - `.claude/skills/` — the seven operating skills (`orient`, `n-seo-setup`,
124
+ `n-seo-add-site`, `n-seo-triage`, `n-seo-ship`, `n-seo-review`,
125
+ `n-seo-deploy`), copied from the engine with this install's real engine and
126
+ instance paths substituted in, so every command in them is copy-pasteable.
127
+ The engine's contributor skills (`ndx-*`) are not copied — they are for
128
+ developing n-seo, not operating it.
129
+ - `CLAUDE.md` — the operating rules an agent working in this directory has to
130
+ follow: the 28-day freeze, the weekly metadata budget, impact-is-not-a-
131
+ forecast, decisions on the 90-day window, shipped-becomes-watching,
132
+ proposals never self-promote, site changes ship as pull requests.
133
+
134
+ With those two files plus `.mcp.json`, opening the instance in Claude Code is
135
+ enough: it can read the queue and the metrics, and it already knows the rules.
136
+
137
+ ```sh
138
+ cd ~/sites/search-ops && claude
139
+ "set this up for my sites" → /n-seo-setup
140
+ "what should I work on today?" → /n-seo-triage
141
+ "do the first one" → /n-seo-ship
142
+ "how did last month go?" → /n-seo-review
143
+ ```
144
+
145
+ Both files are yours once written. `n-seo init` never overwrites, so editing
146
+ `CLAUDE.md` to add your own rules survives every re-run and every engine
147
+ upgrade. To pick up improved skills from a newer engine, delete the ones you
148
+ have not customised and re-run `n-seo init`.
149
+
96
150
  To make `n-seo` a command, link the engine once:
97
151
 
98
152
  ```sh
package/docs/MCP.md CHANGED
@@ -1,10 +1,22 @@
1
- # MCP server — the same data, for agents
2
-
3
- `src/mcp.ts` exposes the control plane over the Model Context Protocol so an
4
- AI agent can read the queue and the metrics directly instead of scraping the
5
- dashboard. It is **read-only by design**: the queue is edited through the
6
- dashboard's accept/watch/retire flow, so nothing here writes anything. Every
7
- tool is annotated `readOnlyHint`.
1
+ # MCP server — your agent, on the queue
2
+
3
+ n-seo has two agentic surfaces. Inside the daily run, the `llm` module hands
4
+ the findings to a model you configure and gets back proposals, verdicts and
5
+ briefings (see the README). This document is the other one: `src/mcp.ts`
6
+ exposes the control plane over the Model Context Protocol, so the coding agent
7
+ you already use — Claude Code, Claude Desktop, or any MCP client — reads the
8
+ real queue and the real metrics instead of scraping the dashboard, then goes
9
+ and implements the work in your site's repo.
10
+
11
+ The division of labour is the point. n-seo decides *what is worth doing*, from
12
+ data, with the evidence attached. Your agent does the doing, in the repo where
13
+ the change belongs, under the rules in `CLAUDE.md`.
14
+
15
+ It is **read-only by design**: the queue is edited through the dashboard's
16
+ accept/watch/retire flow, so nothing here writes anything. All 16 tools and 3
17
+ doc resources are annotated `readOnlyHint`. An agent can reason over your
18
+ search data all day and still cannot bypass the 28-day title freeze, the
19
+ weekly metadata budget, or you.
8
20
 
9
21
  ## Two transports
10
22
 
@@ -65,7 +77,7 @@ another device, tunnel to the dashboard port (Tailscale, `cloudflared`,
65
77
  an SSH tunnel) rather than exposing it — the bearer token is the only thing
66
78
  in front of it.
67
79
 
68
- ## Tools
80
+ ## Tools (16)
69
81
 
70
82
  | Tool | Returns |
71
83
  |---|---|
@@ -84,8 +96,9 @@ in front of it.
84
96
  | `conversions_status` | Whether conversion events are instrumented and their 28-day counts by source |
85
97
  | `campaigns` | Outreach campaigns from `content/campaigns/` — targets, plan, template ids |
86
98
  | `settings` | The effective config: sites, module switches, auth mode (never the key) |
99
+ | `engine_info` | Which engine version, commit and instance directory are running, and which modules are on — check before assuming a feature exists |
87
100
 
88
- Resources: `seo://docs/playbook`, `seo://docs/daily-log`,
101
+ Resources (3): `seo://docs/playbook`, `seo://docs/daily-log`,
89
102
  `seo://docs/operating-rules`.
90
103
 
91
104
  ## Example prompts
@@ -98,6 +111,9 @@ Resources: `seo://docs/playbook`, `seo://docs/daily-log`,
98
111
  - "Summarize the last five daily-log entries — what regressed, what moved."
99
112
  - "Read the opportunity proposals and tell me which ones are worth accepting
100
113
  and why. Don't add anything to the queue."
114
+ - "Take the top striking-distance card for example.com, implement it on a
115
+ branch in the site's repo, and open the PR." — this is the `n-seo-ship`
116
+ skill's job; it stops rather than crossing the freeze or the weekly budget.
101
117
 
102
118
  The rules in `CLAUDE.md` apply to an agent using these tools: it proposes,
103
119
  you accept; it never drafts community comments; it reads outputs rather than
package/docs/PRD.md CHANGED
@@ -52,7 +52,8 @@ with evidence attached, and measure yesterday's changes.
52
52
  - Not an auto-publisher, link builder, or comment bot.
53
53
  - Not a hosted product; there is no account and no server-side component
54
54
  beyond an optional static mirror the owner hosts.
55
- - Windows is not a target (WSL likely works; untested).
55
+ - Not a hosted control panel for other people's sites; one owner, one
56
+ instance, their own credentials.
56
57
 
57
58
  ---
58
59
 
@@ -72,9 +73,10 @@ a fresh checkout runs.
72
73
 
73
74
  ## Feature: Service-account auth without gcloud [shipped]
74
75
 
75
- - Acceptance: with only a service-account JSON key and `openssl` on PATH,
76
- `python3 ingest/google_auth.py` mints a token and lists accessible
77
- properties.
76
+ - Acceptance: with only a service-account JSON key, `n-seo doctor` mints a
77
+ token and lists accessible properties. No gcloud, no pip install and no
78
+ openssl binary: the JWT is signed with node's crypto module, which the
79
+ dashboard already requires.
78
80
  - Acceptance: gcloud impersonation and gcloud user modes remain selectable.
79
81
  - Acceptance: `ops/doctor.py` reports the exact email to add in Search
80
82
  Console and GA4 and which configured properties are not yet accessible.
@@ -100,6 +102,10 @@ a fresh checkout runs.
100
102
  - Acceptance: date × page series for 180 days from both sources.
101
103
  - Acceptance: URL Inspection verdict for every sitemap URL (sitemap indexes
102
104
  followed one level, ≤400 URLs/host), with sitemap submission state.
105
+ - Acceptance: each coverage state renders with what it means, what to do
106
+ about it, and whether Request Indexing helps — true only where Google has
107
+ formed no judgement on the content (unknown, discovered-never-crawled),
108
+ false where it fetched and declined (soft 404, crawled-not-indexed).
103
109
  - Acceptance: metadata audit flags title/query mismatch, CTR below position
104
110
  expectation, missing/short/duplicate descriptions, long titles.
105
111
  - Acceptance: trend file with branded/generic split, rising/falling queries
@@ -146,7 +152,9 @@ the config with the current values as defaults.
146
152
  be marked watching or retired; rule-derived items expose no write buttons.
147
153
  - Acceptance: the server binds 127.0.0.1 by default and rejects cross-origin
148
154
  POSTs.
149
- - Acceptance: light and dark themes; no external fonts.
155
+ - Acceptance: light and dark themes; no external fonts — the one webfont is
156
+ served from the app's own origin, so the dashboard renders identically on a
157
+ host with no outbound internet and leaks no request to a font CDN.
150
158
 
151
159
  ## Feature: Settings page [shipped]
152
160
 
@@ -204,14 +212,22 @@ instead of a CLI, with the key read from `.env`.
204
212
  mode is unchanged when it is unset.
205
213
  - Acceptance: `n-seo init|start|dev|daily|doctor|demo|mcp|check|export|
206
214
  upgrade|version` behave as documented in `docs/INSTANCE.md`.
215
+ - Acceptance: an instance is a standalone project. `n-seo init` exits 2
216
+ without writing anything when the target holds an application marker, or
217
+ when the directory it would create falls inside a git repository that is
218
+ not an instance; `--force` overrides, re-running on an existing instance
219
+ succeeds, and in-place mode in the engine checkout is unaffected.
220
+ - Acceptance: `init` installs the seven instance-facing skills and a
221
+ `CLAUDE.md` with the operating rules, substituting the real engine and
222
+ instance paths, and leaves the contributor (`ndx-*`) skills behind.
207
223
  - Acceptance: `n-seo upgrade` refuses to leave the engine on a commit that
208
224
  fails `npm run check` without printing the rollback command.
209
225
 
210
- ## Feature: Publish the engine to npm [planned]
226
+ ## Feature: Publish the engine to npm [shipped]
211
227
 
212
228
  - Acceptance: `npm i n-seo` installs a working engine; `npx n-seo init`
213
229
  scaffolds an instance; the `files` list excludes tests and the marketing
214
- site.
230
+ site, and includes the instance-facing skills.
215
231
 
216
232
  ## Feature: Scheduled upgrade with gate [planned]
217
233
 
@@ -244,6 +260,10 @@ runs doctor.
244
260
  promises (local data, evidence first, briefs-not-acts) and the operating
245
261
  rules.
246
262
 
247
- ## Feature: Custom domain [planned]
263
+ ## Feature: Custom domain [shipped]
248
264
 
249
- `www/CNAME` and URL updates once a domain is chosen.
265
+ `www/CNAME` carries n-seo.dev; analytics are guarded on the hostname so a
266
+ fork's Pages deploy never reports into the project's property.
267
+
268
+ - Acceptance: typography is self-hosted (latin-subset variable woff2 from the
269
+ page's own origin), so the site makes no third-party request.
@@ -4,6 +4,10 @@ Two things should always be running: the **dashboard** (a long-lived process
4
4
  serving the configured port) and the **daily run** (`ops/daily.py`, once a
5
5
  morning). Both are plain processes; pick the scheduler your OS already has.
6
6
 
7
+ > Or say *"schedule the morning run"* in the instance directory — the
8
+ > `n-seo-deploy` skill covers scheduling on each platform, and `n-seo-setup`
9
+ > ends here.
10
+
7
11
  ## macOS (launchd)
8
12
 
9
13
  The install script fills in the templates under `ops/templates/`, copies them
@@ -79,16 +83,53 @@ loginctl enable-linger "$USER" # keep user units running when you are logged
79
83
  Check on them with `systemctl --user status n-seo-dashboard` and
80
84
  `journalctl --user -u n-seo-daily`.
81
85
 
86
+ ## Windows (Task Scheduler)
87
+
88
+ `ops/templates/n-seo-daily-task.xml` is a ready task definition. Replace the
89
+ two placeholders in it, then register it:
90
+
91
+ ```bat
92
+ where n-seo
93
+ :: put that path in __NSEO__, and your instance directory in __INSTANCE__
94
+ schtasks /create /tn "n-seo daily" /xml ops\templates\n-seo-daily-task.xml
95
+ ```
96
+
97
+ Check it with `schtasks /query /tn "n-seo daily" /v /fo list`, run it now with
98
+ `schtasks /run /tn "n-seo daily"`, remove it with
99
+ `schtasks /delete /tn "n-seo daily"`.
100
+
101
+ The task sets `StartWhenAvailable`, which is the Windows equivalent of the
102
+ launchd behaviour above: a machine that was asleep at 07:00 runs the job when
103
+ it wakes rather than skipping the day the way cron does. It also sets
104
+ `RunOnlyIfNetworkAvailable`, so it will not start into a dead connection.
105
+
106
+ For the dashboard, run `n-seo start` from a terminal, or register a second
107
+ task with the same XML, changing the arguments to `start` and the trigger to
108
+ "At log on".
109
+
110
+ **Hooks run through `cmd.exe` on Windows.** The `hooks` block in your config
111
+ is handed to the system shell, which is `cmd.exe` there and `/bin/sh`
112
+ elsewhere, so a hook written as `foo && bar` behaves but one relying on
113
+ POSIX quoting, `$VAR` or pipelines into Unix tools will not. Point the hook at
114
+ a `.cmd`/`.ps1` script if it needs to do anything shell-specific.
115
+
82
116
  ## Running by hand
83
117
 
84
118
  ```sh
85
- python3 ops/daily.py # everything
86
- python3 ops/daily.py --list # the step names
87
- python3 ops/daily.py --only gsc,ga4 # a subset
88
- python3 ops/daily.py --skip index-status
89
- python3 ops/daily.py --no-network-wait
119
+ n-seo daily # everything
120
+ n-seo daily --list # the step names
121
+ n-seo daily --only gsc,ga4 # a subset
122
+ n-seo daily --skip index-status
123
+ n-seo daily --no-network-wait
90
124
  ```
91
125
 
126
+ The CLI is the portable form: it finds the Python interpreter this machine
127
+ actually has. `python3 ops/daily.py` is equivalent on macOS and Linux, but
128
+ Windows has no `python3` — it installs Python as `python`, and the
129
+ `python3.exe` that Windows ships is an App Execution Alias that opens the
130
+ Microsoft Store instead of running anything. From a checkout, `npm run daily`
131
+ works everywhere for the same reason. `$PYTHON` overrides the choice.
132
+
92
133
  Each run appends to `data/daily-ops.log`, writes `data/last-run.json` (which
93
134
  the dashboard shows as the run status chip), and appends a dated entry to
94
135
  `docs/daily-log.md`. Re-running on the same day replaces that day's entry