n-seo 0.1.0 → 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.
Files changed (71) hide show
  1. package/README.md +132 -46
  2. package/bin/n-seo.mjs +14 -4
  3. package/docs/FAQ.md +14 -3
  4. package/docs/MCP.md +25 -9
  5. package/docs/PRD.md +6 -4
  6. package/docs/RELEASING.md +21 -0
  7. package/docs/SCHEDULING.md +42 -5
  8. package/docs/SETUP-GOOGLE.md +1 -1
  9. package/ingest/__pycache__/analyze_metadata.cpython-312.pyc +0 -0
  10. package/ingest/__pycache__/google_auth.cpython-312.pyc +0 -0
  11. package/ingest/__pycache__/http_util.cpython-312.pyc +0 -0
  12. package/ingest/__pycache__/seo_config.cpython-312.pyc +0 -0
  13. package/ingest/analyze_ga4.py +2 -2
  14. package/ingest/analyze_gsc.py +2 -2
  15. package/ingest/analyze_metadata.py +2 -2
  16. package/ingest/analyze_trends.py +2 -2
  17. package/ingest/google_auth.py +53 -20
  18. package/ingest/pull_ga4.py +1 -1
  19. package/ingest/pull_gsc.py +1 -1
  20. package/ingest/pull_index_status.py +1 -1
  21. package/ingest/pull_timeseries.py +3 -3
  22. package/ingest/seo_config.py +19 -3
  23. package/ops/__pycache__/daily.cpython-312.pyc +0 -0
  24. package/ops/__pycache__/daily_diff.cpython-312.pyc +0 -0
  25. package/ops/__pycache__/demo_data.cpython-312.pyc +0 -0
  26. package/ops/__pycache__/export_static.cpython-312.pyc +0 -0
  27. package/ops/__pycache__/llm.cpython-312.pyc +0 -0
  28. package/ops/__pycache__/publish.cpython-312.pyc +0 -0
  29. package/ops/daily.py +2 -2
  30. package/ops/daily_diff.py +9 -8
  31. package/ops/demo_data.py +17 -17
  32. package/ops/doctor.py +21 -7
  33. package/ops/export_static.py +4 -4
  34. package/ops/hn_digest.py +1 -1
  35. package/ops/indexnow.py +3 -3
  36. package/ops/llm.py +23 -1
  37. package/ops/opportunity_scan.py +4 -4
  38. package/ops/py.mjs +40 -0
  39. package/ops/reddit_digest.py +1 -1
  40. package/ops/templates/n-seo-daily-task.xml +65 -0
  41. package/package.json +16 -9
  42. package/probes/__pycache__/site_probe.cpython-312.pyc +0 -0
  43. package/probes/site_probe.py +1 -1
  44. package/src/actions.ts +1 -1
  45. package/src/config.ts +1 -1
  46. package/src/mcp.ts +2 -2
  47. package/src/settings.tsx +3 -3
  48. package/src/views.tsx +9 -9
  49. package/ingest/__pycache__/analyze_ga4.cpython-313.pyc +0 -0
  50. package/ingest/__pycache__/analyze_gsc.cpython-313.pyc +0 -0
  51. package/ingest/__pycache__/analyze_metadata.cpython-313.pyc +0 -0
  52. package/ingest/__pycache__/analyze_trends.cpython-313.pyc +0 -0
  53. package/ingest/__pycache__/google_auth.cpython-313.pyc +0 -0
  54. package/ingest/__pycache__/http_util.cpython-313.pyc +0 -0
  55. package/ingest/__pycache__/pull_ga4.cpython-313.pyc +0 -0
  56. package/ingest/__pycache__/pull_gsc.cpython-313.pyc +0 -0
  57. package/ingest/__pycache__/pull_index_status.cpython-313.pyc +0 -0
  58. package/ingest/__pycache__/pull_timeseries.cpython-313.pyc +0 -0
  59. package/ingest/__pycache__/seo_config.cpython-313.pyc +0 -0
  60. package/ops/__pycache__/daily.cpython-313.pyc +0 -0
  61. package/ops/__pycache__/daily_diff.cpython-313.pyc +0 -0
  62. package/ops/__pycache__/demo_data.cpython-313.pyc +0 -0
  63. package/ops/__pycache__/doctor.cpython-313.pyc +0 -0
  64. package/ops/__pycache__/export_static.cpython-313.pyc +0 -0
  65. package/ops/__pycache__/hn_digest.cpython-313.pyc +0 -0
  66. package/ops/__pycache__/indexnow.cpython-313.pyc +0 -0
  67. package/ops/__pycache__/llm.cpython-313.pyc +0 -0
  68. package/ops/__pycache__/opportunity_scan.cpython-313.pyc +0 -0
  69. package/ops/__pycache__/publish.cpython-313.pyc +0 -0
  70. package/ops/__pycache__/reddit_digest.cpython-313.pyc +0 -0
  71. package/probes/__pycache__/site_probe.cpython-313.pyc +0 -0
package/README.md CHANGED
@@ -1,39 +1,69 @@
1
- # n-seo
1
+ # n-seo — En Dash SEO
2
2
 
3
- **An SEO, self-hosted.** Website: https://en-dash-consulting.github.io/n-seo/ · From the makers of [n-dx](https://n-dx.dev).
3
+ **An SEO, self-hosted.** Website: https://n-seo.dev/ · From the makers of [n-dx](https://n-dx.dev).
4
4
 
5
- A local-first control plane for growing organic traffic to your own sites —
6
- classic search (SEO), answer engines (AEO) and AI assistants that cite sources
7
- (GEO) — without paying an agency to read Search Console for you.
5
+ [![npm](https://img.shields.io/npm/v/n-seo)](https://www.npmjs.com/package/n-seo)
6
+ [![CI](https://github.com/en-dash-consulting/n-seo/actions/workflows/ci.yml/badge.svg)](https://github.com/en-dash-consulting/n-seo/actions/workflows/ci.yml)
7
+ [![license](https://img.shields.io/npm/l/n-seo)](LICENSE)
8
+
9
+ An agentic, local-first control plane for growing organic traffic to your own
10
+ sites — classic search (SEO), answer engines (AEO) and AI assistants that cite
11
+ sources (GEO) — without paying an agency to read Search Console for you.
8
12
 
9
13
  It pulls Search Console and GA4 into local JSON, probes your live sites for the
10
14
  things that quietly break (robots, sitemap, soft 404s, blocked AI crawlers,
11
15
  JS-only shells), and turns all of it into a **ranked queue of concrete actions**:
12
16
  which page to retitle, which query to answer, which page never got crawled.
13
- It runs on hardware you control, on a schedule, and shows you the result on a dashboard.
14
-
15
- - **Your data stays on your own hardware.** Nothing is sent anywhere except the
16
- Google APIs you authorize and, optionally, a local LLM command you choose.
17
- - **It briefs you; it never posts for you.** Community modules find threads
18
- and write a briefing. The words are always yours.
19
- - **It proposes; you ship.** No module edits your site. The queue tells you
20
- what to change and why, with the numbers; you make the change in your own
21
- repo and the next day's data tells you whether it worked.
17
+ That part is plain deterministic code. Everything that needs judgment is handed
18
+ to **a model you choose**, and everything that changes a site is handed to you.
19
+
20
+ - **Your model, whichever you pick.** Point the `llm` module at any CLI that
21
+ reads a prompt on stdin (`claude -p`, `ollama run`, a shell script) or at an
22
+ HTTP endpoint Anthropic, or anything OpenAI-compatible. It writes the
23
+ proposals, the verdicts on shipped work, and the community briefings. No key
24
+ ships with n-seo. Leave it off and you still get the full data-derived queue.
25
+ - **Your coding agent, on the queue.** A read-only MCP server exposes the same
26
+ data the dashboard reads, so Claude Code — or any MCP client — can answer
27
+ "what should I do first this week?" and then go implement it. Six skills ship
28
+ with it, so the operating rules are enforced rather than merely documented.
29
+ - **It proposes; it never acts.** No module edits a site, sends an email or
30
+ posts a comment. Machine proposals wait in a holding area until you accept
31
+ them. Your data stays on your hardware: nothing leaves the host except the
32
+ Google APIs you authorize and the provider you configured yourself.
22
33
 
23
34
  ![Overview](docs/screenshots/overview.png)
24
35
 
25
36
  ## Quickstart (5 minutes, no Google setup)
26
37
 
38
+ ```sh
39
+ npm install -g n-seo
40
+ n-seo init my-sites && cd my-sites
41
+ n-seo demo # a synthetic dataset for example.com
42
+ n-seo start # dashboard → http://localhost:4600
43
+ ```
44
+
45
+ Open http://localhost:4600. Every page is populated from the demo data, so you
46
+ can see what the tool does before you connect anything. `n-seo demo --clean`
47
+ removes it.
48
+
49
+ Prefer not to install globally? `npx n-seo init my-sites` works the same way.
50
+
51
+ <details>
52
+ <summary>Or run it from a clone, if you want to change the engine itself</summary>
53
+
27
54
  ```sh
28
55
  git clone https://github.com/en-dash-consulting/n-seo.git
29
56
  cd n-seo
30
57
  npm install
31
- npm run demo # writes a synthetic dataset for example.com under data/
32
- npm start # dashboard → http://localhost:4600
58
+ npm run demo
59
+ npm start
33
60
  ```
34
61
 
35
- Open http://localhost:4600. Every page is populated from the demo data, so you
36
- can see what the tool does before you connect anything.
62
+ In this mode the config, queue and data live inside the checkout. That is the
63
+ right shape for hacking on n-seo; for running it, the instance layout above
64
+ keeps your files separate from the engine so upgrades are a reinstall rather
65
+ than a merge. See [docs/INSTANCE.md](docs/INSTANCE.md).
66
+ </details>
37
67
 
38
68
  ## Connect your real sites
39
69
 
@@ -61,6 +91,85 @@ Adding another site later is one entry in the config —
61
91
  Running several people's sites, or want upgrades to be a `git pull`? Keep your
62
92
  config in its own directory — see [docs/INSTANCE.md](docs/INSTANCE.md).
63
93
 
94
+ ## Agentic by design
95
+
96
+ Two things are agentic here, and they are separate. Inside n-seo, the daily run
97
+ hands its findings to a model you configure. Alongside n-seo, your own coding
98
+ agent reads the queue over MCP and does the work.
99
+
100
+ ### Your model, on your findings
101
+
102
+ The `llm` module is off by default. Turn it on and point it at a provider, and
103
+ the morning run stops being a report:
104
+
105
+ - **Proposals** — rising queries nothing in the queue covers become concrete
106
+ cards: the page to write, the section to add, with the numbers cited.
107
+ - **Verdicts** — shipped work is judged against its own success criterion:
108
+ succeeded, failed, or still cooking.
109
+ - **Briefings** — for each community thread worth joining, the gist, the
110
+ debate, and where your genuine experience connects.
111
+
112
+ Reach it two ways, in `n-seo.config.json`:
113
+
114
+ ```jsonc
115
+ // a CLI — anything that reads a prompt on stdin and prints a reply
116
+ "llm": {
117
+ "enabled": true,
118
+ "command": "claude -p --model claude-sonnet-5", // or `llm -m gpt-4o`, `ollama run llama3`, a script
119
+ "fastCommand": "claude -p --model haiku" // optional: cheaper, for the high-volume calls
120
+ }
121
+
122
+ // or an HTTP endpoint — for containers and servers with no CLI signed in
123
+ "llm": {
124
+ "enabled": true,
125
+ "http": {
126
+ "provider": "anthropic", // or "openai" for anything OpenAI-compatible
127
+ "model": "claude-sonnet-5",
128
+ "fastModel": "claude-haiku-4-5-20251001",
129
+ "apiKeyEnv": "ANTHROPIC_API_KEY", // read from the env or .env; no key ships with n-seo
130
+ "baseUrl": "" // optional: a gateway or a local server
131
+ }
132
+ }
133
+ ```
134
+
135
+ `provider: "openai"` speaks the OpenAI chat-completions shape, so it also covers
136
+ the many gateways and local servers that emulate it. `http` wins when it is
137
+ configured and its key resolves; otherwise the CLI path runs. Proposals land in
138
+ a holding area on `/actions` marked PROPOSED; accepting one into the queue is a
139
+ click a human makes. Nothing that comes back is applied automatically.
140
+
141
+ ### Your coding agent, on the queue
142
+
143
+ The repo ships an MCP server over the same data the dashboard reads, so an
144
+ agent can answer "what should I do first this week?" from the actual queue
145
+ instead of scraping pages: 16 tools and 3 doc resources, every one annotated
146
+ read-only. `.mcp.json` registers it for Claude Code automatically; Claude
147
+ Desktop, stdio and authenticated HTTP clients are covered in
148
+ [docs/MCP.md](docs/MCP.md). `CLAUDE.md` holds the operating rules an agent
149
+ working here has to follow.
150
+
151
+ Skills ship for the work itself, so the rules are enforced rather than
152
+ merely documented — six that operate an instance, plus `orient` to get
153
+ current in a fresh session:
154
+
155
+ | Skill | Use it when |
156
+ |---|---|
157
+ | `n-seo-setup` | Fresh clone to first real daily run, including Google access |
158
+ | `n-seo-add-site` | Adding a site: property form, `gscHost`, GA4 id, brand regex, grants |
159
+ | `n-seo-triage` | "What should I work on today" from the queue and the last run |
160
+ | `n-seo-ship` | Implement one queue card in the site's repo, then record it as watching |
161
+ | `n-seo-review` | The weekly pass: judge watching items, retire what is done, refresh insights |
162
+ | `n-seo-deploy` | Moving n-seo off the laptop onto an always-on host, on a schedule |
163
+ | `orient` | First thing in a fresh session — get current in a few reads |
164
+
165
+ `n-seo-ship` stops rather than crossing the 28-day title freeze or the weekly
166
+ metadata budget. `.claude/skills/README.md` explains which skills operate an
167
+ instance and which are contributor tooling for developing the engine with
168
+ [n-dx](https://n-dx.dev).
169
+
170
+ Read-only is the point: an agent can reason over your search data all day and
171
+ still cannot bypass the freeze, the batching, or you.
172
+
64
173
  ## What you get
65
174
 
66
175
  | Page | What it shows |
@@ -99,7 +208,7 @@ on the Settings page or in `n-seo.config.json`.
99
208
  | `indexStatus` | Asks the URL Inspection API whether each sitemap URL is indexed | Search Console access | on |
100
209
  | `metadataAudit` | Fetches each ranking page's live title/description and scores them against its queries | nothing extra | on |
101
210
  | `opportunityScan` | Refreshes trend analysis; flags rising queries no queue item covers | nothing extra | on |
102
- | `llm` | Runs a local command (default: the `claude` CLI) for scan proposals and digest briefings | any CLI that reads a prompt on stdin | off |
211
+ | `llm` | Hands the run's findings to the model you choose: scan proposals, verdicts on shipped work, community briefings | a provider you configure: any stdin CLI, or an Anthropic / OpenAI-compatible endpoint | off |
103
212
  | `hackerNews` | Finds fresh HN threads in your expertise areas and briefs you | your HN username (optional) | off |
104
213
  | `reddit` | The same for subreddits | a free Reddit "script" app's credentials in `.env` | off |
105
214
  | `indexNow` | Generates a key and pings Bing/Copilot/Yandex with changed URLs | nothing (does nothing for Google) | off |
@@ -107,30 +216,6 @@ on the Settings page or in `n-seo.config.json`.
107
216
  | `gitAutoCommit` | Commits (and pushes) the daily log and export after each run | a git remote, if you want the push | off |
108
217
  | `notifications` | macOS notification when a daily step fails | macOS | off |
109
218
 
110
- ## Working with an AI agent
111
-
112
- The repo ships an MCP server over the same data the dashboard reads, so an
113
- agent can answer "what should I do first this week?" from the actual queue
114
- instead of scraping pages. `.mcp.json` registers it for Claude Code
115
- automatically; Claude Desktop and HTTP clients are covered in
116
- [docs/MCP.md](docs/MCP.md). It is read-only by design, and `CLAUDE.md` holds
117
- the operating rules an agent working here has to follow.
118
-
119
- It also ships skills for the work itself, so the rules are enforced rather
120
- than merely documented:
121
-
122
- | Skill | Use it when |
123
- |---|---|
124
- | `orient` | First thing in a fresh session — get current in a few reads |
125
- | `n-seo-setup` | Fresh clone to first real daily run, including Google access |
126
- | `n-seo-add-site` | Adding a site: property form, `gscHost`, GA4 id, brand regex, grants |
127
- | `n-seo-triage` | "What should I work on today" from the queue and the last run |
128
- | `n-seo-ship` | Implement one queue card in the site's repo, then record it as watching |
129
- | `n-seo-review` | The weekly pass: judge watching items, retire what is done, refresh insights |
130
-
131
- `.claude/skills/README.md` explains which skills operate an instance and which
132
- are contributor tooling for developing the engine with [n-dx](https://n-dx.dev).
133
-
134
219
  ## Working with n-dx
135
220
 
136
221
  The repo is wired for [n-dx](https://n-dx.dev): `docs/PRD.md` is the product
@@ -175,9 +260,10 @@ accept; community participation is human. The reasoning is in
175
260
 
176
261
  ## Requirements
177
262
 
178
- Node 20 or newer, Python 3.10 or newer, `curl`, `openssl`. macOS or Linux.
179
- Python is stdlib-only (no pip); the TypeScript app runs under `tsx` with no
180
- build step.
263
+ Node 20 or newer, Python 3.10 or newer, and `curl`. macOS, Linux or Windows
264
+ 10+, all three exercised by CI on every commit. Python is stdlib-only (no
265
+ pip); the TypeScript app runs under `tsx` with no build step. Nothing needs
266
+ `openssl`, `gcloud` or a compiler.
181
267
 
182
268
  ## Contributing and license
183
269
 
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";
@@ -121,6 +122,15 @@ function tsxBin() {
121
122
  }
122
123
  }
123
124
 
125
+ /* npm, npx and similar arrive as `.cmd` batch shims on Windows, which
126
+ * spawnSync cannot execute without a shell — a bare `npm` fails there with
127
+ * ENOENT. (The Python interpreter has the same problem; ops/py.mjs solves
128
+ * it, and this file imports pythonBin from there so the CLI and the npm
129
+ * scripts resolve the interpreter identically.) */
130
+ function npmBin(name = "npm") {
131
+ return process.platform === "win32" ? `${name}.cmd` : name;
132
+ }
133
+
124
134
  function missingDeps(cmd, what) {
125
135
  const installed = ROOT.includes(`${path.sep}node_modules${path.sep}`);
126
136
  console.error(`${cmd} needs ${what}.`);
@@ -229,14 +239,14 @@ function upgrade(instance) {
229
239
  }
230
240
  if (sha256(path.join(ROOT, "package-lock.json")) !== lockBefore || !fs.existsSync(path.join(ROOT, "node_modules"))) {
231
241
  console.log("lockfile changed — npm ci");
232
- r = spawnSync("npm", ["ci"], { cwd: ROOT, stdio: "inherit" });
242
+ r = spawnSync(npmBin(), ["ci"], { cwd: ROOT, stdio: "inherit" });
233
243
  if (r.status !== 0) {
234
244
  console.error(`npm ci failed. Roll back with:\n git -C ${ROOT} reset --hard ${before}`);
235
245
  return r.status ?? 1;
236
246
  }
237
247
  }
238
248
  console.log("running the engine's checks");
239
- r = spawnSync("npm", ["run", "check"], { cwd: ROOT, stdio: "inherit", env: { ...process.env, N_SEO_INSTANCE: instance } });
249
+ r = spawnSync(npmBin(), ["run", "check"], { cwd: ROOT, stdio: "inherit", env: { ...process.env, N_SEO_INSTANCE: instance } });
240
250
  if (r.status !== 0) {
241
251
  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
252
  return 1;
@@ -249,7 +259,7 @@ function upgrade(instance) {
249
259
 
250
260
  const { cmd, instance: flag, rest } = parseArgs(process.argv.slice(2));
251
261
  const instance = resolveInstance(flag);
252
- const py = process.env.PYTHON ?? "python3";
262
+ const py = pythonBin();
253
263
  let code = 0;
254
264
 
255
265
  switch (cmd) {
@@ -274,7 +284,7 @@ switch (cmd) {
274
284
  case "check":
275
285
  // The self-test needs devDependencies, which an npm install omits.
276
286
  code = resolvePkgDir("typescript")
277
- ? run("npm", ["run", "check", "--silent", "--", ...rest], instance)
287
+ ? run(npmBin(), ["run", "check", "--silent", "--", ...rest], instance)
278
288
  : missingDeps(cmd, "the engine's dev dependencies (it is the engine's own test suite)");
279
289
  break;
280
290
  case "daily":
package/docs/FAQ.md CHANGED
@@ -28,8 +28,19 @@ proposals and verdicts to the opportunity scan and briefings to the digests.
28
28
  Nothing it produces is applied automatically.
29
29
 
30
30
  **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.
31
+ Yes, since 0.2.0, and CI runs the full test suite on Windows on every commit
32
+ rather than taking the claim on trust. You need Node 20+ and Python 3.10+;
33
+ `curl` ships with Windows 10 and later. There is no `openssl` requirement on
34
+ any platform any more — the service-account JWT is signed with node's crypto
35
+ module. Schedule the daily run with Task Scheduler, using the task definition
36
+ in `ops/templates/n-seo-daily-task.xml` (see [SCHEDULING.md](SCHEDULING.md)).
37
+
38
+ Two differences worth knowing. Commands in the `hooks` block go to `cmd.exe`
39
+ rather than `/bin/sh`, so write them in its syntax or point them at a script.
40
+ And use `n-seo daily` (or `npm run daily`) rather than `python3 ops/daily.py`:
41
+ Windows installs Python as `python`, and the `python3.exe` it ships is a stub
42
+ that opens the Microsoft Store instead of running anything. The CLI finds the
43
+ real interpreter for you, and `$PYTHON` overrides it.
33
44
 
34
45
  **Where does my data go?**
35
46
  Into `data/` on the machine that runs the pipeline, as JSON. The only network
@@ -83,7 +94,7 @@ behind it; the page flags verdicts older than 90 days as stale. Request
83
94
  indexing and re-check rather than chasing a template bug.
84
95
 
85
96
  **A daily step failed. What now?**
86
- Open `/logs` (or `data/daily-ops.log`), then `python3 ops/doctor.py`. A 401
97
+ Open `/logs` (or `data/daily-ops.log`), then run `n-seo doctor`. A 401
87
98
  or 403 from Google means the service account lost access or the key file
88
99
  moved. A run that fails every network step at once was offline; the next run
89
100
  will recover. Steps are independent — one failing does not stop the others.
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.
package/docs/RELEASING.md CHANGED
@@ -139,6 +139,27 @@ workflow also deliberately omits `registry-url` from `setup-node`, because it
139
139
  writes an `.npmrc` auth line interpolating `NODE_AUTH_TOKEN`, and an empty
140
140
  value there produces the same misleading 404.
141
141
 
142
+ ## Approving a staged release
143
+
144
+ The trusted publisher is configured to allow `npm stage publish` only, so the
145
+ workflow submits a version and stops. It is not on the registry until a human
146
+ approves it with 2FA. That is deliberate: a compromised runner or a stray tag
147
+ cannot put code on npm that other people's machines will then execute.
148
+
149
+ After the release run goes green:
150
+
151
+ ```sh
152
+ npm stage list n-seo # the stage id
153
+ npm stage view <stage-id> # inspect exactly what CI built
154
+ npm stage approve <stage-id> # 2FA; now it is live
155
+ ```
156
+
157
+ `npm stage reject <stage-id>` throws it away. The package's Staged Packages
158
+ tab on npmjs.com does the same thing in a browser, which is easier if your
159
+ second factor is a passkey rather than an authenticator app.
160
+
161
+ The workflow's run summary prints these commands with the version filled in.
162
+
142
163
  ## Verify what was published
143
164
 
144
165
  Do not trust the workflow's own output. Install from the registry, into an
@@ -79,16 +79,53 @@ loginctl enable-linger "$USER" # keep user units running when you are logged
79
79
  Check on them with `systemctl --user status n-seo-dashboard` and
80
80
  `journalctl --user -u n-seo-daily`.
81
81
 
82
+ ## Windows (Task Scheduler)
83
+
84
+ `ops/templates/n-seo-daily-task.xml` is a ready task definition. Replace the
85
+ two placeholders in it, then register it:
86
+
87
+ ```bat
88
+ where n-seo
89
+ :: put that path in __NSEO__, and your instance directory in __INSTANCE__
90
+ schtasks /create /tn "n-seo daily" /xml ops\templates\n-seo-daily-task.xml
91
+ ```
92
+
93
+ Check it with `schtasks /query /tn "n-seo daily" /v /fo list`, run it now with
94
+ `schtasks /run /tn "n-seo daily"`, remove it with
95
+ `schtasks /delete /tn "n-seo daily"`.
96
+
97
+ The task sets `StartWhenAvailable`, which is the Windows equivalent of the
98
+ launchd behaviour above: a machine that was asleep at 07:00 runs the job when
99
+ it wakes rather than skipping the day the way cron does. It also sets
100
+ `RunOnlyIfNetworkAvailable`, so it will not start into a dead connection.
101
+
102
+ For the dashboard, run `n-seo start` from a terminal, or register a second
103
+ task with the same XML, changing the arguments to `start` and the trigger to
104
+ "At log on".
105
+
106
+ **Hooks run through `cmd.exe` on Windows.** The `hooks` block in your config
107
+ is handed to the system shell, which is `cmd.exe` there and `/bin/sh`
108
+ elsewhere, so a hook written as `foo && bar` behaves but one relying on
109
+ POSIX quoting, `$VAR` or pipelines into Unix tools will not. Point the hook at
110
+ a `.cmd`/`.ps1` script if it needs to do anything shell-specific.
111
+
82
112
  ## Running by hand
83
113
 
84
114
  ```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
115
+ n-seo daily # everything
116
+ n-seo daily --list # the step names
117
+ n-seo daily --only gsc,ga4 # a subset
118
+ n-seo daily --skip index-status
119
+ n-seo daily --no-network-wait
90
120
  ```
91
121
 
122
+ The CLI is the portable form: it finds the Python interpreter this machine
123
+ actually has. `python3 ops/daily.py` is equivalent on macOS and Linux, but
124
+ Windows has no `python3` — it installs Python as `python`, and the
125
+ `python3.exe` that Windows ships is an App Execution Alias that opens the
126
+ Microsoft Store instead of running anything. From a checkout, `npm run daily`
127
+ works everywhere for the same reason. `$PYTHON` overrides the choice.
128
+
92
129
  Each run appends to `data/daily-ops.log`, writes `data/last-run.json` (which
93
130
  the dashboard shows as the run status chip), and appends a dated entry to
94
131
  `docs/daily-log.md`. Re-running on the same day replaces that day's entry
@@ -120,7 +120,7 @@ Set `google.auth` to one of:
120
120
 
121
121
  | Mode | How it gets a token | When to use |
122
122
  |---|---|---|
123
- | `service-account-key` | Signs an OAuth JWT with the key file using `openssl` | Default. No extra tools |
123
+ | `service-account-key` | Signs an OAuth JWT with the key file, using node's crypto module | Default. No extra tools, on any platform |
124
124
  | `gcloud-impersonate` | `gcloud auth print-access-token --impersonate-service-account=<google.impersonate>` | You already use gcloud and would rather grant your user *Service Account Token Creator* on the SA than keep a key file. Still add the SA to the consoles as above |
125
125
  | `gcloud-user` | `gcloud auth print-access-token` for your own login | Rarely works: gcloud's default client does not carry the Search Console or Analytics scopes for user credentials, and Google blocks `application-default login` with those scopes. Kept for completeness |
126
126
  | `metadata` | The runtime service account from the GCE / Cloud Run / GKE metadata server, exchanged for a scoped token | Running on Google Cloud. No key file exists, so none can leak |
@@ -24,7 +24,7 @@ def rows(site, name):
24
24
  p = GA_DIR / site / f"{name}.json"
25
25
  if not p.exists():
26
26
  return []
27
- d = json.loads(p.read_text())
27
+ d = json.loads(p.read_text(encoding="utf-8"))
28
28
  out = []
29
29
  for r in d.get("rows", []):
30
30
  dims = [v["value"] for v in r.get("dimensionValues", [])]
@@ -69,7 +69,7 @@ def main():
69
69
  dest_dir = seo_config.INSTANCE / "docs" / "reports"
70
70
  dest_dir.mkdir(parents=True, exist_ok=True)
71
71
  dest = dest_dir / f"ga4-findings-{date.today():%Y-%m}.md"
72
- dest.write_text(text)
72
+ dest.write_text(text, encoding="utf-8")
73
73
  print(text)
74
74
  print(f"[saved to {dest}]")
75
75
  return 0
@@ -19,7 +19,7 @@ EXPECTED_CTR = {1: 0.28, 2: 0.15, 3: 0.10, 4: 0.07, 5: 0.05, 6: 0.04}
19
19
 
20
20
  def load(slug, dataset):
21
21
  p = seo_config.DATA / "gsc" / slug / f"{dataset}.json"
22
- return json.loads(p.read_text())["rows"] if p.exists() else []
22
+ return json.loads(p.read_text(encoding="utf-8"))["rows"] if p.exists() else []
23
23
 
24
24
 
25
25
  def fmt_pct(x):
@@ -126,7 +126,7 @@ def main():
126
126
  dest_dir = seo_config.INSTANCE / "docs" / "reports"
127
127
  dest_dir.mkdir(parents=True, exist_ok=True)
128
128
  dest = dest_dir / f"gsc-findings-{date.today():%Y-%m}.md"
129
- dest.write_text(text)
129
+ dest.write_text(text, encoding="utf-8")
130
130
  print(text)
131
131
  print(f"\n[saved to {dest}]")
132
132
  return 0
@@ -73,7 +73,7 @@ def main():
73
73
  host = site["gscHost"]
74
74
  cache_key = str(gsc_file)
75
75
  if cache_key not in qp_cache:
76
- qp_cache[cache_key] = json.loads(gsc_file.read_text())["rows"]
76
+ qp_cache[cache_key] = json.loads(gsc_file.read_text(encoding="utf-8"))["rows"]
77
77
  rows = [r for r in qp_cache[cache_key]
78
78
  if r["keys"][1].split("/")[2] == host]
79
79
 
@@ -149,7 +149,7 @@ def main():
149
149
  print(f"{site['host']:28s} {len(pages)} pages audited, {len(findings)} with findings")
150
150
 
151
151
  seo_config.DATA.mkdir(parents=True, exist_ok=True)
152
- (seo_config.DATA / "metadata-audit.json").write_text(json.dumps(audit, indent=1))
152
+ (seo_config.DATA / "metadata-audit.json").write_text(json.dumps(audit, indent=1), encoding="utf-8")
153
153
  print("saved data/metadata-audit.json")
154
154
  return 0
155
155
 
@@ -93,7 +93,7 @@ def main():
93
93
  p = seo_config.DATA / "gsc" / slug / "dates.json"
94
94
  if p.exists():
95
95
  monthly = {}
96
- for r in json.loads(p.read_text())["rows"]:
96
+ for r in json.loads(p.read_text(encoding="utf-8"))["rows"]:
97
97
  m = r["keys"][0][:7]
98
98
  cur = monthly.setdefault(m, {"clicks": 0, "imps": 0})
99
99
  cur["clicks"] += r["clicks"]
@@ -122,7 +122,7 @@ def main():
122
122
 
123
123
  seo_config.DATA.mkdir(parents=True, exist_ok=True)
124
124
  dest = seo_config.DATA / f"trends-{date.today().isoformat()}.json"
125
- dest.write_text(json.dumps(out, indent=1))
125
+ dest.write_text(json.dumps(out, indent=1), encoding="utf-8")
126
126
  print(f"saved {dest}\n")
127
127
 
128
128
  for site, d in out["sites"].items():