@aiguru/google-web-operations 0.6.4

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 (116) hide show
  1. package/.env.example +42 -0
  2. package/README.md +110 -0
  3. package/apps/cli/src/index.js +286 -0
  4. package/apps/mcp-server/src/index.js +360 -0
  5. package/apps/worker/src/index.js +96 -0
  6. package/apps/worker/src/loop.js +46 -0
  7. package/bin/gwo.js +67 -0
  8. package/docs/BOOTSTRAP-NEXT-PROMPT.md +57 -0
  9. package/docs/GETTING-STARTED.he.md +63 -0
  10. package/docs/HOSTED-WORKER.md +36 -0
  11. package/docs/INSTALL.he.md +57 -0
  12. package/docs/PRODUCT-TECHNICAL-SPEC-v0.2.md +153 -0
  13. package/docs/PRODUCT-TECHNICAL-SPEC-v0.3.md +86 -0
  14. package/docs/PRODUCT-TECHNICAL-SPEC-v0.4.md +92 -0
  15. package/docs/PRODUCT-TECHNICAL-SPEC-v0.5.md +94 -0
  16. package/docs/PRODUCT-TECHNICAL-SPEC-v0.6.md +98 -0
  17. package/docs/SECURITY.md +69 -0
  18. package/docs/SITE-ADAPTERS.md +87 -0
  19. package/docs/adr/ADR-001-shared-mcp-core.md +27 -0
  20. package/docs/adr/ADR-002-policy-gated-writes.md +18 -0
  21. package/docs/adr/ADR-003-single-source-playbooks.md +18 -0
  22. package/docs/adr/ADR-004-native-typescript-runtime.md +16 -0
  23. package/docs/adr/ADR-005-two-token-publish.md +22 -0
  24. package/docs/adr/ADR-006-one-package-one-installer.md +20 -0
  25. package/hosts/claude/.claude-plugin/plugin.json +9 -0
  26. package/hosts/claude/.mcp.json +10 -0
  27. package/hosts/claude/README.md +7 -0
  28. package/hosts/claude/commands/gwo-add-site.md +7 -0
  29. package/hosts/claude/commands/gwo-daily.md +7 -0
  30. package/hosts/claude/commands/gwo-measure.md +7 -0
  31. package/hosts/claude/commands/gwo-monthly.md +7 -0
  32. package/hosts/claude/commands/gwo-onboard.md +7 -0
  33. package/hosts/claude/commands/gwo-scan.md +7 -0
  34. package/hosts/claude/commands/gwo-setup.md +7 -0
  35. package/hosts/claude/commands/gwo-triage.md +7 -0
  36. package/hosts/claude/commands/gwo-weekly.md +7 -0
  37. package/hosts/claude/skills/gwo-client-report-template/SKILL.md +36 -0
  38. package/hosts/claude/skills/gwo-content-brief/SKILL.md +60 -0
  39. package/hosts/claude/skills/gwo-daily/SKILL.md +47 -0
  40. package/hosts/claude/skills/gwo-measurement/SKILL.md +92 -0
  41. package/hosts/claude/skills/gwo-monthly/SKILL.md +30 -0
  42. package/hosts/claude/skills/gwo-onboarding/SKILL.md +27 -0
  43. package/hosts/claude/skills/gwo-operating-model/SKILL.md +76 -0
  44. package/hosts/claude/skills/gwo-setup/SKILL.md +42 -0
  45. package/hosts/claude/skills/gwo-triage/SKILL.md +37 -0
  46. package/hosts/claude/skills/gwo-weekly/SKILL.md +48 -0
  47. package/hosts/codex/.codex/config.template.toml +5 -0
  48. package/hosts/codex/.codex-plugin/plugin.json +30 -0
  49. package/hosts/codex/AGENTS.md +470 -0
  50. package/hosts/codex/README.md +13 -0
  51. package/hosts/codex/skills/gwo-client-report-template/SKILL.md +36 -0
  52. package/hosts/codex/skills/gwo-content-brief/SKILL.md +60 -0
  53. package/hosts/codex/skills/gwo-daily/SKILL.md +47 -0
  54. package/hosts/codex/skills/gwo-measurement/SKILL.md +92 -0
  55. package/hosts/codex/skills/gwo-monthly/SKILL.md +30 -0
  56. package/hosts/codex/skills/gwo-onboarding/SKILL.md +27 -0
  57. package/hosts/codex/skills/gwo-operating-model/SKILL.md +76 -0
  58. package/hosts/codex/skills/gwo-setup/SKILL.md +42 -0
  59. package/hosts/codex/skills/gwo-triage/SKILL.md +37 -0
  60. package/hosts/codex/skills/gwo-weekly/SKILL.md +48 -0
  61. package/package.json +24 -0
  62. package/packages/connectors/browser/src/index.js +122 -0
  63. package/packages/connectors/docx/src/index.js +122 -0
  64. package/packages/connectors/github/src/index.js +102 -0
  65. package/packages/connectors/google/src/auth.js +73 -0
  66. package/packages/connectors/google/src/discover.js +135 -0
  67. package/packages/connectors/google/src/ga4.js +42 -0
  68. package/packages/connectors/google/src/gsc.js +49 -0
  69. package/packages/connectors/google/src/gtm.js +261 -0
  70. package/packages/connectors/google/src/index.js +8 -0
  71. package/packages/connectors/google/src/oauth.js +198 -0
  72. package/packages/connectors/google/src/pagespeed.js +48 -0
  73. package/packages/connectors/google/src/token-store.js +89 -0
  74. package/packages/connectors/odoo/src/index.js +206 -0
  75. package/packages/connectors/repo/src/index.js +94 -0
  76. package/packages/connectors/resend/src/index.js +30 -0
  77. package/packages/connectors/web-crawler/src/a11y.js +91 -0
  78. package/packages/connectors/web-crawler/src/fetch.js +165 -0
  79. package/packages/connectors/web-crawler/src/index.js +69 -0
  80. package/packages/connectors/web-crawler/src/parse.js +157 -0
  81. package/packages/core/src/adapters.js +27 -0
  82. package/packages/core/src/admin.js +232 -0
  83. package/packages/core/src/content.js +213 -0
  84. package/packages/core/src/doctor.js +39 -0
  85. package/packages/core/src/escalate.js +36 -0
  86. package/packages/core/src/gtm-ops.js +311 -0
  87. package/packages/core/src/index.js +13 -0
  88. package/packages/core/src/operations.js +239 -0
  89. package/packages/core/src/paths.js +46 -0
  90. package/packages/core/src/registry.js +114 -0
  91. package/packages/core/src/report/monthly.js +272 -0
  92. package/packages/core/src/scan/render.js +40 -0
  93. package/packages/core/src/scan/rules.js +289 -0
  94. package/packages/core/src/scan/suppress.js +37 -0
  95. package/packages/core/src/types.js +10 -0
  96. package/packages/core/src/writes.js +50 -0
  97. package/packages/playbooks/00-operating-model.md +74 -0
  98. package/packages/playbooks/10-daily.md +45 -0
  99. package/packages/playbooks/20-weekly.md +46 -0
  100. package/packages/playbooks/30-monthly.md +28 -0
  101. package/packages/playbooks/40-triage.md +35 -0
  102. package/packages/playbooks/50-onboarding.md +25 -0
  103. package/packages/playbooks/60-content-brief.md +58 -0
  104. package/packages/playbooks/70-measurement.md +90 -0
  105. package/packages/playbooks/80-setup.md +40 -0
  106. package/packages/playbooks/90-client-report-template.md +34 -0
  107. package/packages/policy/src/index.js +115 -0
  108. package/packages/storage/src/index.js +131 -0
  109. package/policies/default.yaml +90 -0
  110. package/sites/schema.yaml +71 -0
  111. package/sites/secrets.example.yaml +6 -0
  112. package/tools/build-hosts.js +118 -0
  113. package/tools/build-package.js +43 -0
  114. package/tools/install.js +145 -0
  115. package/tools/schedule.ps1 +37 -0
  116. package/tools/smoke-mcp.js +61 -0
package/.env.example ADDED
@@ -0,0 +1,42 @@
1
+ # Copy to .env. Never commit .env.
2
+
3
+ # --- Google ------------------------------------------------------------------
4
+ # Durable path (recommended): put the "Desktop app" OAuth client JSON from
5
+ # Google Cloud Console at secrets/oauth-client.json and run once:
6
+ # npm run cli -- auth login
7
+ # The refresh token is stored encrypted under secrets/ (DPAPI on Windows,
8
+ # AES-256-GCM elsewhere). Nothing else is needed in this file.
9
+
10
+ # Development bridge only: a short-lived bearer token, expires in ~1 hour.
11
+ # Used when no stored authorisation exists, or when GWO_GOOGLE_AUTH=env.
12
+ GOOGLE_ACCESS_TOKEN=
13
+ GWO_GOOGLE_AUTH=
14
+
15
+ # Optional. PageSpeed works without a key at a lower quota.
16
+ GOOGLE_PAGESPEED_API_KEY=
17
+
18
+ # --- Paths (optional overrides) ---------------------------------------------
19
+ # Defaults resolve relative to the repository root, not the working directory.
20
+ GWO_SITES_DIR=
21
+ GWO_DATA_DIR=
22
+ GWO_POLICY_FILE=
23
+ GWO_SECRETS_DIR=
24
+ # Force the portable AES store on Windows (tests, CI): GWO_TOKEN_STORE=aes
25
+ GWO_TOKEN_STORE=
26
+
27
+ # --- Client reports ---------------------------------------------------------
28
+ # A .dotx/.docx whose header, footer and styles the monthly client report inherits.
29
+ # Without it the report is a plain, correctly styled document.
30
+ GWO_DOCX_TEMPLATE=
31
+
32
+ # --- Delivery (worker --deliver) -------------------------------------------
33
+ RESEND_API_KEY=
34
+ GWO_REPORT_FROM=ops@aiguru.co.il
35
+ GWO_REPORT_TO=ai@aiguru.co.il
36
+
37
+ # --- GitHub (pull requests for repository-backed sites) --------------------
38
+ # Fine-grained token: Contents write + Pull requests write on the site repos.
39
+ GITHUB_TOKEN=
40
+
41
+ # --- NPM JS ACCESS TOKEN - gwo-release --------------------
42
+ NPM_TOKEN=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
package/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # Google Web Operations
2
+
3
+ Agent-run SEO and GEO operations for a portfolio of client websites. One repository, one core, one MCP server, two hosts: Claude Code and Codex. A scheduled worker runs the sensing half without any agent.
4
+
5
+ Repository: https://github.com/ai-gur/GWeb-Ops
6
+
7
+ ## What it does
8
+
9
+ For each registered site: observe the live site and (with access) Search Console, Analytics, Tag Manager and PageSpeed; turn observations into ranked findings; decide against a playbook; act within a strict permission model; verify every write; record everything.
10
+
11
+ Daily health pass, weekly improvement pass, monthly deep scan and client report, incident triage, onboarding.
12
+
13
+ ## Install (one package, both hosts)
14
+
15
+ Needs Node 22.18 or newer. The compiled package is published to the public npm registry on every release (the source repository stays private):
16
+
17
+ ```
18
+ npm i -g @aiguru/google-web-operations # latest release
19
+ gwo install # MCP server + skills + slash commands for Claude Code and Codex; creates ~/.gwo
20
+ gwo update # later: fetch the latest release and re-register the hosts
21
+ ```
22
+
23
+ The release page carries the same tarball (`google-web-operations-latest.tgz` and a versioned copy) for machines without registry access: `npm i -g ./google-web-operations-latest.tgz`. Until the `NPM_TOKEN` secret is set in the repository, releases go to GitHub Packages as `@ai-gur/google-web-operations` instead, which needs a GitHub token with `read:packages` in `~/.npmrc`.
24
+
25
+ Restart the hosts, run `gwo doctor` to see where things stand, then start with `/gwo-setup` (Claude Code) or "set up Google Web Operations" (Codex): it connects the Google Cloud project, authorises the operations account and registers the first site, all from the chat. Operator state (sites, data, secrets, `.env`) lives in `~/.gwo` (`GWO_HOME`) and survives upgrades; `gwo install` again after `npm i -g` of a newer version.
26
+
27
+ `gwo install claude`, `gwo install codex`, `--print` to preview, `gwo uninstall` to remove the host entries. A locally built tarball installs with `npm i -g ./dist/aiguru-google-web-operations-<version>.tgz` (the `./` matters, or npm reads it as a package name).
28
+
29
+ ## Development (repository checkout)
30
+
31
+ ```
32
+ npm install
33
+ npm run check # typecheck, tests, regenerate hosts, build the package, run its bin
34
+ npm run pack:release # dist/aiguru-google-web-operations-<version>.tgz (compiled JavaScript)
35
+ npm run cli -- sites
36
+ npm run cli -- scan aiguru # crawls, runs 40+ rules, saves scan + snapshot
37
+ npm run smoke:mcp # boots the MCP server and lists tools
38
+ node tools/install.ts --print # what gwo install would do from this checkout
39
+ ```
40
+
41
+ In a checkout, state stays next to the code (`sites/`, `.gwo-data/`, `secrets/`, `.env`). Site scans need no credentials. Google tools need one consent for the operations account, from the chat (`/gwo-setup`) or the CLI:
42
+
43
+ ```
44
+ npm run cli -- auth login # browser consent once; token stored encrypted under secrets/
45
+ npm run cli -- discover --apply # fills Search Console and GA4 ids into sites/*.yaml
46
+ ```
47
+
48
+ ### Commands
49
+
50
+ `/gwo-setup`, `/gwo-add-site <url>`, `/gwo-daily`, `/gwo-weekly`, `/gwo-monthly`, `/gwo-triage <site>`, `/gwo-onboard <site>`, `/gwo-scan <site>`, `/gwo-measure <site>`. Codex reads the same skills; `hosts/codex/AGENTS.md` is there for projects that take instructions rather than skills.
51
+
52
+ ### Rendered checks (optional)
53
+
54
+ `web_inspect_rendered` and `gwo inspect <url> --rendered` need Playwright once per machine: `npm i -g playwright && npx playwright install chromium`. Without it every other tool works; the rendered check explains what to install.
55
+
56
+ ### Worker (scheduled)
57
+
58
+ ```
59
+ gwo worker daily # scan, diff, escalate, report
60
+ gwo worker weekly --deliver # also email the report through Resend
61
+ gwo schedule install # Windows Task Scheduler: 07:00 daily, Monday 07:30
62
+ ```
63
+
64
+ Exit code 0 healthy, 2 escalations to triage, 1 scan failures. Reports are kept under `<home>/.gwo-data/_runs/`. For an always-on host, `gwo worker loop` or the container in `deploy/` (`docs/HOSTED-WORKER.md`).
65
+
66
+ ## Layout
67
+
68
+ ```
69
+ apps/
70
+ mcp-server/ one server for both hosts
71
+ worker/ scheduled sensing with escalation rules, no agent needed
72
+ cli/ local admin
73
+ packages/
74
+ core/ registry, operations engine, rule engine, write loop, types
75
+ connectors/
76
+ web-crawler/ fetch, robots, sitemap, parse
77
+ google/ OAuth, Search Console, GA4, Tag Manager, PageSpeed, discovery
78
+ odoo/ JSON-RPC site adapter
79
+ repo/ gwo.seo.json manifest adapter
80
+ github/ pull requests for repository-backed sites
81
+ resend/ report delivery
82
+ policy/ risk classes and enforcement
83
+ storage/ scans, snapshots, GTM snapshots, audit log
84
+ playbooks/ THE source of operating knowledge
85
+ hosts/ GENERATED from playbooks. Never edit.
86
+ sites/ one YAML per site (in ~/.gwo/sites when installed). secrets in *.secrets.yaml (gitignored)
87
+ policies/ default.yaml, the permission model
88
+ docs/ spec, security, adapters, ADRs, next-session bootstrap
89
+ tests/
90
+ tools/ build-hosts, install (gwo install), smoke-mcp, schedule.ps1
91
+ bin/ gwo, the package executable
92
+ ```
93
+
94
+ ## Permission model
95
+
96
+ | Class | Examples | Gate |
97
+ |---|---|---|
98
+ | `auto` | read, scan, report, submit sitemap, fill an empty meta description | none |
99
+ | `approval_required` | any content, title, schema, canonical, redirect, tag or config change; pull requests | token `<site_id>:<action>` in-session; a Tag Manager publish also needs the review token of the diff shown (ADR-005) |
100
+ | `protected` | delete property or container, account, retention, access, billing | never |
101
+
102
+ Defined in `policies/default.yaml`, enforced in `packages/policy`. Every write runs through `performWrite()`: enforce, read before, execute, read after, verify, record.
103
+
104
+ ## Runtime
105
+
106
+ TypeScript, executed natively by Node 22.18+ (type stripping). No build step, no transpiler at runtime. `tsc --noEmit` checks types. See ADR-004.
107
+
108
+ ## Status
109
+
110
+ v0.6. Installable as one npm package with `gwo install` for both hosts and `gwo update` for the latest release (ADR-006); `gwo doctor` says what is missing. The scanner has an IS 5568 accessibility area, `web_inspect_rendered` compares static HTML with the rendered DOM (Playwright, optional), the worker runs hosted (`deploy/`, `docs/HOSTED-WORKER.md`), and the Odoo adapter addresses product pages. Hebrew quick start: `docs/GETTING-STARTED.he.md`. Site sensing, rule engine, policy, storage, playbooks, hosts, worker and CLI are live on two pilot sites. Google access is durable; Search Console, GA4, PageSpeed and Tag Manager reads are verified live. Site adapters for Odoo and repository-backed sites exist. Writes through the loop: sitemap submission, meta descriptions, titles, structured data (Odoo view arch or repository manifest), GA4 key events, pull requests, and Tag Manager changes staged in a `gwo-` workspace, diffed, and published under two tokens. The monthly client report renders to a Word document in the company template. The worker runs on a schedule with escalation rules and optional email delivery. Setup and site registration run from the chat. 44 MCP tools, 10 skills. See `docs/PRODUCT-TECHNICAL-SPEC-v0.6.md` and `docs/BOOTSTRAP-NEXT-PROMPT.md`.
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Local admin CLI. Exercises the core without any agent host.
4
+ *
5
+ * node apps/cli/src/index.ts sites
6
+ * node apps/cli/src/index.ts scan <site> [--pages N] [--json]
7
+ * node apps/cli/src/index.ts inspect <url> [--site <site>]
8
+ * node apps/cli/src/index.ts diff <site>
9
+ * node apps/cli/src/index.ts classify <action> [--site <site>]
10
+ * node apps/cli/src/index.ts audit <site> [--limit N]
11
+ * node apps/cli/src/index.ts auth login [--scope <extra>]... | status | logout
12
+ * node apps/cli/src/index.ts discover [--apply]
13
+ * node apps/cli/src/index.ts gtm workspaces|stage|wdiff|publish <site> ...
14
+ * node apps/cli/src/index.ts content meta|title|schema <site> <path> ...
15
+ */
16
+ import { readFileSync } from 'node:fs';
17
+ import { operations } from "../../../packages/core/src/index.js";
18
+ import * as admin from "../../../packages/core/src/admin.js";
19
+ import { openPullRequest } from "../../../packages/connectors/github/src/index.js";
20
+ import { login, logout, makeOauthProvider, tokenProvider, discover, SCOPES_FOR } from "../../../packages/connectors/google/src/index.js";
21
+ const [, , cmd, ...rest] = process.argv;
22
+ const positional = rest.filter((r, i) => !r.startsWith('--') && !(rest[i - 1] ?? '').startsWith('--'));
23
+ const flag = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : undefined; };
24
+ const has = (name) => rest.includes(name);
25
+ const out = (v) => console.log(typeof v === 'string' ? v : JSON.stringify(v, null, 2));
26
+ try {
27
+ switch (cmd) {
28
+ case 'sites': {
29
+ for (const s of operations.listSites({ includeDisabled: true })) {
30
+ console.log(`${s.enabled ? '*' : ' '} ${s.site_id.padEnd(16)} ${s.production_url.padEnd(34)} ${s.platform.padEnd(9)} ${s.tier}`);
31
+ }
32
+ break;
33
+ }
34
+ case 'scan': {
35
+ const site = positional[0];
36
+ if (!site)
37
+ throw new Error('usage: scan <site> [--pages N] [--json]');
38
+ const { scan, scanPath } = await operations.scanSite(site, { maxPages: Number(flag('--pages') ?? 50) });
39
+ if (has('--json'))
40
+ out(scan);
41
+ else {
42
+ out(operations.renderScan(scan));
43
+ console.log(`\nsaved: ${scanPath}`);
44
+ }
45
+ break;
46
+ }
47
+ case 'inspect': {
48
+ const url = positional[0];
49
+ if (!url)
50
+ throw new Error('usage: inspect <url> [--site <site>]');
51
+ if (has('--rendered')) {
52
+ const r = await operations.inspectRendered(url, flag('--site'));
53
+ out(r.rendered);
54
+ }
55
+ else
56
+ out(await operations.inspectUrl(url, flag('--site')));
57
+ break;
58
+ }
59
+ case 'diff': {
60
+ const site = positional[0];
61
+ if (!site)
62
+ throw new Error('usage: diff <site>');
63
+ out(operations.diffLatest(site));
64
+ break;
65
+ }
66
+ case 'classify': {
67
+ const action = positional[0];
68
+ if (!action)
69
+ throw new Error('usage: classify <action> [--site <site>]');
70
+ out(operations.classifyAction(action, flag('--site')));
71
+ break;
72
+ }
73
+ case 'audit': {
74
+ const site = positional[0];
75
+ if (!site)
76
+ throw new Error('usage: audit <site> [--limit N]');
77
+ out(operations.auditLog(site, Number(flag('--limit') ?? 50)));
78
+ break;
79
+ }
80
+ case 'auth': {
81
+ const sub = positional[0];
82
+ if (sub === 'login') {
83
+ // --scope may be repeated; accept the short names from SCOPES_FOR or full URLs.
84
+ const extra = rest.flatMap((r, i) => (r === '--scope' ? [rest[i + 1] ?? ''] : []))
85
+ .map((s) => SCOPES_FOR[s] ?? s).filter(Boolean);
86
+ const r = await login({ scopes: extra, open: !has('--no-open') });
87
+ out({ authorised: true, ...r });
88
+ }
89
+ else if (sub === 'status') {
90
+ out({ active: tokenProvider().describe(), oauth: makeOauthProvider().status() });
91
+ }
92
+ else if (sub === 'logout') {
93
+ logout();
94
+ out('Stored Google authorisation removed.');
95
+ }
96
+ else
97
+ throw new Error('usage: auth login [--no-open] [--scope ga4_write|gtm_edit|gtm_publish|<url>]... | auth status | auth logout');
98
+ break;
99
+ }
100
+ case 'sitemap': {
101
+ const [sub, site] = positional;
102
+ if (sub !== 'submit' || !site)
103
+ throw new Error('usage: sitemap submit <site> [--approval <token>]');
104
+ const r = await operations.submitSitemap(site, flag('--approval') ? { approval: flag('--approval') } : {});
105
+ out({ status: r.status, verified: r.verified, reason: r.reason, before: r.audit.before, after: r.audit.after });
106
+ break;
107
+ }
108
+ case 'site': {
109
+ const [sub, site, p] = positional;
110
+ if (sub === 'ping' && site)
111
+ out(await operations.adapterPing(site));
112
+ else if (sub === 'pages' && site)
113
+ out(await operations.adapterPages(site));
114
+ else if (sub === 'seo' && site && p)
115
+ out(await operations.adapterPageSeo(site, p));
116
+ else if (sub === 'schema' && site && p)
117
+ out(await operations.adapterPageSchema(site, p));
118
+ else
119
+ throw new Error('usage: site ping <site> | site pages <site> | site seo <site> <path> | site schema <site> <path>');
120
+ break;
121
+ }
122
+ case 'content': {
123
+ const [sub, site, p] = positional;
124
+ const text = flag('--text');
125
+ const approval = flag('--approval') ? { approval: flag('--approval') } : {};
126
+ const show = (r) => out(r.status === 'invalid' ? r : { status: r.status, verified: r.verified, reason: r.reason, before: r.audit.before, after: r.audit.after });
127
+ if (sub === 'validate' && text)
128
+ out(has('--title') ? operations.validateTitle(text) : operations.validateMetaDescription(text));
129
+ else if (sub === 'meta' && site && p && text)
130
+ show(await operations.setMetaDescription(site, p, text, approval));
131
+ else if (sub === 'title' && site && p && text)
132
+ show(await operations.setTitle(site, p, text, approval));
133
+ else if (sub === 'schema' && site && p && flag('--file')) {
134
+ const parsed = JSON.parse(readFileSync(flag('--file'), 'utf8'));
135
+ show(await operations.setSchema(site, p, Array.isArray(parsed) ? parsed : [parsed], approval));
136
+ }
137
+ else
138
+ throw new Error('usage: content validate --text "..." [--title] | content meta|title <site> <path> --text "..." [--approval <token>] | content schema <site> <path> --file blocks.json [--approval <token>]');
139
+ break;
140
+ }
141
+ case 'ga4': {
142
+ const [sub, site, ev] = positional;
143
+ if (sub === 'key-event' && site && ev) {
144
+ const r = await operations.createKeyEvent(site, ev, flag('--approval') ? { approval: flag('--approval') } : {});
145
+ out({ status: r.status, verified: r.verified, reason: r.reason, before: r.audit.before, after: r.audit.after });
146
+ }
147
+ else
148
+ throw new Error('usage: ga4 key-event <site> <eventName> [--approval <token>]');
149
+ break;
150
+ }
151
+ case 'repo': {
152
+ const [sub, site] = positional;
153
+ if (sub !== 'pr' || !site)
154
+ throw new Error('usage: repo pr <site> [--approval <token>] [--change "..."]...');
155
+ const changes = rest.flatMap((r, i) => (r === '--change' ? [rest[i + 1] ?? ''] : [])).filter(Boolean);
156
+ const r = await openPullRequest(site, { changes, ...(flag('--approval') ? { approval: flag('--approval') } : {}) });
157
+ out({ status: r.status, verified: r.verified, reason: r.reason, pr: r.after });
158
+ break;
159
+ }
160
+ case 'gtm': {
161
+ const [sub, site] = positional;
162
+ if (sub === 'containers')
163
+ out(await operations.gtmContainers());
164
+ else if (sub === 'versions' && site)
165
+ out(await operations.gtmVersions(site));
166
+ else if (sub === 'snapshot' && site) {
167
+ const r = await operations.gtmSnapshot(site);
168
+ out(r.rendered);
169
+ console.log(`\nsaved: ${r.file}`);
170
+ }
171
+ else if (sub === 'diff' && site)
172
+ out(operations.gtmDiff(site));
173
+ else if (sub === 'workspaces' && site)
174
+ out(await operations.gtmWorkspaces(site));
175
+ else if (sub === 'stage' && site && flag('--file')) {
176
+ const entities = JSON.parse(readFileSync(flag('--file'), 'utf8'));
177
+ const r = await operations.gtmStage(site, entities, { ...(flag('--approval') ? { approval: flag('--approval') } : {}), ...(flag('--reason') ? { reason: flag('--reason') } : {}), ...(flag('--workspace') ? { workspaceId: flag('--workspace') } : {}) });
178
+ out(r.rendered);
179
+ }
180
+ else if (sub === 'wdiff' && site) {
181
+ const r = await operations.gtmWorkspaceDiff(site, flag('--workspace'));
182
+ out('error' in r ? r : r.rendered);
183
+ }
184
+ else if (sub === 'publish' && site && flag('--review')) {
185
+ out(await operations.gtmPublish(site, { review: flag('--review'), ...(flag('--approval') ? { approval: flag('--approval') } : {}), ...(flag('--workspace') ? { workspaceId: flag('--workspace') } : {}), ...(flag('--name') ? { versionName: flag('--name') } : {}) }));
186
+ }
187
+ else
188
+ throw new Error('usage: gtm containers | versions <site> | snapshot <site> | diff <site> | workspaces <site> | stage <site> --file entities.json [--reason "..."] [--approval <token>] | wdiff <site> [--workspace id] | publish <site> --review <token> [--approval <token>] [--name "..."]');
189
+ break;
190
+ }
191
+ case 'setup': {
192
+ out(admin.setupStatus());
193
+ break;
194
+ }
195
+ case 'doctor': {
196
+ out((await import("../../../packages/core/src/doctor.js")).renderDoctor());
197
+ break;
198
+ }
199
+ case 'add-site': {
200
+ const url = positional[0];
201
+ if (!url || !flag('--name'))
202
+ throw new Error('usage: add-site <url> --name "..." [--platform odoo|nextjs|wordpress|custom] [--odoo-url ...] [--odoo-db ...] [--tier full|advisory]');
203
+ const r = admin.addSite({ url, name: flag('--name'), ...(flag('--platform') ? { platform: flag('--platform') } : {}), ...(flag('--tier') ? { tier: flag('--tier') } : {}),
204
+ ...(flag('--odoo-url') && flag('--odoo-db') ? { odoo: { url: flag('--odoo-url'), db: flag('--odoo-db') } } : {}) });
205
+ out({ file: r.file, site_id: r.site.site_id });
206
+ break;
207
+ }
208
+ case 'set': {
209
+ const [site, p, value] = positional;
210
+ if (!site || !p || value === undefined)
211
+ throw new Error('usage: set <site> <dotted.path> <value>');
212
+ out(admin.updateSite(site, p, value));
213
+ break;
214
+ }
215
+ case 'suppress': {
216
+ const [site, rule] = positional;
217
+ if (!site || !rule || !flag('--reason'))
218
+ throw new Error('usage: suppress <site> <rule> [--path /x/] --reason "..."');
219
+ out(admin.addSuppression(site, { rule, ...(flag('--path') ? { path: flag('--path') } : {}), reason: flag('--reason') }));
220
+ break;
221
+ }
222
+ case 'report': {
223
+ const [sub, site] = positional;
224
+ if (sub !== 'client' || !site)
225
+ throw new Error('usage: report client <site> [--month YYYY-MM] [--lang he|en] [--next "..."] [--headline "..."]');
226
+ const r = await operations.renderClientReport(site, {
227
+ ...(flag('--month') ? { month: flag('--month') } : {}), ...(flag('--lang') ? { lang: flag('--lang') } : {}),
228
+ narrative: { ...(flag('--headline') ? { headline: flag('--headline') } : {}), ...(flag('--next') ? { next: flag('--next') } : {}) },
229
+ });
230
+ out(r.markdown);
231
+ console.log(`\ndocx: ${r.docx}\ntemplate: ${r.template ?? 'none'}${r.notes.length ? `\nnotes: ${r.notes.join('; ')}` : ''}`);
232
+ break;
233
+ }
234
+ case 'discover': {
235
+ const r = await discover({ apply: has('--apply') });
236
+ out({
237
+ inventory: { searchConsole: r.inventory.gsc.length, ga4: r.inventory.ga4.length, tagManager: r.inventory.gtm.length },
238
+ matches: r.matches,
239
+ applied: r.applied,
240
+ hint: has('--apply') ? undefined : 'Re-run with --apply to write matched ids into sites/*.yaml.',
241
+ });
242
+ break;
243
+ }
244
+ default:
245
+ if (cmd && cmd !== 'help' && cmd !== '--help' && cmd !== '-h')
246
+ console.error(`unknown command "${cmd}"\n`);
247
+ console.log([
248
+ 'gwo: Google Web Operations',
249
+ '',
250
+ 'Getting started',
251
+ ' gwo install [claude|codex|all] [--print] register the MCP server, skills and commands with the hosts',
252
+ ' gwo doctor what is set up, what is missing, the next step',
253
+ ' gwo add-site <url> --name "..." [--platform odoo|nextjs|wordpress|custom] [--odoo-url ... --odoo-db ...]',
254
+ ' gwo auth login [--scope ga4_write|gtm_edit|gtm_publish] | auth status | auth logout',
255
+ ' gwo discover [--apply] match Search Console, GA4 and Tag Manager to sites',
256
+ ' gwo update latest release, then re-register the hosts',
257
+ '',
258
+ 'Look',
259
+ ' gwo sites | scan <site> [--pages N] [--json] | inspect <url> [--site <site>] [--rendered] | diff <site>',
260
+ ' gwo audit <site> [--limit N] | classify <action> [--site <site>] | setup (JSON) | doctor (text)',
261
+ ' gwo gtm containers | versions <site> | snapshot <site> | diff <site> | workspaces <site>',
262
+ ' gwo site ping <site> | pages <site> | seo <site> <path> | schema <site> <path>',
263
+ '',
264
+ 'Change (policy-gated; approval tokens look like <site>:<action>)',
265
+ ' gwo sitemap submit <site>',
266
+ ' gwo content validate --text "..." [--title]',
267
+ ' gwo content meta|title <site> <path> --text "..." [--approval <token>]',
268
+ ' gwo content schema <site> <path> --file blocks.json [--approval <token>]',
269
+ ' gwo ga4 key-event <site> <eventName> [--approval <token>]',
270
+ ' gwo gtm stage <site> --file entities.json [--reason "..."] [--approval <token>]',
271
+ ' gwo gtm wdiff <site> | gtm publish <site> --review <token> [--approval <token>]',
272
+ ' gwo repo pr <site> [--approval <token>] [--change "..."]...',
273
+ ' gwo set <site> <dotted.path> <value> | suppress <site> <rule> [--path /x/] --reason "..."',
274
+ '',
275
+ 'Report and schedule',
276
+ ' gwo report client <site> [--month YYYY-MM] [--lang he|en] [--headline "..."] [--next "..."]',
277
+ ' gwo worker daily|weekly|monthly [--deliver] | worker loop | schedule install|status|remove',
278
+ '',
279
+ 'Docs: docs/INSTALL.he.md, docs/GETTING-STARTED.he.md, README.md. In the chat: /gwo-setup.',
280
+ ].join('\n'));
281
+ }
282
+ }
283
+ catch (e) {
284
+ console.error(e.message);
285
+ process.exitCode = 1;
286
+ }