seomd-cli 1.1.1 → 1.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.
package/README.md CHANGED
@@ -1,69 +1,225 @@
1
- # seomd-cli
1
+ # SEO.md CLI
2
2
 
3
- The official command-line interface for the **SEO.md** open standard — AEO (AI Engine Optimization) infrastructure for technical founders.
3
+ <p align="left" style="padding: 100px 0;">
4
+ <img alt="SEO.md CLI logo" src="./assets/logo.svg" />
5
+ </p>
4
6
 
5
- Use the CLI to scaffold, validate, analyze, and synchronize `SEO.md` configuration files directly from your workspace.
7
+ The official CLI for the [SEO.md](https://seomd.dev) open standard AEO (AI Engine Optimization) infrastructure for technical founders.
6
8
 
7
- ---
9
+ Use it to scaffold, validate, analyze, and sync `SEO.md` files directly from your repo.
8
10
 
9
- ## Installation
11
+ ## Table of Contents
10
12
 
11
- Install the CLI globally using `npm`:
13
+ - [Why SEO.md](#why-seomd)
14
+ - [Install](#install)
15
+ - [Quick Start](#quick-start)
16
+ - [Configuration](#configuration)
17
+ - [Commands](#commands)
18
+ - [Local Development](#local-development)
19
+ - [Testing](#testing)
20
+ - [Release Notes (Contributor Tagging)](#release-notes-contributor-tagging)
21
+ - [Security](#security)
22
+ - [Specification Reference](#specification-reference)
23
+ - [License](#license)
24
+
25
+ ## Why SEO.md
26
+
27
+ SEO.md is a structured, version-controlled specification for describing your site, intent queries, and pages so AI engines can cite you more often.
28
+
29
+ - Declare what matters (site, identity, keywords, intent, pages)
30
+ - Run audits via your connected platform
31
+ - Write back `_analysis` blocks and per-page playbooks into your repo
32
+
33
+ ## Install
12
34
 
13
35
  ```bash
14
36
  npm install -g seomd-cli
15
37
  ```
16
38
 
17
- ---
39
+ Or run without installing (zero-install):
40
+
41
+ ```bash
42
+ npx seomd-cli init
43
+ ```
44
+
45
+ Verify:
46
+
47
+ ```bash
48
+ seomd --help
49
+ ```
18
50
 
19
51
  ## Quick Start
20
52
 
21
- ### 1. Initialize a Specification
22
- Run the interactive setup in the root of your project to generate a tailored `SEO.md` file:
53
+ ### 1) Initialize
54
+
55
+ Run in the root of your project:
23
56
 
24
57
  ```bash
25
58
  seomd init
26
59
  ```
27
- The CLI will ask you five quick questions (e.g., brand name, domain, site type, primary keyword, and competitors) and scaffold the format matching your site type (SaaS, Blog, eCommerce, Marketplace, or Local).
28
60
 
29
- ### 2. Validate the File
30
- Verify that your local file complies with the official open specification rules:
61
+ Or non-interactively with flags:
62
+
63
+ ```bash
64
+ seomd init -y --type local
65
+ # or
66
+ seomd init --brand "Acme" --domain acme.com --primary-keyword "local seo"
67
+ ```
68
+
69
+ ### 2) Validate
31
70
 
32
71
  ```bash
33
72
  seomd validate
34
73
  ```
35
74
 
36
- ### 3. Check Local Status
37
- Check validation state and connection parameters:
75
+ ### 3) Run an Audit (Analyze) and Sync
76
+
77
+ ```bash
78
+ seomd analyze
79
+ seomd sync
80
+ ```
81
+
82
+ ### 4) View Status
38
83
 
39
84
  ```bash
40
85
  seomd status
86
+ seomd status --json
41
87
  ```
42
88
 
43
- ---
89
+ ## Configuration
90
+
91
+ Copy `.env.example` from your platform provider docs, or create one with the vars you need:
92
+
93
+ Required for live audits:
94
+ ```bash
95
+ SEOMD_API_URL=
96
+ SEOMD_API_KEY=your_key_here
97
+ ```
98
+
99
+ Optional:
100
+ ```bash
101
+ SEOMD_PAYMENT_TOKEN= # x402 pay-per-scan token
102
+ SEOMD_DOMAIN= # override domain header
103
+ ```
44
104
 
45
- ## Command Reference
105
+ > If you don't have a platform key yet, `seomd init` still works without `.env`.
106
+
107
+ ## Commands
46
108
 
47
109
  ### `seomd init`
48
- Scaffolds a new `SEO.md` file in the current working directory.
110
+
111
+ Scaffolds `SEO.md`, `SEO.REVERSE.md`, and the `.seomd/` intelligence directory.
112
+
113
+ **Usage:**
114
+ ```bash
115
+ seomd init # interactive 5-question flow
116
+ seomd init -y --type local # skip prompts, use defaults
117
+ seomd init --brand "Acme" --domain acme.com # non-interactive with partial flags
118
+ seomd init --type saas --brand "MyApp" --domain myapp.com --primary-keyword "billing automation" --output ./new-project
119
+ ```
120
+
121
+ **Options:**
122
+ | Flag | Description |
123
+ |------|-------------|
124
+ | `-y, --yes` | skip prompts, use defaults |
125
+ | `--type <type>` | site type: saas, ecommerce, local, blog, marketplace |
126
+ | `--brand <name>` | brand name |
127
+ | `--domain <domain>` | primary domain |
128
+ | `--primary-keyword <keyword>` | primary keyword |
129
+ | `--competitors <list>` | comma-separated competitor list |
130
+ | `--output <dir>` | scaffold into a new (empty) directory instead of cwd |
131
+
132
+ **Behavior:**
133
+ - Interactive flow by default (5 questions)
134
+ - Non-interactive when `-y` is set **OR** any config flag (`--brand`, `--domain`, `--primary-keyword`, `--competitors`) is provided
135
+ - `--type` alone pre-selects site type in the interactive flow
136
+ - `--output` writes all files to the target directory (must be empty or non-existent)
49
137
 
50
138
  ### `seomd validate`
51
- Validates the structural integrity and required fields of your `SEO.md` file.
139
+
140
+ Validates your `SEO.md` against the spec requirements.
52
141
 
53
142
  ### `seomd status`
54
- Displays local validation results, connected platforms, and project identification.
143
+
144
+ Shows current citation rates and gap scores from `_analysis`.
145
+
146
+ - `--json` outputs machine-readable JSON for scripts/CI
55
147
 
56
148
  ### `seomd analyze`
57
- Analyzes your local specification and requests search visibility summaries from your connected platform.
149
+
150
+ Runs an AI search audit via your connected platform and writes results back into:
151
+
152
+ - `SEO.md` (`_analysis` blocks)
153
+ - `SEO.REVERSE.md` (generated reverse view)
154
+ - `.seomd/pages/*.md` (per-page playbooks when available)
58
155
 
59
156
  ### `seomd sync`
60
- Synchronizes live engine analysis blocks, keyword gap scores, and cited sources from your connected writeback platform.
61
157
 
62
- ---
158
+ Pulls cached/latest platform intelligence and writes it back to the same files as `analyze`.
159
+
160
+ - `--dry-run` prints a preview and does not modify files
161
+
162
+ ## Built-in Templates
163
+
164
+ `seomd-cli` ships with type-specific templates under `src/templates/`. `seomd init --type <type>` uses the matching template automatically.
165
+
166
+ | Type | Template dir | Best for |
167
+ |------|-------------|----------|
168
+ | `saas` | `src/templates/saas/` | Software products, B2B tools, web apps |
169
+ | `blog` | `src/templates/blog/` | Content sites, newsletters, personal brands |
170
+ | `ecommerce` | `src/templates/ecommerce/` | Online stores, DTC brands, product catalogs |
171
+ | `local` | `src/templates/local/` | Service-area businesses, locations pages |
172
+ | `marketplace` | `src/templates/marketplace/` | Two-sided platforms, directories |
173
+
174
+ Each template contains:
175
+
176
+ - `SEO.md` — pre-filled with type-specific intent queries, page structures, and negative keywords
177
+ - `SEO.REVERSE.md` — reverse-engineer output scaffold with placeholders for competitor analysis
178
+
179
+ Want to customize a template? Copy the relevant folder, edit the placeholders (`{{brand}}`, `{{domain}}`, etc.), and use `--type` with your custom scaffold.
180
+
181
+ ## Local Development
182
+
183
+ Prefer the local entrypoint while developing:
184
+
185
+ ```bash
186
+ node ./bin/seomd.js --help
187
+ node ./bin/seomd.js init
188
+ node ./bin/seomd.js validate
189
+ node ./bin/seomd.js status --json
190
+ ```
191
+
192
+ ## Testing
193
+
194
+ ```bash
195
+ npm test
196
+ ```
197
+
198
+ ## Release Notes (Contributor Tagging)
199
+
200
+ To generate a contributor section for a release (commit-based attribution), maintain mappings in `.github/contributors.yml` and generate markdown from a tag range:
201
+
202
+ ```bash
203
+ npm run release:contributors -- --from v1.0.2 --to v1.0.3
204
+ ```
205
+
206
+ To write output to a file:
207
+
208
+ ```bash
209
+ npm run release:contributors -- --from v1.0.2 --to v1.0.3 --out notes/v1.0.3-contributors.md
210
+ ```
211
+
212
+ To generate a full release note (changes + contributors) for a tag:
213
+
214
+ ```bash
215
+ npm run release:notes -- --tag v1.0.3
216
+ ```
217
+
218
+ Automation: the repository includes a GitHub Actions workflow that runs on tag push (`v*`) and creates/updates the GitHub Release using `scripts/release-notes.js`.
63
219
 
64
220
  ## Platform Connections & API Keys
65
221
 
66
- To enable live citation writebacks (using automated platforms like [Foxcite](https://foxcite.com)):
222
+ To enable live intelligence writebacks (using automated platforms like [Foxcite](https://foxcite.com)):
67
223
 
68
224
  1. Obtain a developer API key from your platform provider.
69
225
  2. Export the key as an environment variable:
@@ -72,9 +228,12 @@ To enable live citation writebacks (using automated platforms like [Foxcite](htt
72
228
  ```
73
229
  3. Run `seomd sync` or `seomd analyze`.
74
230
 
75
- *Note: Never commit your API keys or `.env` files containing keys to version control.*
231
+ _Note: Never commit your API keys or `.env` files containing keys to version control._
232
+
233
+ ## Security
76
234
 
77
- ---
235
+ - Never commit `.env` files or API keys
236
+ - Use `.env.example` as the template for required variables
78
237
 
79
238
  ## Specification Reference
80
239
 
package/bin/seomd.js CHANGED
@@ -25,6 +25,19 @@ program
25
25
  .description('Scaffold SEO.md for your project')
26
26
  .option('-y, --yes', 'skip prompts and use defaults')
27
27
  .option('--type <type>', 'site type: saas, ecommerce, local, blog, marketplace')
28
+ .option('--brand <brand>', 'brand name')
29
+ .option('--domain <domain>', 'primary domain')
30
+ .option('--primary-keyword <keyword>', 'primary keyword')
31
+ .option('--competitors <list>', 'comma-separated competitor list')
32
+ .option('--output <dir>', 'scaffold into a new directory instead of cwd')
33
+ .addHelpText('after', `
34
+
35
+ Examples:
36
+ seomd init # interactive 5-question flow
37
+ seomd init -y --type local # skip prompts, use defaults
38
+ seomd init --brand "Acme" --domain acme.com --primary-keyword "local seo"
39
+ seomd init --type saas --brand "MyApp" --domain myapp.com --output ./new-project
40
+ `)
28
41
  .action(initCommand);
29
42
 
30
43
  program
@@ -33,23 +46,47 @@ program
33
46
  .option('--page <url>', 'analyze a specific page only')
34
47
  .option('--intent <category>', 'analyze a specific intent category only')
35
48
  .option('--engines <list>', 'comma-separated list of engines to scan (e.g. chatgpt,claude)')
49
+ .addHelpText('after', `
50
+
51
+ Examples:
52
+ seomd analyze # full audit
53
+ seomd analyze --page /pricing # single page audit
54
+ seomd analyze --intent transactional --engines chatgpt,claude
55
+ `)
36
56
  .action(analyzeCommand);
37
57
 
38
58
  program
39
59
  .command('sync')
40
60
  .description('Sync latest platform intelligence to your SEO.md files')
41
61
  .option('--dry-run', 'preview changes without writing')
62
+ .addHelpText('after', `
63
+
64
+ Examples:
65
+ seomd sync # sync latest data
66
+ seomd sync --dry-run # preview changes only
67
+ `)
42
68
  .action(syncCommand);
43
69
 
44
70
  program
45
71
  .command('status')
46
72
  .description('Show current citation rates and gap scores')
47
73
  .option('--json', 'output as JSON')
74
+ .addHelpText('after', `
75
+
76
+ Examples:
77
+ seomd status # human-readable summary
78
+ seomd status --json # machine-readable JSON
79
+ `)
48
80
  .action(statusCommand);
49
81
 
50
82
  program
51
83
  .command('validate')
52
84
  .description('Validate your SEO.md against the spec')
85
+ .addHelpText('after', `
86
+
87
+ Example:
88
+ seomd validate
89
+ `)
53
90
  .action(validateCommand);
54
91
 
55
92
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seomd-cli",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "The official CLI for the SEO.md open standard — AEO infrastructure for technical founders",
5
5
  "homepage": "https://seomd.dev",
6
6
  "repository": {
@@ -14,26 +14,31 @@
14
14
  },
15
15
  "files": [
16
16
  "bin",
17
+ "scripts",
17
18
  "src"
18
19
  ],
19
20
  "scripts": {
20
21
  "dev": "node bin/seomd.js",
21
22
  "test": "node --experimental-vm-modules node_modules/.bin/jest",
22
- "lint": "eslint src/**/*.js"
23
+ "lint": "eslint src/**/*.js",
24
+ "release:contributors": "node scripts/release-contributors.js",
25
+ "release:notes": "node scripts/release-notes.js"
23
26
  },
24
27
  "dependencies": {
28
+ "axios": "^1.6.0",
25
29
  "chalk": "^5.3.0",
26
30
  "commander": "^11.0.0",
31
+ "dotenv": "^16.3.1",
27
32
  "enquirer": "^2.4.1",
28
- "ora": "^7.0.1",
29
- "yaml": "^2.3.4",
30
33
  "fs-extra": "^11.1.1",
31
- "axios": "^1.6.0",
32
- "dotenv": "^16.3.1"
34
+ "ora": "^7.0.1",
35
+ "yaml": "^2.3.4"
33
36
  },
34
37
  "devDependencies": {
35
- "jest": "^29.7.0",
36
- "eslint": "^8.50.0"
38
+ "@eslint/js": "^10.0.1",
39
+ "eslint": "^8.50.0",
40
+ "eslint-plugin-n": "^18.1.0",
41
+ "jest": "^29.7.0"
37
42
  },
38
43
  "engines": {
39
44
  "node": ">=18.0.0"
@@ -0,0 +1,196 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { execSync } from 'child_process';
4
+ import YAML from 'yaml';
5
+
6
+ function parseArgs(argv) {
7
+ const out = { from: null, to: null, outFile: null };
8
+ for (let i = 2; i < argv.length; i += 1) {
9
+ const arg = argv[i];
10
+ const next = argv[i + 1];
11
+ if (arg === '--from' && next) {
12
+ out.from = next;
13
+ i += 1;
14
+ } else if (arg === '--to' && next) {
15
+ out.to = next;
16
+ i += 1;
17
+ } else if (arg === '--out' && next) {
18
+ out.outFile = next;
19
+ i += 1;
20
+ }
21
+ }
22
+ return out;
23
+ }
24
+
25
+ function readContributorsMap(repoRoot) {
26
+ const filePath = path.join(repoRoot, '.github', 'contributors.yml');
27
+ if (!fs.existsSync(filePath)) {
28
+ return { version: 1, contributors: [] };
29
+ }
30
+ const parsed = YAML.parse(fs.readFileSync(filePath, 'utf8')) || {};
31
+ return {
32
+ version: parsed.version || 1,
33
+ contributors: Array.isArray(parsed.contributors) ? parsed.contributors : []
34
+ };
35
+ }
36
+
37
+ function normalizeEmail(email) {
38
+ return String(email || '').trim().toLowerCase();
39
+ }
40
+
41
+ function normalizeName(name) {
42
+ return String(name || '').trim().toLowerCase();
43
+ }
44
+
45
+ function resolveHandle(map, { name, email }) {
46
+ const nEmail = normalizeEmail(email);
47
+ const nName = normalizeName(name);
48
+
49
+ for (const c of map.contributors) {
50
+ if (!c || typeof c !== 'object') continue;
51
+ if (!c.github) continue;
52
+ const emails = Array.isArray(c.emails) ? c.emails.map(normalizeEmail) : [];
53
+ if (nEmail && emails.includes(nEmail)) return c.github;
54
+ const names = Array.isArray(c.names) ? c.names.map(normalizeName) : [];
55
+ if (nName && names.includes(nName)) return c.github;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function git(repoRoot, args) {
61
+ return execSync(`git ${args}`, { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }).toString('utf8');
62
+ }
63
+
64
+ function getRepoRoot() {
65
+ return execSync('git rev-parse --show-toplevel', { stdio: ['ignore', 'pipe', 'pipe'] }).toString('utf8').trim();
66
+ }
67
+
68
+ function getCommitBodies(repoRoot, range) {
69
+ const sepCommit = '\u001e';
70
+ const sepField = '\u001f';
71
+ const out = git(repoRoot, `log ${range} --no-color --format=%H${sepField}%an${sepField}%ae${sepField}%B${sepCommit}`);
72
+ const rawCommits = out.split(sepCommit).map(s => s.trim()).filter(Boolean);
73
+ return rawCommits.map(entry => {
74
+ const parts = entry.split(sepField);
75
+ const [sha, authorName, authorEmail] = parts;
76
+ const body = parts.slice(3).join(sepField);
77
+ return { sha, authorName, authorEmail, body };
78
+ });
79
+ }
80
+
81
+ function extractCoAuthors(commitBody) {
82
+ const out = [];
83
+ const lines = String(commitBody || '').split('\n');
84
+ for (const line of lines) {
85
+ const m = line.match(/^Co-authored-by:\s*(.+?)\s*<(.+?)>\s*$/i);
86
+ if (m) out.push({ name: m[1], email: m[2] });
87
+ }
88
+ return out;
89
+ }
90
+
91
+ function bumpCounter(map, key, display) {
92
+ const existing = map.get(key);
93
+ if (existing) {
94
+ existing.count += 1;
95
+ return;
96
+ }
97
+ map.set(key, { count: 1, display });
98
+ }
99
+
100
+ function fmtHandle(handle) {
101
+ if (!handle) return null;
102
+ return handle.startsWith('@') ? handle : `@${handle}`;
103
+ }
104
+
105
+ function buildContribMarkdown({ from, to, resolvedCounts, unresolvedCounts }) {
106
+ const lines = [];
107
+ const rangeLabel = from && to ? `${from}..${to}` : 'range';
108
+
109
+ lines.push(`## Contributors`);
110
+ lines.push('');
111
+ lines.push(`Commit range: ${rangeLabel}`);
112
+ lines.push('');
113
+
114
+ const resolved = Array.from(resolvedCounts.entries())
115
+ .sort((a, b) => b[1].count - a[1].count || a[0].localeCompare(b[0]));
116
+
117
+ if (resolved.length > 0) {
118
+ for (const [handle, info] of resolved) {
119
+ lines.push(`- ${fmtHandle(handle)} (${info.count})`);
120
+ }
121
+ lines.push('');
122
+ } else {
123
+ lines.push(`- None`);
124
+ lines.push('');
125
+ }
126
+
127
+ const unresolved = Array.from(unresolvedCounts.entries())
128
+ .sort((a, b) => b[1].count - a[1].count || a[0].localeCompare(b[0]));
129
+
130
+ if (unresolved.length > 0) {
131
+ lines.push(`## Unmapped Contributors`);
132
+ lines.push('');
133
+ for (const [key, info] of unresolved) {
134
+ lines.push(`- ${info.display} (${info.count})`);
135
+ }
136
+ lines.push('');
137
+ lines.push(`To tag these contributors, add their name/email to .github/contributors.yml.`);
138
+ lines.push('');
139
+ }
140
+
141
+ return lines.join('\n');
142
+ }
143
+
144
+ function main() {
145
+ const args = parseArgs(process.argv);
146
+ if (!args.from || !args.to) {
147
+ process.stderr.write('Usage: node scripts/release-contributors.js --from <tag> --to <tag> [--out <file>]\n');
148
+ process.exit(1);
149
+ }
150
+
151
+ const repoRoot = getRepoRoot();
152
+ const contribMap = readContributorsMap(repoRoot);
153
+ const range = `${args.from}..${args.to}`;
154
+ const commits = getCommitBodies(repoRoot, range);
155
+
156
+ const resolvedCounts = new Map();
157
+ const unresolvedCounts = new Map();
158
+
159
+ for (const c of commits) {
160
+ const authorHandle = resolveHandle(contribMap, { name: c.authorName, email: c.authorEmail });
161
+ if (authorHandle) {
162
+ bumpCounter(resolvedCounts, authorHandle, fmtHandle(authorHandle));
163
+ } else {
164
+ bumpCounter(unresolvedCounts, `${normalizeName(c.authorName)}|${normalizeEmail(c.authorEmail)}`, `${c.authorName} <${c.authorEmail}>`);
165
+ }
166
+
167
+ for (const co of extractCoAuthors(c.body)) {
168
+ const coHandle = resolveHandle(contribMap, co);
169
+ if (coHandle) {
170
+ bumpCounter(resolvedCounts, coHandle, fmtHandle(coHandle));
171
+ } else {
172
+ bumpCounter(unresolvedCounts, `${normalizeName(co.name)}|${normalizeEmail(co.email)}`, `${co.name} <${co.email}>`);
173
+ }
174
+ }
175
+ }
176
+
177
+ const md = buildContribMarkdown({
178
+ from: args.from,
179
+ to: args.to,
180
+ resolvedCounts,
181
+ unresolvedCounts
182
+ });
183
+
184
+ if (args.outFile) {
185
+ const outPath = path.isAbsolute(args.outFile) ? args.outFile : path.join(repoRoot, args.outFile);
186
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
187
+ fs.writeFileSync(outPath, md, 'utf8');
188
+ process.stdout.write(`${outPath}\n`);
189
+ return;
190
+ }
191
+
192
+ process.stdout.write(md + '\n');
193
+ }
194
+
195
+ main();
196
+
@@ -0,0 +1,194 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { execSync } from 'child_process';
4
+ import YAML from 'yaml';
5
+
6
+ function parseArgs(argv) {
7
+ const out = { tag: null, prev: null, outFile: null };
8
+ for (let i = 2; i < argv.length; i += 1) {
9
+ const arg = argv[i];
10
+ const next = argv[i + 1];
11
+ if (arg === '--tag' && next) {
12
+ out.tag = next;
13
+ i += 1;
14
+ } else if (arg === '--prev' && next) {
15
+ out.prev = next;
16
+ i += 1;
17
+ } else if (arg === '--out' && next) {
18
+ out.outFile = next;
19
+ i += 1;
20
+ }
21
+ }
22
+ return out;
23
+ }
24
+
25
+ function git(repoRoot, args) {
26
+ return execSync(`git ${args}`, { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }).toString('utf8');
27
+ }
28
+
29
+ function getRepoRoot() {
30
+ return execSync('git rev-parse --show-toplevel', { stdio: ['ignore', 'pipe', 'pipe'] }).toString('utf8').trim();
31
+ }
32
+
33
+ function readContributorsMap(repoRoot) {
34
+ const filePath = path.join(repoRoot, '.github', 'contributors.yml');
35
+ if (!fs.existsSync(filePath)) {
36
+ return { version: 1, contributors: [] };
37
+ }
38
+ const parsed = YAML.parse(fs.readFileSync(filePath, 'utf8')) || {};
39
+ return {
40
+ version: parsed.version || 1,
41
+ contributors: Array.isArray(parsed.contributors) ? parsed.contributors : []
42
+ };
43
+ }
44
+
45
+ function normalizeEmail(email) {
46
+ return String(email || '').trim().toLowerCase();
47
+ }
48
+
49
+ function normalizeName(name) {
50
+ return String(name || '').trim().toLowerCase();
51
+ }
52
+
53
+ function resolveHandle(map, { name, email }) {
54
+ const nEmail = normalizeEmail(email);
55
+ const nName = normalizeName(name);
56
+
57
+ for (const c of map.contributors) {
58
+ if (!c || typeof c !== 'object') continue;
59
+ if (!c.github) continue;
60
+ const emails = Array.isArray(c.emails) ? c.emails.map(normalizeEmail) : [];
61
+ if (nEmail && emails.includes(nEmail)) return c.github;
62
+ const names = Array.isArray(c.names) ? c.names.map(normalizeName) : [];
63
+ if (nName && names.includes(nName)) return c.github;
64
+ }
65
+ return null;
66
+ }
67
+
68
+ function fmtHandle(handle) {
69
+ if (!handle) return null;
70
+ return handle.startsWith('@') ? handle : `@${handle}`;
71
+ }
72
+
73
+ function bumpCounter(map, key) {
74
+ map.set(key, (map.get(key) || 0) + 1);
75
+ }
76
+
77
+ function extractCoAuthors(commitBody) {
78
+ const out = [];
79
+ const lines = String(commitBody || '').split('\n');
80
+ for (const line of lines) {
81
+ const m = line.match(/^Co-authored-by:\s*(.+?)\s*<(.+?)>\s*$/i);
82
+ if (m) out.push({ name: m[1], email: m[2] });
83
+ }
84
+ return out;
85
+ }
86
+
87
+ function getCommitBodies(repoRoot, range) {
88
+ const sepCommit = '\u001e';
89
+ const sepField = '\u001f';
90
+ const out = git(repoRoot, `log ${range} --no-color --format=%H${sepField}%an${sepField}%ae${sepField}%s${sepField}%B${sepCommit}`);
91
+ const rawCommits = out.split(sepCommit).map(s => s.trim()).filter(Boolean);
92
+ return rawCommits.map(entry => {
93
+ const parts = entry.split(sepField);
94
+ const [sha, authorName, authorEmail, subject] = parts;
95
+ const body = parts.slice(4).join(sepField);
96
+ return { sha, authorName, authorEmail, subject, body };
97
+ });
98
+ }
99
+
100
+ function determinePrevTag(repoRoot, tag) {
101
+ try {
102
+ return git(repoRoot, `describe --tags --abbrev=0 ${tag}^`).trim();
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ function buildReleaseMarkdown({ tag, prev, commits, contributors }) {
109
+ const lines = [];
110
+
111
+ lines.push(`# ${tag}`);
112
+ lines.push('');
113
+
114
+ if (prev) {
115
+ lines.push(`Changes since ${prev}`);
116
+ lines.push('');
117
+ }
118
+
119
+ if (commits.length > 0) {
120
+ lines.push(`## Changes`);
121
+ lines.push('');
122
+ for (const c of commits) {
123
+ const sha = String(c.sha || '').slice(0, 7);
124
+ lines.push(`- ${c.subject} (${sha})`);
125
+ }
126
+ lines.push('');
127
+ }
128
+
129
+ lines.push(`## Contributors`);
130
+ lines.push('');
131
+
132
+ const sorted = Array.from(contributors.entries())
133
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
134
+
135
+ if (sorted.length === 0) {
136
+ lines.push(`- None`);
137
+ lines.push('');
138
+ return lines.join('\n');
139
+ }
140
+
141
+ for (const [handle, count] of sorted) {
142
+ lines.push(`- ${fmtHandle(handle)} (${count})`);
143
+ }
144
+ lines.push('');
145
+
146
+ return lines.join('\n');
147
+ }
148
+
149
+ function main() {
150
+ const args = parseArgs(process.argv);
151
+ if (!args.tag) {
152
+ process.stderr.write('Usage: node scripts/release-notes.js --tag <tag> [--prev <tag>] [--out <file>]\n');
153
+ process.exit(1);
154
+ }
155
+
156
+ const repoRoot = getRepoRoot();
157
+ const prev = args.prev || determinePrevTag(repoRoot, args.tag);
158
+ const range = prev ? `${prev}..${args.tag}` : args.tag;
159
+
160
+ const contribMap = readContributorsMap(repoRoot);
161
+ const commits = getCommitBodies(repoRoot, range);
162
+
163
+ const contributors = new Map();
164
+
165
+ for (const c of commits) {
166
+ const authorHandle = resolveHandle(contribMap, { name: c.authorName, email: c.authorEmail });
167
+ if (authorHandle) bumpCounter(contributors, authorHandle);
168
+
169
+ for (const co of extractCoAuthors(c.body)) {
170
+ const coHandle = resolveHandle(contribMap, co);
171
+ if (coHandle) bumpCounter(contributors, coHandle);
172
+ }
173
+ }
174
+
175
+ const md = buildReleaseMarkdown({
176
+ tag: args.tag,
177
+ prev,
178
+ commits,
179
+ contributors
180
+ });
181
+
182
+ if (args.outFile) {
183
+ const outPath = path.isAbsolute(args.outFile) ? args.outFile : path.join(repoRoot, args.outFile);
184
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
185
+ fs.writeFileSync(outPath, md, 'utf8');
186
+ process.stdout.write(`${outPath}\n`);
187
+ return;
188
+ }
189
+
190
+ process.stdout.write(md + '\n');
191
+ }
192
+
193
+ main();
194
+
@@ -1,7 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import ora from 'ora';
3
3
  import fs from 'fs-extra';
4
- import path from 'path';
5
4
  import dotenv from 'dotenv';
6
5
  import { parseSeoMd } from '../utils/parser.js';
7
6
  import { client } from '../utils/api-client.js';
@@ -16,8 +16,19 @@ export async function initCommand(options) {
16
16
  console.log(chalk.dim('The open standard for AI-era SEO configuration.'));
17
17
  console.log('');
18
18
 
19
- // Check if SEO.md already exists
20
- const seomdPath = path.join(process.cwd(), 'SEO.md');
19
+ // Determine target directory
20
+ let workingDir = process.cwd();
21
+ if (options.output) {
22
+ const resolved = path.resolve(options.output);
23
+ if (await fs.pathExists(resolved) && (await fs.readdir(resolved)).length > 0) {
24
+ console.log(chalk.red(`Output directory must be empty or non-existent: ${resolved}`));
25
+ process.exit(1);
26
+ }
27
+ await fs.ensureDir(resolved);
28
+ workingDir = resolved;
29
+ }
30
+
31
+ const seomdPath = path.join(workingDir, 'SEO.md');
21
32
  if (await fs.pathExists(seomdPath)) {
22
33
  console.log(chalk.yellow('⚠ SEO.md already exists in this directory.'));
23
34
  const { overwrite } = await prompt({
@@ -34,14 +45,26 @@ export async function initCommand(options) {
34
45
 
35
46
  let answers;
36
47
 
37
- if (options.yes) {
38
- // Default values for --yes flag
48
+ // Mode 5: non-interactive if -y OR any config field provided
49
+ const hasConfigFlags =
50
+ options.brand !== undefined ||
51
+ options.domain !== undefined ||
52
+ options.primaryKeyword !== undefined ||
53
+ options.competitors !== undefined;
54
+
55
+ if (options.yes || hasConfigFlags) {
39
56
  answers = {
40
57
  site_type: options.type || 'saas',
41
- domain: 'example.com',
42
- brand: 'My Brand',
43
- primary_keyword: '',
44
- competitors: '',
58
+ domain: options.domain || 'example.com',
59
+ brand: options.brand || 'My Brand',
60
+ primary_keyword: options.primaryKeyword || '',
61
+ competitors: options.competitors
62
+ ? options.competitors
63
+ .split(',')
64
+ .map((c) => c.trim())
65
+ .filter(Boolean)
66
+ .slice(0, 3)
67
+ : [],
45
68
  };
46
69
  } else {
47
70
  // The 5-question init flow
@@ -109,21 +132,20 @@ export async function initCommand(options) {
109
132
  try {
110
133
  // 1. Generate SEO.md
111
134
  const seomdContent = generateSeoMd(answers);
112
- await fs.writeFile(seomdPath, seomdContent, 'utf8');
135
+ await fs.writeFile(path.join(workingDir, 'SEO.md'), seomdContent, 'utf8');
113
136
  spinner.succeed(chalk.green('SEO.md created'));
114
137
 
115
138
  // 2. Generate SEO.REVERSE.md
116
- const reversePath = path.join(process.cwd(), 'SEO.REVERSE.md');
117
139
  const reverseContent = generateReverseMd(answers);
118
- await fs.writeFile(reversePath, reverseContent, 'utf8');
140
+ await fs.writeFile(path.join(workingDir, 'SEO.REVERSE.md'), reverseContent, 'utf8');
119
141
  spinner.succeed(chalk.green('SEO.REVERSE.md initialized'));
120
142
 
121
143
  // 3. Create .seomd/ directory structure
122
- await createSeomdDir(process.cwd(), answers);
144
+ await createSeomdDir(workingDir, answers);
123
145
  spinner.succeed(chalk.green('.seomd/ directory created'));
124
146
 
125
147
  // 4. Add .seomd/ to .gitignore if it exists
126
- await updateGitignore(process.cwd());
148
+ await updateGitignore(workingDir);
127
149
 
128
150
  console.log('');
129
151
  console.log(chalk.bold.green('✓ SEO.md initialized successfully'));
@@ -28,7 +28,8 @@ export async function statusCommand(options) {
28
28
  console.log(chalk.white(' npx seomd analyze'));
29
29
  console.log('');
30
30
  }
31
- process.exit(0);
31
+ process.exitCode = 0;
32
+ return;
32
33
  }
33
34
 
34
35
  // Format overall metrics
@@ -77,7 +78,8 @@ export async function statusCommand(options) {
77
78
  });
78
79
 
79
80
  console.log(JSON.stringify(output, null, 2));
80
- process.exit(0);
81
+ process.exitCode = 0;
82
+ return;
81
83
  }
82
84
 
83
85
  // Output beautiful terminal dashboard
@@ -145,7 +147,8 @@ export async function statusCommand(options) {
145
147
  console.log(chalk.red('✗ ') + chalk.bold('Failed to display status:'));
146
148
  console.log(` ${chalk.dim(err.message)}`);
147
149
  console.log('');
148
- process.exit(1);
150
+ process.exitCode = 1;
151
+ return;
149
152
  }
150
153
  }
151
154
 
@@ -1,7 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import ora from 'ora';
3
3
  import fs from 'fs-extra';
4
- import path from 'path';
5
4
  import dotenv from 'dotenv';
6
5
  import { parseSeoMd } from '../utils/parser.js';
7
6
  import { client } from '../utils/api-client.js';
@@ -79,7 +78,7 @@ export async function syncCommand(options) {
79
78
  console.log(`Last Analyzed : ${chalk.dim(aeo.last_analyzed)}`);
80
79
  console.log(chalk.yellow('\n⚠ Dry-run enabled: No files were modified.'));
81
80
  console.log('');
82
- process.exit(0);
81
+ return;
83
82
  }
84
83
 
85
84
  spinner.text = 'Updating repository files...';
@@ -13,7 +13,8 @@ export async function validateCommand() {
13
13
  if (errors.length === 0 && warnings.length === 0) {
14
14
  console.log(chalk.green(' ✓ ') + chalk.bold('SEO.md is fully compliant with spec v1.0!'));
15
15
  console.log('');
16
- process.exit(0);
16
+ process.exitCode = 0;
17
+ return;
17
18
  }
18
19
 
19
20
  // Print errors
@@ -41,18 +42,21 @@ export async function validateCommand() {
41
42
  console.log(chalk.red.bold(`✗ Validation failed: ${errors.length} error(s) and ${warnings.length} warning(s) found.`));
42
43
  console.log(chalk.dim('Please fix the errors to make your SEO.md valid.'));
43
44
  console.log('');
44
- process.exit(1);
45
+ process.exitCode = 1;
46
+ return;
45
47
  } else {
46
48
  console.log(chalk.yellow.bold(`✓ Validation passed with ${warnings.length} warning(s).`));
47
49
  console.log(chalk.dim('Warnings are optional recommendations and do not block compliance.'));
48
50
  console.log('');
49
- process.exit(0);
51
+ process.exitCode = 0;
52
+ return;
50
53
  }
51
54
 
52
55
  } catch (err) {
53
56
  console.log(chalk.red('✗ ') + chalk.bold('Validation process failed:'));
54
57
  console.log(` ${chalk.dim(err.message)}`);
55
58
  console.log('');
56
- process.exit(1);
59
+ process.exitCode = 1;
60
+ return;
57
61
  }
58
62
  }
@@ -40,38 +40,89 @@ export async function createSeomdDir(cwd, answers) {
40
40
  function generateSeomdReadme(brand, domain) {
41
41
  return `# .seomd/
42
42
 
43
- Intelligence directory for ${brand} (${domain})
43
+ AI search optimization workspace for **${brand}** (${domain})
44
44
  Generated by SEO.md CLI — https://seomd.dev
45
45
 
46
+ ## What is this?
47
+
48
+ \`.seomd/\` stores platform-generated intelligence from citation audits, competitor analysis, and reverse-engineered playbooks. It lives alongside your source code so SEO strategy is version-controlled and reviewable in PRs.
49
+
46
50
  ## Directory Structure
47
51
 
48
52
  \`\`\`
49
53
  .seomd/
50
- ├── pages/ # per-page reverse engineer analysis
54
+ ├── pages/ # per-page reverse-engineer analysis
55
+ │ ├── homepage.md
56
+ │ ├── services.md
57
+ │ └── ...
51
58
  ├── reports/ # dated snapshot reports (gitignored by default)
59
+ │ └── 2026-06-10.md
52
60
  └── competitors/ # competitor citation profiles
61
+ └── comp-1.md
53
62
  \`\`\`
54
63
 
55
- ## Usage
64
+ ## Git behavior
65
+
66
+ | Path | Git | Why |
67
+ |------|-----|-----|
68
+ | \`.seomd/pages/\` | tracked | actionable playbooks belong in code review |
69
+ | \`.seomd/competitors/\` | tracked | competitor intel is project state |
70
+ | \`.seomd/reports/\` | ignored | dated snapshots are noise in git history |
71
+
72
+ \`.seomd/reports/\` is added to \`.gitignore\` automatically by \`seomd init\`.
73
+
74
+ ## When to run what
56
75
 
57
76
  \`\`\`bash
58
- # Run citation analysis and update all files
77
+ # First full audit scans all pages + intents
59
78
  npx seomd analyze
60
79
 
61
- # Sync latest platform intelligence
80
+ # Refresh from cached platform data (no new scan)
62
81
  npx seomd sync
63
82
 
64
- # View current status
83
+ # Check current gap scores without modifying files
65
84
  npx seomd status
85
+ npx seomd status --json
66
86
  \`\`\`
67
87
 
68
- ## File Ownership
88
+ | Command | Use when |
89
+ |---------|----------|
90
+ | \`analyze\` | You want a fresh audit, new competitor data, or updated playbooks |
91
+ | \`sync\` | You just want the latest cached data from your platform |
92
+ | \`status\` | You want a quick health check without writebacks |
93
+
94
+ ## Reading a page playbook
95
+
96
+ Each \`pages/<id>.md\` contains:
97
+
98
+ - **why_page_won** — what top-cited competitors do right
99
+ - **citation_hooks** — patterns to replicate
100
+ - **gaps_for_brand** — where your page falls short
101
+ - **remediation_playbook** — step-by-step fixes with effort/impact
102
+ - **fastest_win** — highest ROI action to take now
103
+ - **suggested_content_outline** — ready-to-use outline for your writer or AI
104
+
105
+ ## File ownership
106
+
107
+ | File/dir | Owner | Editable? |
108
+ |-----------|-------|----------|
109
+ | \`SEO.md\` | you | yes |
110
+ | \`SEO.REVERSE.md\` | platform | no |
111
+ | \`.seomd/pages/*.md\` | platform | no |
112
+ | \`.seomd/competitors/*.md\` | platform | no |
113
+ | \`.seomd/reports/*.md\` | platform | no |
114
+
115
+ Platform-generated files are overwritten on each \`analyze\` or \`sync\`.
116
+
117
+ ## Platform connection
118
+
119
+ To enable live writebacks:
69
120
 
70
- - \`pages/\` platform generated, do not edit manually
71
- - \`reports/\` platform generated, gitignored by default
72
- - \`competitors/\` platform generated, do not edit manually
121
+ 1. Connect at https://seomd.dev/connect
122
+ 2. Add \`SEOMD_API_KEY\` to your \`.env\`
123
+ 3. Run \`npx seomd analyze\`
73
124
 
74
- Connect your platform at https://seomd.dev/connect
125
+ _Note: Never commit API keys or \`.env\` files to version control._
75
126
  `;
76
127
  }
77
128
 
@@ -121,12 +121,27 @@ export const PERFORMANCE_THRESHOLDS = {
121
121
  };
122
122
 
123
123
  export const AI_BOTS = [
124
- 'Googlebot',
125
- 'Bingbot',
126
- 'PerplexityBot',
127
- 'ChatGPT-User',
128
- 'GPTBot',
129
- 'ClaudeBot',
130
- 'anthropic-ai',
131
- 'cohere-ai',
124
+ { userAgent: 'GPTBot', allow: '/' },
125
+ { userAgent: 'OAI-SearchBot', allow: '/' },
126
+ { userAgent: 'ChatGPT-User', allow: '/' },
127
+ { userAgent: 'ClaudeBot', allow: '/' },
128
+ { userAgent: 'Claude-User', allow: '/' },
129
+ { userAgent: 'Claude-SearchBot', allow: '/' },
130
+ { userAgent: 'anthropic-ai', allow: '/' },
131
+ { userAgent: 'PerplexityBot', allow: '/' },
132
+ { userAgent: 'Perplexity-User', allow: '/' },
133
+ { userAgent: 'cohere-ai', allow: '/' },
134
+ { userAgent: 'MistralAI-User', allow: '/' },
135
+ { userAgent: 'Googlebot', allow: '/' },
136
+ { userAgent: 'Google-Extended', allow: '/' },
137
+ { userAgent: 'Bingbot', allow: '/' },
138
+ { userAgent: 'Meta-ExternalAgent', allow: '/' },
139
+ { userAgent: 'Meta-ExternalFetcher', allow: '/' },
140
+ { userAgent: 'Applebot', allow: '/' },
141
+ { userAgent: 'Applebot-Extended', allow: '/' },
142
+ { userAgent: 'Bytespider', allow: '/' },
143
+ { userAgent: 'Amazonbot', allow: '/' },
144
+ { userAgent: 'CCBot', allow: '/' },
145
+ { userAgent: 'diffbot', allow: '/' },
146
+ { userAgent: 'webzio-extended', allow: '/' },
132
147
  ];