seomd-cli 1.0.3 → 1.1.2

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,164 @@
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
+ Verify:
40
+
41
+ ```bash
42
+ seomd --help
43
+ ```
18
44
 
19
45
  ## Quick Start
20
46
 
21
- ### 1. Initialize a Specification
22
- Run the interactive setup in the root of your project to generate a tailored `SEO.md` file:
47
+ ### 1) Initialize
48
+
49
+ Run in the root of your project:
23
50
 
24
51
  ```bash
25
52
  seomd init
26
53
  ```
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
54
 
29
- ### 2. Validate the File
30
- Verify that your local file complies with the official open specification rules:
55
+ ### 2) Validate
31
56
 
32
57
  ```bash
33
58
  seomd validate
34
59
  ```
35
60
 
36
- ### 3. Check Local Status
37
- Check validation state and connection parameters:
61
+ ### 3) Run an Audit (Analyze) and Sync
62
+
63
+ ```bash
64
+ seomd analyze
65
+ seomd sync
66
+ ```
67
+
68
+ ### 4) View Status
38
69
 
39
70
  ```bash
40
71
  seomd status
72
+ seomd status --json
41
73
  ```
42
74
 
43
- ---
75
+ ## Configuration
44
76
 
45
- ## Command Reference
77
+ Copy the example env file:
78
+
79
+ ```bash
80
+ cp .env.example .env
81
+ ```
82
+
83
+ Environment variables:
84
+
85
+ - `SEOMD_API_URL` (optional) API base URL (defaults to `https://api.foxcite.com`)
86
+ - `SEOMD_API_KEY` (optional) platform API key (human dashboard auth)
87
+ - `SEOMD_PAYMENT_TOKEN` (optional) agent-native payment token (x402 pay-per-scan)
88
+ - `SEOMD_DOMAIN` (optional) override domain header
89
+
90
+ ## Commands
46
91
 
47
92
  ### `seomd init`
48
- Scaffolds a new `SEO.md` file in the current working directory.
93
+
94
+ Scaffolds `SEO.md`, `SEO.REVERSE.md`, and the `.seomd/` intelligence directory.
49
95
 
50
96
  ### `seomd validate`
51
- Validates the structural integrity and required fields of your `SEO.md` file.
97
+
98
+ Validates your `SEO.md` against the spec requirements.
52
99
 
53
100
  ### `seomd status`
54
- Displays local validation results, connected platforms, and project identification.
101
+
102
+ Shows current citation rates and gap scores from `_analysis`.
103
+
104
+ - `--json` outputs machine-readable JSON for scripts/CI
55
105
 
56
106
  ### `seomd analyze`
57
- Analyzes your local specification and requests search visibility summaries from your connected platform.
107
+
108
+ Runs an AI search audit via your connected platform and writes results back into:
109
+
110
+ - `SEO.md` (`_analysis` blocks)
111
+ - `SEO.REVERSE.md` (generated reverse view)
112
+ - `.seomd/pages/*.md` (per-page playbooks when available)
58
113
 
59
114
  ### `seomd sync`
60
- Synchronizes live engine analysis blocks, keyword gap scores, and cited sources from your connected writeback platform.
61
115
 
62
- ---
116
+ Pulls cached/latest platform intelligence and writes it back to the same files as `analyze`.
117
+
118
+ - `--dry-run` prints a preview and does not modify files
119
+
120
+ ## Local Development
121
+
122
+ Prefer the local entrypoint while developing:
123
+
124
+ ```bash
125
+ node ./bin/seomd.js --help
126
+ node ./bin/seomd.js init
127
+ node ./bin/seomd.js validate
128
+ node ./bin/seomd.js status --json
129
+ ```
130
+
131
+ ## Testing
132
+
133
+ ```bash
134
+ npm test
135
+ ```
136
+
137
+ ## Release Notes (Contributor Tagging)
138
+
139
+ To generate a contributor section for a release (commit-based attribution), maintain mappings in `.github/contributors.yml` and generate markdown from a tag range:
140
+
141
+ ```bash
142
+ npm run release:contributors -- --from v1.0.2 --to v1.0.3
143
+ ```
144
+
145
+ To write output to a file:
146
+
147
+ ```bash
148
+ npm run release:contributors -- --from v1.0.2 --to v1.0.3 --out notes/v1.0.3-contributors.md
149
+ ```
150
+
151
+ To generate a full release note (changes + contributors) for a tag:
152
+
153
+ ```bash
154
+ npm run release:notes -- --tag v1.0.3
155
+ ```
156
+
157
+ 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
158
 
64
159
  ## Platform Connections & API Keys
65
160
 
66
- To enable live citation writebacks (using automated platforms like [Foxcite](https://foxcite.com)):
161
+ To enable live intelligence writebacks (using automated platforms like [Foxcite](https://foxcite.com)):
67
162
 
68
163
  1. Obtain a developer API key from your platform provider.
69
164
  2. Export the key as an environment variable:
@@ -72,9 +167,12 @@ To enable live citation writebacks (using automated platforms like [Foxcite](htt
72
167
  ```
73
168
  3. Run `seomd sync` or `seomd analyze`.
74
169
 
75
- *Note: Never commit your API keys or `.env` files containing keys to version control.*
170
+ _Note: Never commit your API keys or `.env` files containing keys to version control._
171
+
172
+ ## Security
76
173
 
77
- ---
174
+ - Never commit `.env` files or API keys
175
+ - Use `.env.example` as the template for required variables
78
176
 
79
177
  ## Specification Reference
80
178
 
package/bin/seomd.js CHANGED
@@ -32,6 +32,7 @@ program
32
32
  .description('Run citation analysis and write back _analysis blocks')
33
33
  .option('--page <url>', 'analyze a specific page only')
34
34
  .option('--intent <category>', 'analyze a specific intent category only')
35
+ .option('--engines <list>', 'comma-separated list of engines to scan (e.g. chatgpt,claude)')
35
36
  .action(analyzeCommand);
36
37
 
37
38
  program
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seomd-cli",
3
- "version": "1.0.3",
3
+ "version": "1.1.2",
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,12 +14,15 @@
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": {
25
28
  "chalk": "^5.3.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
+
@@ -9,6 +9,23 @@ import { writeAnalysisToSeoMd, writeReverseMd, writePageAnalysis } from '../util
9
9
 
10
10
  dotenv.config();
11
11
 
12
+ function matchRoute(pattern, url) {
13
+ const cleanPattern = pattern.replace(/\/$/, '');
14
+ const cleanUrl = url.replace(/\/$/, '');
15
+
16
+ if (cleanPattern.toLowerCase() === cleanUrl.toLowerCase()) {
17
+ return true;
18
+ }
19
+
20
+ // Replace "/[param]" with optional group "(?:/([^/]+))?"
21
+ const regexPattern = cleanPattern
22
+ .replace(/\/\[[^\]]+\]/g, '(?:\\/([^/]+))?')
23
+ .replace(/\//g, '\\/');
24
+
25
+ const regex = new RegExp('^' + regexPattern + '\\/?$', 'i');
26
+ return regex.test(url);
27
+ }
28
+
12
29
  export async function analyzeCommand(options) {
13
30
  const apiKey = process.env.SEOMD_API_KEY;
14
31
  const paymentToken = process.env.SEOMD_PAYMENT_TOKEN;
@@ -68,21 +85,31 @@ export async function analyzeCommand(options) {
68
85
 
69
86
  // Filter by options.page if specified
70
87
  if (options.page) {
71
- pagesList = pagesList.filter(p => p.url === options.page);
88
+ pagesList = pagesList.filter(p => matchRoute(p.url, options.page));
72
89
  }
73
90
 
74
- // Default to homepage if no pages defined
91
+ // Default fallback if no page matches or list is empty
75
92
  if (pagesList.length === 0) {
93
+ let fallbackId = 'homepage';
94
+ if (options.page && options.page !== '/') {
95
+ fallbackId = options.page
96
+ .replace(/^\//, '')
97
+ .replace(/\/$/, '')
98
+ .replace(/[^a-zA-Z0-9-]/g, '-');
99
+ }
76
100
  pagesList.push({
77
- id: 'homepage',
101
+ id: fallbackId,
78
102
  url: options.page || '/',
79
- primary_keyword: `best ${niche}`,
103
+ primary_keyword: data.keywords?.primary || `best ${niche}`,
80
104
  status: 'planned'
81
105
  });
82
106
  }
83
107
 
84
108
  // Extract engines
85
- const engines = data.aeo?._analysis?.engines_tracked || ['ChatGPT'];
109
+ let engines = data.aeo?._analysis?.engines_tracked || ['ChatGPT'];
110
+ if (options.engines) {
111
+ engines = options.engines.split(',').map(e => e.trim());
112
+ }
86
113
 
87
114
  console.log(chalk.bold.cyan(`\n📊 Foxcite: Running AI Search Audit for ${chalk.white(domain)}`));
88
115
  console.log(chalk.dim(`Engines: ${engines.join(', ')}`));
@@ -92,14 +119,16 @@ export async function analyzeCommand(options) {
92
119
  const spinner = ora('Initializing scan sessions...').start();
93
120
 
94
121
  try {
122
+ const brand = data.identity?.brand || 'My Brand';
95
123
  const payload = {
96
124
  domain,
97
125
  niche,
126
+ brand,
98
127
  queries,
99
128
  engines,
100
129
  pages: pagesList.map(p => ({
101
130
  id: p.id,
102
- url: p.url,
131
+ url: options.page || p.url, // Use the specific page requested if provided
103
132
  primary_keyword: p.primary_keyword || data.keywords?.primary || `best ${niche}`,
104
133
  status: p.status
105
134
  }))
@@ -116,7 +145,8 @@ export async function analyzeCommand(options) {
116
145
  await writeAnalysisToSeoMd(doc, results, cwd);
117
146
 
118
147
  // Writeback to SEO.REVERSE.md
119
- await writeReverseMd(cwd, results);
148
+ const brandName = data.identity?.brand || 'My Brand';
149
+ await writeReverseMd(cwd, results, domain, brandName);
120
150
 
121
151
  // Writeback to .seomd/pages/*.md
122
152
  await writePageAnalysis(cwd, results);
@@ -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
 
@@ -79,7 +79,7 @@ export async function syncCommand(options) {
79
79
  console.log(`Last Analyzed : ${chalk.dim(aeo.last_analyzed)}`);
80
80
  console.log(chalk.yellow('\n⚠ Dry-run enabled: No files were modified.'));
81
81
  console.log('');
82
- process.exit(0);
82
+ return;
83
83
  }
84
84
 
85
85
  spinner.text = 'Updating repository files...';
@@ -88,7 +88,8 @@ export async function syncCommand(options) {
88
88
  await writeAnalysisToSeoMd(doc, results, cwd);
89
89
 
90
90
  // Writeback to SEO.REVERSE.md
91
- await writeReverseMd(cwd, results);
91
+ const brandName = data.identity?.brand || 'My Brand';
92
+ await writeReverseMd(cwd, results, domain, brandName);
92
93
 
93
94
  // Writeback to .seomd/pages/*.md
94
95
  await writePageAnalysis(cwd, results);
@@ -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
  }
@@ -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
  ];
@@ -67,7 +67,7 @@ export async function writeAnalysisToSeoMd(doc, response, cwd) {
67
67
  * @param {string} cwd - Current working directory
68
68
  * @param {any} response - The API response from analyze/sync
69
69
  */
70
- export async function writeReverseMd(cwd, response) {
70
+ export async function writeReverseMd(cwd, response, defaultDomain = 'example.com', defaultBrand = 'My Brand') {
71
71
  const reversePath = path.join(cwd, 'SEO.REVERSE.md');
72
72
 
73
73
  const reversePages = response.page_analysis.map(p => ({
@@ -88,8 +88,8 @@ export async function writeReverseMd(cwd, response) {
88
88
  const primaryCompetitor = response.intent_analysis?.comparison?.top_cited_competitor || 'None';
89
89
 
90
90
  const reverseDoc = {
91
- domain: response.domain || 'example.com',
92
- brand: response.brand_name || 'My Brand',
91
+ domain: response.domain || defaultDomain,
92
+ brand: response.brand_name || defaultBrand,
93
93
  primary_competitor: primaryCompetitor,
94
94
  last_analyzed: response.aeo_analysis.last_analyzed,
95
95
  next_analysis: response.aeo_analysis.next_analysis,