seomd-cli 1.0.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/bin/seomd.js ADDED
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { program } from 'commander';
4
+ import { readFileSync } from 'fs';
5
+ import { fileURLToPath } from 'url';
6
+ import { dirname, join } from 'path';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'));
10
+
11
+ // Commands
12
+ import { initCommand } from '../src/commands/init.js';
13
+ import { analyzeCommand } from '../src/commands/analyze.js';
14
+ import { syncCommand } from '../src/commands/sync.js';
15
+ import { statusCommand } from '../src/commands/status.js';
16
+ import { validateCommand } from '../src/commands/validate.js';
17
+
18
+ program
19
+ .name('seomd')
20
+ .description('AEO infrastructure for technical founders — seomd.dev')
21
+ .version(pkg.version);
22
+
23
+ program
24
+ .command('init')
25
+ .description('Scaffold SEO.md for your project')
26
+ .option('-y, --yes', 'skip prompts and use defaults')
27
+ .option('--type <type>', 'site type: saas, ecommerce, local, blog, marketplace')
28
+ .action(initCommand);
29
+
30
+ program
31
+ .command('analyze')
32
+ .description('Run citation analysis and write back _analysis blocks')
33
+ .option('--page <url>', 'analyze a specific page only')
34
+ .option('--intent <category>', 'analyze a specific intent category only')
35
+ .action(analyzeCommand);
36
+
37
+ program
38
+ .command('sync')
39
+ .description('Sync latest platform intelligence to your SEO.md files')
40
+ .option('--dry-run', 'preview changes without writing')
41
+ .action(syncCommand);
42
+
43
+ program
44
+ .command('status')
45
+ .description('Show current citation rates and gap scores')
46
+ .option('--json', 'output as JSON')
47
+ .action(statusCommand);
48
+
49
+ program
50
+ .command('validate')
51
+ .description('Validate your SEO.md against the spec')
52
+ .action(validateCommand);
53
+
54
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "seomd-cli",
3
+ "version": "1.0.0",
4
+ "description": "The official CLI for the SEO.md open standard — AEO infrastructure for technical founders",
5
+ "homepage": "https://seomd.dev",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/seomd/seomd-cli"
9
+ },
10
+ "license": "MIT",
11
+ "type": "module",
12
+ "bin": {
13
+ "seomd": "./bin/seomd.js"
14
+ },
15
+ "scripts": {
16
+ "dev": "node bin/seomd.js",
17
+ "test": "node --experimental-vm-modules node_modules/.bin/jest",
18
+ "lint": "eslint src/**/*.js"
19
+ },
20
+ "dependencies": {
21
+ "chalk": "^5.3.0",
22
+ "commander": "^11.0.0",
23
+ "enquirer": "^2.4.1",
24
+ "ora": "^7.0.1",
25
+ "yaml": "^2.3.4",
26
+ "fs-extra": "^11.1.1",
27
+ "axios": "^1.6.0",
28
+ "dotenv": "^16.3.1"
29
+ },
30
+ "devDependencies": {
31
+ "jest": "^29.7.0",
32
+ "eslint": "^8.50.0"
33
+ },
34
+ "engines": {
35
+ "node": ">=18.0.0"
36
+ },
37
+ "keywords": [
38
+ "seo",
39
+ "aeo",
40
+ "geo",
41
+ "seomd",
42
+ "ai-search",
43
+ "citation-tracking",
44
+ "llm-seo"
45
+ ]
46
+ }
@@ -0,0 +1,145 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import fs from 'fs-extra';
4
+ import path from 'path';
5
+ import dotenv from 'dotenv';
6
+ import { parseSeoMd } from '../utils/parser.js';
7
+ import { client } from '../utils/api-client.js';
8
+ import { writeAnalysisToSeoMd, writeReverseMd, writePageAnalysis } from '../utils/writeback.js';
9
+
10
+ dotenv.config();
11
+
12
+ export async function analyzeCommand(options) {
13
+ const apiKey = process.env.SEOMD_API_KEY;
14
+ const paymentToken = process.env.SEOMD_PAYMENT_TOKEN;
15
+
16
+ if (!apiKey && !paymentToken) {
17
+ console.log('');
18
+ console.log(chalk.yellow('⚠ No API key or payment token found.'));
19
+ console.log('');
20
+ console.log('Add your API key to .env:');
21
+ console.log(chalk.cyan(' SEOMD_API_KEY=your_key_here'));
22
+ console.log('');
23
+ console.log('Get your API key at ' + chalk.cyan('https://seomd.dev/connect'));
24
+ console.log('');
25
+ process.exit(1);
26
+ }
27
+
28
+ const cwd = process.cwd();
29
+ let doc, data;
30
+
31
+ try {
32
+ const parsed = await parseSeoMd(cwd);
33
+ doc = parsed.doc;
34
+ data = parsed.data;
35
+ } catch (err) {
36
+ console.log(chalk.red(`\n❌ Error: ${err.message}`));
37
+ process.exit(1);
38
+ }
39
+
40
+ const domain = data.site?.domain;
41
+ if (!domain) {
42
+ console.log(chalk.red('\n❌ Error: "site.domain" is required in SEO.md.'));
43
+ process.exit(1);
44
+ }
45
+
46
+ const niche = data.site?.type || 'saas';
47
+
48
+ // Extract intent queries
49
+ const queries = {};
50
+ if (data.intent) {
51
+ for (const [intentType, obj] of Object.entries(data.intent)) {
52
+ if (intentType !== '_analysis' && obj && Array.isArray(obj.queries)) {
53
+ queries[intentType] = obj.queries;
54
+ }
55
+ }
56
+ }
57
+
58
+ // Extract pages
59
+ let pagesList = [];
60
+ if (data.pages) {
61
+ if (Array.isArray(data.pages.required)) {
62
+ pagesList = pagesList.concat(data.pages.required);
63
+ }
64
+ if (Array.isArray(data.pages.optional)) {
65
+ pagesList = pagesList.concat(data.pages.optional);
66
+ }
67
+ }
68
+
69
+ // Default to homepage if no pages defined
70
+ if (pagesList.length === 0) {
71
+ pagesList.push({
72
+ id: 'homepage',
73
+ url: '/',
74
+ primary_keyword: `best ${niche}`,
75
+ status: 'planned'
76
+ });
77
+ }
78
+
79
+ // Extract engines
80
+ const engines = data.aeo?._analysis?.engines_tracked || ['Claude', 'ChatGPT'];
81
+
82
+ console.log(chalk.bold.cyan(`\n📊 foxcite: Running AI Search Audit for ${chalk.white(domain)}`));
83
+ console.log(chalk.dim(`Engines: ${engines.join(', ')}`));
84
+ console.log(chalk.dim(`Pages to scan: ${pagesList.length}`));
85
+ console.log('');
86
+
87
+ const spinner = ora('Initializing scan sessions...').start();
88
+
89
+ try {
90
+ const payload = {
91
+ domain,
92
+ niche,
93
+ queries,
94
+ engines,
95
+ pages: pagesList.map(p => ({
96
+ id: p.id,
97
+ url: p.url,
98
+ primary_keyword: p.primary_keyword,
99
+ status: p.status
100
+ }))
101
+ };
102
+
103
+ spinner.text = 'Scanning AI search engines and compiling citations (this may take up to a minute)...';
104
+
105
+ const response = await client.post('/cli/analyze', payload);
106
+ const results = response.data;
107
+
108
+ spinner.text = 'Writing analysis blocks back to repository files...';
109
+
110
+ // Writeback to SEO.md
111
+ await writeAnalysisToSeoMd(doc, results, cwd);
112
+
113
+ // Writeback to SEO.REVERSE.md
114
+ await writeReverseMd(cwd, results);
115
+
116
+ // Writeback to .seomd/pages/*.md
117
+ await writePageAnalysis(cwd, results);
118
+
119
+ spinner.succeed(chalk.green('Analysis completed successfully!'));
120
+ console.log('');
121
+
122
+ // Display results summary
123
+ const aeo = results.aeo_analysis;
124
+ console.log(chalk.bold('--- Results Summary ---'));
125
+ console.log(`Overall Citation Rate : ${chalk.bold.green((aeo.overall_citation_rate * 100).toFixed(0) + '%')}`);
126
+ console.log(`Overall Gap Score : ${chalk.bold.red(aeo.overall_gap_score)}`);
127
+
128
+ if (results.credits_remaining !== null) {
129
+ console.log(`Credits Remaining : ${chalk.cyan(results.credits_remaining)}`);
130
+ }
131
+ console.log(`Last Analyzed : ${chalk.dim(aeo.last_analyzed)}`);
132
+ console.log(`Next Analysis Target : ${chalk.dim(aeo.next_analysis)}`);
133
+ console.log('-----------------------');
134
+ console.log('');
135
+ console.log(chalk.green('✔ SEO.md updated.'));
136
+ console.log(chalk.green('✔ SEO.REVERSE.md updated.'));
137
+ console.log(chalk.green('✔ .seomd/pages/ playbooks generated.'));
138
+ console.log('');
139
+
140
+ } catch (err) {
141
+ spinner.fail(chalk.red('Analysis failed'));
142
+ console.error(chalk.bold.red(`\nError: ${err.message}`));
143
+ process.exit(1);
144
+ }
145
+ }
@@ -0,0 +1,160 @@
1
+ import chalk from 'chalk';
2
+ import enquirer from 'enquirer';
3
+ const { prompt } = enquirer;
4
+ import ora from 'ora';
5
+ import fs from 'fs-extra';
6
+ import path from 'path';
7
+ import { generateSeoMd } from '../generators/seomd.js';
8
+ import { generateReverseMd } from '../generators/reverse.js';
9
+ import { createSeomdDir } from '../generators/directory.js';
10
+ import { SITE_TYPES } from '../utils/constants.js';
11
+
12
+ export async function initCommand(options) {
13
+ console.log('');
14
+ console.log(chalk.bold('SEO.md') + chalk.dim(' v0.1.0 — https://seomd.dev'));
15
+ console.log('');
16
+ console.log(chalk.dim('The open standard for AI-era SEO configuration.'));
17
+ console.log('');
18
+
19
+ // Check if SEO.md already exists
20
+ const seomdPath = path.join(process.cwd(), 'SEO.md');
21
+ if (await fs.pathExists(seomdPath)) {
22
+ console.log(chalk.yellow('⚠ SEO.md already exists in this directory.'));
23
+ const { overwrite } = await prompt({
24
+ type: 'confirm',
25
+ name: 'overwrite',
26
+ message: 'Overwrite existing SEO.md?',
27
+ initial: false,
28
+ });
29
+ if (!overwrite) {
30
+ console.log(chalk.dim('Aborted.'));
31
+ process.exit(0);
32
+ }
33
+ }
34
+
35
+ let answers;
36
+
37
+ if (options.yes) {
38
+ // Default values for --yes flag
39
+ answers = {
40
+ site_type: options.type || 'saas',
41
+ domain: 'example.com',
42
+ brand: 'My Brand',
43
+ primary_keyword: '',
44
+ competitors: '',
45
+ };
46
+ } else {
47
+ // The 5-question init flow
48
+ answers = await prompt([
49
+ {
50
+ type: 'select',
51
+ name: 'site_type',
52
+ message: 'Site type:',
53
+ choices: SITE_TYPES.map(t => ({ name: t.value, message: t.name })),
54
+ initial: options.type ? SITE_TYPES.findIndex(t => t.value === options.type) : 0,
55
+ },
56
+ {
57
+ type: 'input',
58
+ name: 'domain',
59
+ message: 'Primary domain:',
60
+ hint: 'e.g. myapp.com',
61
+ validate(value) {
62
+ if (!value) return 'Domain is required';
63
+ // Strip protocol if provided
64
+ return true;
65
+ },
66
+ result(value) {
67
+ return value.replace(/^https?:\/\//, '').replace(/\/$/, '');
68
+ },
69
+ },
70
+ {
71
+ type: 'input',
72
+ name: 'brand',
73
+ message: 'Brand name:',
74
+ hint: 'e.g. MyApp',
75
+ validate(value) {
76
+ if (!value) return 'Brand name is required';
77
+ return true;
78
+ },
79
+ },
80
+ {
81
+ type: 'input',
82
+ name: 'primary_keyword',
83
+ message: 'Primary keyword:',
84
+ hint: 'e.g. project management software',
85
+ validate(value) {
86
+ if (!value) return 'Primary keyword is required';
87
+ return true;
88
+ },
89
+ },
90
+ {
91
+ type: 'input',
92
+ name: 'competitors',
93
+ message: 'Top 3 competitors (comma separated):',
94
+ hint: 'e.g. asana.com, monday.com, notion.so',
95
+ result(value) {
96
+ return value
97
+ .split(',')
98
+ .map(c => c.trim())
99
+ .filter(Boolean)
100
+ .slice(0, 3);
101
+ },
102
+ },
103
+ ]);
104
+ }
105
+
106
+ console.log('');
107
+ const spinner = ora('Scaffolding your SEO.md files...').start();
108
+
109
+ try {
110
+ // 1. Generate SEO.md
111
+ const seomdContent = generateSeoMd(answers);
112
+ await fs.writeFile(seomdPath, seomdContent, 'utf8');
113
+ spinner.succeed(chalk.green('SEO.md created'));
114
+
115
+ // 2. Generate SEO.REVERSE.md
116
+ const reversePath = path.join(process.cwd(), 'SEO.REVERSE.md');
117
+ const reverseContent = generateReverseMd(answers);
118
+ await fs.writeFile(reversePath, reverseContent, 'utf8');
119
+ spinner.succeed(chalk.green('SEO.REVERSE.md initialized'));
120
+
121
+ // 3. Create .seomd/ directory structure
122
+ await createSeomdDir(process.cwd(), answers);
123
+ spinner.succeed(chalk.green('.seomd/ directory created'));
124
+
125
+ // 4. Add .seomd/ to .gitignore if it exists
126
+ await updateGitignore(process.cwd());
127
+
128
+ console.log('');
129
+ console.log(chalk.bold.green('✓ SEO.md initialized successfully'));
130
+ console.log('');
131
+ console.log(chalk.dim('Files created:'));
132
+ console.log(' ' + chalk.cyan('SEO.md') + chalk.dim(' — your living SEO config'));
133
+ console.log(' ' + chalk.cyan('SEO.REVERSE.md') + chalk.dim(' — reverse engineer output (platform generated)'));
134
+ console.log(' ' + chalk.cyan('.seomd/') + chalk.dim(' — intelligence directory'));
135
+ console.log('');
136
+ console.log(chalk.dim('Next steps:'));
137
+ console.log(' ' + chalk.white('npx seomd analyze') + chalk.dim(' — run your first citation analysis'));
138
+ console.log(' ' + chalk.white('npx seomd status') + chalk.dim(' — view current gap scores'));
139
+ console.log('');
140
+ console.log(chalk.dim('Connect your platform at ') + chalk.cyan('https://seomd.dev/connect'));
141
+ console.log('');
142
+
143
+ } catch (err) {
144
+ spinner.fail(chalk.red('Failed to scaffold SEO.md'));
145
+ console.error(chalk.dim(err.message));
146
+ process.exit(1);
147
+ }
148
+ }
149
+
150
+ async function updateGitignore(cwd) {
151
+ const gitignorePath = path.join(cwd, '.gitignore');
152
+ const entry = '\n# seomd intelligence directory\n.seomd/reports/\n';
153
+
154
+ if (await fs.pathExists(gitignorePath)) {
155
+ const content = await fs.readFile(gitignorePath, 'utf8');
156
+ if (!content.includes('.seomd')) {
157
+ await fs.appendFile(gitignorePath, entry);
158
+ }
159
+ }
160
+ }
@@ -0,0 +1,192 @@
1
+ import chalk from 'chalk';
2
+ import { parseSeoMd } from '../utils/parser.js';
3
+
4
+ export async function statusCommand(options) {
5
+ try {
6
+ const { data } = await parseSeoMd(process.cwd());
7
+
8
+ const aeoAnalysis = data.aeo?._analysis || {};
9
+ const intentAnalysis = data.intent?._analysis || {};
10
+ const pagesRequired = data.pages?.required || [];
11
+ const pagesAnalysis = data.pages?._analysis?.pages || [];
12
+
13
+ // Check if there is any analysis data
14
+ const hasOverallData = aeoAnalysis.overall_citation_rate !== null && aeoAnalysis.overall_citation_rate !== undefined;
15
+
16
+ if (!hasOverallData) {
17
+ if (options.json) {
18
+ console.log(JSON.stringify({ status: "no_data", message: "No analysis data found" }, null, 2));
19
+ } else {
20
+ console.log('');
21
+ console.log(chalk.yellow('⚠ No analysis data found in SEO.md.'));
22
+ console.log('');
23
+ console.log('To populate analysis data:');
24
+ console.log(` 1. Get an API key at ${chalk.cyan('https://seomd.dev/connect')}`);
25
+ console.log(' 2. Add it to your .env file:');
26
+ console.log(chalk.cyan(' SEOMD_API_KEY=your_key_here'));
27
+ console.log(' 3. Run citation analysis:');
28
+ console.log(chalk.white(' npx seomd analyze'));
29
+ console.log('');
30
+ }
31
+ process.exit(0);
32
+ }
33
+
34
+ // Format overall metrics
35
+ const overallCitation = formatPercentage(aeoAnalysis.overall_citation_rate);
36
+ const overallGap = formatScore(aeoAnalysis.overall_gap_score);
37
+
38
+ if (options.json) {
39
+ // Output structured JSON
40
+ const output = {
41
+ site: {
42
+ type: data.site?.type || null,
43
+ domain: data.site?.domain || null,
44
+ brand: data.identity?.brand || null,
45
+ },
46
+ overall: {
47
+ citation_rate: aeoAnalysis.overall_citation_rate,
48
+ gap_score: aeoAnalysis.overall_gap_score,
49
+ last_analyzed: aeoAnalysis.last_analyzed || null,
50
+ },
51
+ intent: {},
52
+ pages: []
53
+ };
54
+
55
+ const categories = ['informational', 'comparison', 'transactional', 'reputational', 'category'];
56
+ categories.forEach(cat => {
57
+ const catData = intentAnalysis[cat] || {};
58
+ const declared = data.intent?.[cat] || {};
59
+ output.intent[cat] = {
60
+ priority: declared.priority || null,
61
+ citation_rate: catData.citation_rate || null,
62
+ gap_score: catData.gap_score || null,
63
+ trend: catData.trend || null
64
+ };
65
+ });
66
+
67
+ pagesRequired.forEach(req => {
68
+ const anal = pagesAnalysis.find(p => p.id === req.id) || {};
69
+ output.pages.push({
70
+ id: req.id,
71
+ url: req.url,
72
+ status: req.status || 'planned',
73
+ priority: req.priority || 0,
74
+ citation_rate: anal.citation_rate !== undefined ? anal.citation_rate : null,
75
+ gap_score: anal.gap_score !== undefined ? anal.gap_score : null,
76
+ });
77
+ });
78
+
79
+ console.log(JSON.stringify(output, null, 2));
80
+ process.exit(0);
81
+ }
82
+
83
+ // Output beautiful terminal dashboard
84
+ console.log('');
85
+ console.log(chalk.bold('SEO.md Status Dashboard') + chalk.dim(` — ${data.identity?.brand || 'Brand'} (${data.site?.domain || 'domain'})`));
86
+ console.log(chalk.dim(`Last analyzed: ${aeoAnalysis.last_analyzed || 'N/A'}`));
87
+ console.log(chalk.dim('─'.repeat(60)));
88
+
89
+ // Overall block
90
+ console.log(`Overall Citation Rate: ${colorCitation(aeoAnalysis.overall_citation_rate, overallCitation)}`);
91
+ console.log(`Overall Gap Score: ${colorGap(aeoAnalysis.overall_gap_score, overallGap)}`);
92
+ console.log(chalk.dim('─'.repeat(60)));
93
+
94
+ // Intent Categories Table
95
+ console.log(chalk.bold('INTENT CATEGORY SUMMARY'));
96
+ console.log('');
97
+ console.log(formatRow('Category', 'Priority', 'Citation Rate', 'Gap Score', 'Trend'));
98
+ console.log(chalk.dim('─'.repeat(65)));
99
+
100
+ const categories = ['informational', 'comparison', 'transactional', 'reputational', 'category'];
101
+ categories.forEach(cat => {
102
+ const declared = data.intent?.[cat] || {};
103
+ const anal = intentAnalysis[cat] || {};
104
+
105
+ const name = cat.charAt(0).toUpperCase() + cat.slice(1);
106
+ const priority = declared.priority || 'medium';
107
+ const citationVal = anal.citation_rate;
108
+ const gapVal = anal.gap_score;
109
+ const trend = anal.trend || '-';
110
+
111
+ const citationStr = colorCitation(citationVal, formatPercentage(citationVal));
112
+ const gapStr = colorGap(gapVal, formatScore(gapVal));
113
+
114
+ console.log(formatRow(name, priority, citationStr, gapStr, trend));
115
+ });
116
+
117
+ console.log(chalk.dim('─'.repeat(65)));
118
+ console.log('');
119
+
120
+ // Pages Table
121
+ console.log(chalk.bold('PAGE ANALYSIS SUMMARY'));
122
+ console.log('');
123
+ console.log(formatRowPage('Page ID', 'URL', 'Status', 'Citation Rate', 'Gap Score'));
124
+ console.log(chalk.dim('─'.repeat(70)));
125
+
126
+ pagesRequired.forEach(req => {
127
+ const anal = pagesAnalysis.find(p => p.id === req.id) || {};
128
+
129
+ const id = req.id;
130
+ const url = req.url;
131
+ const status = req.status || 'planned';
132
+ const citationVal = anal.citation_rate;
133
+ const gapVal = anal.gap_score;
134
+
135
+ const citationStr = colorCitation(citationVal, formatPercentage(citationVal));
136
+ const gapStr = colorGap(gapVal, formatScore(gapVal));
137
+
138
+ console.log(formatRowPage(id, url, status, citationStr, gapStr));
139
+ });
140
+
141
+ console.log(chalk.dim('─'.repeat(70)));
142
+ console.log('');
143
+
144
+ } catch (err) {
145
+ console.log(chalk.red('✗ ') + chalk.bold('Failed to display status:'));
146
+ console.log(` ${chalk.dim(err.message)}`);
147
+ console.log('');
148
+ process.exit(1);
149
+ }
150
+ }
151
+
152
+ // Helpers for string formatting/alignment
153
+ function formatRow(cat, pri, cit, gap, trend) {
154
+ return `${cat.padEnd(16)} │ ${pri.padEnd(10)} │ ${cit.padEnd(23)} │ ${gap.padEnd(19)} │ ${trend}`;
155
+ }
156
+
157
+ function formatRowPage(id, url, status, cit, gap) {
158
+ return `${id.padEnd(14)} │ ${url.padEnd(20)} │ ${status.padEnd(8)} │ ${cit.padEnd(23)} │ ${gap.padEnd(19)}`;
159
+ }
160
+
161
+ function formatPercentage(val) {
162
+ if (val === null || val === undefined) return '-';
163
+ if (typeof val === 'number') {
164
+ return `${(val * 100).toFixed(0)}%`;
165
+ }
166
+ return String(val);
167
+ }
168
+
169
+ function formatScore(val) {
170
+ if (val === null || val === undefined) return '-';
171
+ return String(val);
172
+ }
173
+
174
+ function colorCitation(val, str) {
175
+ if (val === null || val === undefined) return chalk.dim(str);
176
+ const num = typeof val === 'number' ? val * 100 : parseFloat(val);
177
+ if (isNaN(num)) return chalk.dim(str);
178
+
179
+ if (num >= 20) return chalk.green.bold(str);
180
+ if (num >= 5) return chalk.yellow.bold(str);
181
+ return chalk.red.bold(str);
182
+ }
183
+
184
+ function colorGap(val, str) {
185
+ if (val === null || val === undefined) return chalk.dim(str);
186
+ const num = parseInt(val, 10);
187
+ if (isNaN(num)) return chalk.dim(str);
188
+
189
+ if (num < 30) return chalk.green.bold(str);
190
+ if (num <= 70) return chalk.yellow.bold(str);
191
+ return chalk.red.bold(str);
192
+ }