content-grade 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/CONTRIBUTING.md +198 -0
- package/LICENSE +21 -0
- package/README.md +348 -0
- package/bin/content-grade.js +1033 -0
- package/bin/telemetry.js +230 -0
- package/dist/assets/index-BUN69TiT.js +78 -0
- package/dist/index.html +22 -0
- package/dist-server/server/app.js +36 -0
- package/dist-server/server/claude.js +22 -0
- package/dist-server/server/db.js +79 -0
- package/dist-server/server/index.js +62 -0
- package/dist-server/server/routes/demos.js +1701 -0
- package/dist-server/server/routes/stripe.js +136 -0
- package/dist-server/server/services/claude.js +55 -0
- package/dist-server/server/services/stripe.js +109 -0
- package/package.json +84 -0
|
@@ -0,0 +1,1033 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ContentGrade CLI
|
|
4
|
+
*
|
|
5
|
+
* Commands:
|
|
6
|
+
* analyze <file> Analyze content from a file (zero-config, just needs Claude)
|
|
7
|
+
* headline "<text>" Grade a single headline
|
|
8
|
+
* start Launch the full web dashboard
|
|
9
|
+
* init Guided first-run setup
|
|
10
|
+
* help Show this help
|
|
11
|
+
*
|
|
12
|
+
* Example:
|
|
13
|
+
* npx content-grade analyze ./my-post.md
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execFileSync, execFile, spawn } from 'child_process';
|
|
17
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, statSync } from 'fs';
|
|
18
|
+
import { resolve, dirname, basename, extname } from 'path';
|
|
19
|
+
import { fileURLToPath } from 'url';
|
|
20
|
+
import { promisify } from 'util';
|
|
21
|
+
import { initTelemetry, recordEvent, disableTelemetry, enableTelemetry, telemetryStatus, isOptedOut } from './telemetry.js';
|
|
22
|
+
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const root = resolve(__dirname, '..');
|
|
25
|
+
const execFileAsync = promisify(execFile);
|
|
26
|
+
|
|
27
|
+
let _version = '1.0.0';
|
|
28
|
+
try { _version = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')).version ?? '1.0.0'; } catch {}
|
|
29
|
+
|
|
30
|
+
// ── ANSI colours ──────────────────────────────────────────────────────────────
|
|
31
|
+
const R = '\x1b[0m';
|
|
32
|
+
const B = '\x1b[1m';
|
|
33
|
+
const D = '\x1b[2m';
|
|
34
|
+
const GN = '\x1b[32m';
|
|
35
|
+
const YL = '\x1b[33m';
|
|
36
|
+
const RD = '\x1b[31m';
|
|
37
|
+
const CY = '\x1b[36m';
|
|
38
|
+
const MG = '\x1b[35m';
|
|
39
|
+
const BL = '\x1b[34m';
|
|
40
|
+
const WH = '\x1b[97m';
|
|
41
|
+
|
|
42
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
function ok(msg) { console.log(` ${GN}✓${R} ${msg}`); }
|
|
45
|
+
function warn(msg) { console.log(` ${YL}⚠${R} ${msg}`); }
|
|
46
|
+
function fail(msg) { console.log(` ${RD}✗${R} ${msg}`); }
|
|
47
|
+
function info(msg) { console.log(` ${D}${msg}${R}`); }
|
|
48
|
+
function hr() { console.log(` ${D}${'─'.repeat(58)}${R}`); }
|
|
49
|
+
function blank() { console.log(''); }
|
|
50
|
+
|
|
51
|
+
function banner() {
|
|
52
|
+
blank();
|
|
53
|
+
console.log(`${B}${CY} ╔═══════════════════════════════════════╗${R}`);
|
|
54
|
+
console.log(`${B}${CY} ║ ContentGrade · Content Analysis ║${R}`);
|
|
55
|
+
console.log(`${B}${CY} ╚═══════════════════════════════════════╝${R}`);
|
|
56
|
+
blank();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function scoreBar(score, width = 30) {
|
|
60
|
+
const clamped = Math.max(0, Math.min(100, score || 0));
|
|
61
|
+
const filled = Math.round((clamped / 100) * width);
|
|
62
|
+
const empty = width - filled;
|
|
63
|
+
const color = score >= 70 ? GN : score >= 45 ? YL : RD;
|
|
64
|
+
return `${color}${'█'.repeat(filled)}${D}${'░'.repeat(empty)}${R}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function gradeLetter(score) {
|
|
68
|
+
if (score >= 90) return `${GN}${B}A+${R}`;
|
|
69
|
+
if (score >= 85) return `${GN}${B}A${R}`;
|
|
70
|
+
if (score >= 80) return `${GN}${B}A-${R}`;
|
|
71
|
+
if (score >= 75) return `${YL}${B}B+${R}`;
|
|
72
|
+
if (score >= 70) return `${YL}${B}B${R}`;
|
|
73
|
+
if (score >= 65) return `${YL}${B}B-${R}`;
|
|
74
|
+
if (score >= 60) return `${YL}${B}C+${R}`;
|
|
75
|
+
if (score >= 55) return `${YL}${B}C${R}`;
|
|
76
|
+
if (score >= 50) return `${YL}${B}C-${R}`;
|
|
77
|
+
if (score >= 40) return `${RD}${B}D${R}`;
|
|
78
|
+
return `${RD}${B}F${R}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Claude wrapper ────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
function getClaudePath() {
|
|
84
|
+
return process.env.CLAUDE_PATH || 'claude';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function checkClaude() {
|
|
88
|
+
try {
|
|
89
|
+
execFileSync(getClaudePath(), ['--version'], { stdio: 'pipe', timeout: 5000 });
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function askClaude(prompt, systemPrompt, model = 'claude-haiku-4-5-20251001') {
|
|
97
|
+
const claudePath = getClaudePath();
|
|
98
|
+
const args = ['-p', '--no-session-persistence', '--model', model];
|
|
99
|
+
if (systemPrompt) args.push('--system-prompt', systemPrompt);
|
|
100
|
+
args.push(prompt);
|
|
101
|
+
|
|
102
|
+
const { stdout } = await execFileAsync(claudePath, args, {
|
|
103
|
+
timeout: 120000,
|
|
104
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
105
|
+
});
|
|
106
|
+
return stdout.trim();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseJSON(raw) {
|
|
110
|
+
try { return JSON.parse(raw); } catch {}
|
|
111
|
+
const m = raw.match(/\{[\s\S]*\}/);
|
|
112
|
+
if (m) {
|
|
113
|
+
try { return JSON.parse(m[0]); } catch {}
|
|
114
|
+
}
|
|
115
|
+
throw new Error(`Could not parse response as JSON.\n\nRaw output:\n${raw.slice(0, 400)}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Content type detection ────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
function detectContentType(text, filename) {
|
|
121
|
+
const lower = text.toLowerCase();
|
|
122
|
+
const name = filename.toLowerCase();
|
|
123
|
+
|
|
124
|
+
if (name.includes('email') || lower.includes('subject:') || lower.includes('unsubscribe'))
|
|
125
|
+
return 'email';
|
|
126
|
+
// Use word-boundary check to avoid "thread" or "gradient" falsely matching "ad"
|
|
127
|
+
if (/\bad\b/.test(name) || lower.includes('cta') || lower.includes('click here') || lower.includes('buy now'))
|
|
128
|
+
return 'ad';
|
|
129
|
+
if (name.includes('thread') || lower.includes('@') || /(?:^|\s)#[a-z]/m.test(lower))
|
|
130
|
+
return 'social';
|
|
131
|
+
if (lower.includes('landing') || lower.includes('hero') || lower.includes('social proof'))
|
|
132
|
+
return 'landing';
|
|
133
|
+
return 'blog';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Analyze command ───────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
const ANALYZE_SYSTEM = `You are a world-class content strategist and conversion copywriter. You analyze written content and give actionable, specific feedback.
|
|
139
|
+
|
|
140
|
+
Analyze the provided content and return ONLY valid JSON (no markdown, no explanation outside JSON):
|
|
141
|
+
|
|
142
|
+
{
|
|
143
|
+
"content_type": "<blog_post|landing_page|email|social_thread|ad_copy>",
|
|
144
|
+
"total_score": <0-100>,
|
|
145
|
+
"grade": "<A+|A|A-|B+|B|B-|C+|C|C-|D|F>",
|
|
146
|
+
"headline": {
|
|
147
|
+
"text": "<the headline/title you found>",
|
|
148
|
+
"score": <0-100>,
|
|
149
|
+
"feedback": "<1-2 sentences>"
|
|
150
|
+
},
|
|
151
|
+
"dimensions": {
|
|
152
|
+
"clarity": { "score": <0-100>, "label": "Clarity", "feedback": "<1 sentence>" },
|
|
153
|
+
"engagement": { "score": <0-100>, "label": "Engagement", "feedback": "<1 sentence>" },
|
|
154
|
+
"structure": { "score": <0-100>, "label": "Structure", "feedback": "<1 sentence>" },
|
|
155
|
+
"value": { "score": <0-100>, "label": "Value Delivery", "feedback": "<1 sentence>" }
|
|
156
|
+
},
|
|
157
|
+
"strengths": ["<specific strength 1>", "<specific strength 2>"],
|
|
158
|
+
"improvements": [
|
|
159
|
+
{ "priority": "high", "issue": "<specific issue>", "fix": "<specific actionable fix>" },
|
|
160
|
+
{ "priority": "high", "issue": "<specific issue>", "fix": "<specific actionable fix>" },
|
|
161
|
+
{ "priority": "medium", "issue": "<specific issue>", "fix": "<specific actionable fix>" }
|
|
162
|
+
],
|
|
163
|
+
"headline_rewrites": [
|
|
164
|
+
"<rewritten headline 1 — more specific, benefit-driven>",
|
|
165
|
+
"<rewritten headline 2 — different angle>"
|
|
166
|
+
],
|
|
167
|
+
"one_line_verdict": "<single punchy sentence summarising the content's biggest opportunity>"
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
SCORING CALIBRATION:
|
|
171
|
+
- Use the full 0-100 range. Generic content scores <40. Good content scores 60-79. Exceptional content scores 80+.
|
|
172
|
+
- Be specific in feedback — name the exact line, word, or element you're referring to.
|
|
173
|
+
- Improvements must be actionable: "Add a statistic to support claim in paragraph 2" not "Add more proof".`;
|
|
174
|
+
|
|
175
|
+
async function cmdAnalyze(filePath) {
|
|
176
|
+
if (!filePath) {
|
|
177
|
+
blank();
|
|
178
|
+
fail(`No file specified.`);
|
|
179
|
+
blank();
|
|
180
|
+
console.log(` Usage:`);
|
|
181
|
+
console.log(` ${CY}content-grade analyze <file>${R} ${D}(or: check <file>)${R}`);
|
|
182
|
+
blank();
|
|
183
|
+
console.log(` Examples:`);
|
|
184
|
+
console.log(` ${D}content-grade analyze ./blog-post.md${R}`);
|
|
185
|
+
console.log(` ${D}content-grade check ./email-draft.txt${R}`);
|
|
186
|
+
console.log(` ${D}content-grade analyze ~/landing-page-copy.md${R}`);
|
|
187
|
+
blank();
|
|
188
|
+
console.log(` ${D}No file? Try the demo: ${CY}content-grade demo${R}`);
|
|
189
|
+
blank();
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const absPath = resolve(process.cwd(), filePath);
|
|
194
|
+
if (!existsSync(absPath)) {
|
|
195
|
+
blank();
|
|
196
|
+
fail(`File not found: ${absPath}`);
|
|
197
|
+
blank();
|
|
198
|
+
console.log(` ${YL}Check the path and try again.${R}`);
|
|
199
|
+
console.log(` ${D}Tip: use a relative path like ./my-file.md or an absolute path.${R}`);
|
|
200
|
+
blank();
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Guard: reject directories
|
|
205
|
+
const fileStat = statSync(absPath);
|
|
206
|
+
if (!fileStat.isFile()) {
|
|
207
|
+
blank();
|
|
208
|
+
fail(`Path is not a file: ${absPath}`);
|
|
209
|
+
blank();
|
|
210
|
+
console.log(` ${D}Pass a text file (.md, .txt) — not a directory.${R}`);
|
|
211
|
+
blank();
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Guard: reject files over 500 KB before reading into memory
|
|
216
|
+
const MAX_BYTES = 500 * 1024;
|
|
217
|
+
if (fileStat.size > MAX_BYTES) {
|
|
218
|
+
blank();
|
|
219
|
+
fail(`File is too large (${Math.round(fileStat.size / 1024)} KB). Maximum is 500 KB.`);
|
|
220
|
+
blank();
|
|
221
|
+
console.log(` ${YL}Tip:${R} Copy the relevant section into a new file and analyze that.`);
|
|
222
|
+
blank();
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const content = readFileSync(absPath, 'utf8');
|
|
227
|
+
|
|
228
|
+
// Guard: reject binary files (null bytes indicate non-text content)
|
|
229
|
+
if (content.includes('\x00')) {
|
|
230
|
+
blank();
|
|
231
|
+
fail(`File appears to be binary (not readable text): ${basename(absPath)}`);
|
|
232
|
+
blank();
|
|
233
|
+
console.log(` ${YL}ContentGrade analyzes written content — blog posts, emails, ad copy, landing pages.${R}`);
|
|
234
|
+
console.log(` ${D}Supported formats: .md, .txt, .mdx, or any plain-text file.${R}`);
|
|
235
|
+
blank();
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (content.trim().length < 20) {
|
|
240
|
+
blank();
|
|
241
|
+
fail(`File is too short to analyze (${content.trim().length} chars). Add some content and try again.`);
|
|
242
|
+
blank();
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
banner();
|
|
247
|
+
console.log(` ${B}Analyzing:${R} ${CY}${basename(absPath)}${R}`);
|
|
248
|
+
console.log(` ${D}${content.length.toLocaleString()} characters · detecting content type...${R}`);
|
|
249
|
+
blank();
|
|
250
|
+
|
|
251
|
+
// Check Claude
|
|
252
|
+
if (!checkClaude()) {
|
|
253
|
+
fail(`Claude CLI not found.`);
|
|
254
|
+
blank();
|
|
255
|
+
console.log(` ContentGrade uses your local Claude CLI — no API keys needed.`);
|
|
256
|
+
blank();
|
|
257
|
+
console.log(` ${B}To install Claude CLI:${R}`);
|
|
258
|
+
console.log(` 1. Visit ${CY}https://claude.ai/code${R}`);
|
|
259
|
+
console.log(` 2. Install and run: ${CY}claude login${R}`);
|
|
260
|
+
console.log(` 3. Verify: ${CY}claude -p "say hello"${R}`);
|
|
261
|
+
blank();
|
|
262
|
+
console.log(` ${D}If Claude is installed but not on PATH, set:${R}`);
|
|
263
|
+
console.log(` ${D} export CLAUDE_PATH=/path/to/claude${R}`);
|
|
264
|
+
blank();
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const filename = basename(absPath, extname(absPath));
|
|
269
|
+
const hintedType = detectContentType(content, filename);
|
|
270
|
+
const typeHint = `This appears to be a ${hintedType.replace('_', ' ')}. Analyze accordingly.`;
|
|
271
|
+
|
|
272
|
+
const truncated = content.length > 6000 ? content.slice(0, 6000) + '\n\n[Content truncated for analysis]' : content;
|
|
273
|
+
|
|
274
|
+
process.stdout.write(` ${D}Running analysis (this takes ~15 seconds)...${R}`);
|
|
275
|
+
|
|
276
|
+
let result;
|
|
277
|
+
try {
|
|
278
|
+
const raw = await askClaude(
|
|
279
|
+
`${typeHint}\n\nContent to analyze:\n---\n${truncated}\n---`,
|
|
280
|
+
ANALYZE_SYSTEM,
|
|
281
|
+
'claude-sonnet-4-6'
|
|
282
|
+
);
|
|
283
|
+
process.stdout.write(`\r ${GN}✓${R} Analysis complete${' '.repeat(30)}\n`);
|
|
284
|
+
result = parseJSON(raw);
|
|
285
|
+
recordEvent({ event: 'analyze_result', score: result.total_score, content_type: result.content_type });
|
|
286
|
+
} catch (err) {
|
|
287
|
+
process.stdout.write(`\n`);
|
|
288
|
+
blank();
|
|
289
|
+
const isTimeout = err.killed || /timeout|ETIMEDOUT|timed out/i.test(err.message);
|
|
290
|
+
if (isTimeout) {
|
|
291
|
+
fail(`Analysis timed out (Claude took too long to respond).`);
|
|
292
|
+
blank();
|
|
293
|
+
console.log(` ${YL}Try:${R}`);
|
|
294
|
+
console.log(` ${D}· Wait a moment and retry — Claude may be under load${R}`);
|
|
295
|
+
console.log(` ${D}· Verify Claude is working: ${CY}claude -p "say hi"${R}`);
|
|
296
|
+
} else {
|
|
297
|
+
fail(`Analysis failed: ${err.message}`);
|
|
298
|
+
blank();
|
|
299
|
+
console.log(` ${YL}Possible causes:${R}`);
|
|
300
|
+
console.log(` ${D}· Claude CLI rate limit — wait a moment and retry${R}`);
|
|
301
|
+
console.log(` ${D}· Network or auth issue — run: ${CY}claude -p "say hi"${R}`);
|
|
302
|
+
}
|
|
303
|
+
blank();
|
|
304
|
+
process.exit(1);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
blank();
|
|
308
|
+
hr();
|
|
309
|
+
// ── Score header
|
|
310
|
+
const scoreColor = result.total_score >= 70 ? GN : result.total_score >= 45 ? YL : RD;
|
|
311
|
+
console.log(` ${B}OVERALL SCORE${R} ${scoreColor}${B}${result.total_score}/100${R} ${gradeLetter(result.total_score)} ${D}${result.content_type?.replace('_', ' ') ?? 'content'}${R}`);
|
|
312
|
+
console.log(` ${scoreBar(result.total_score, 40)}`);
|
|
313
|
+
blank();
|
|
314
|
+
|
|
315
|
+
// ── Headline
|
|
316
|
+
if (result.headline?.text) {
|
|
317
|
+
console.log(` ${B}HEADLINE SCORE${R} ${scoreBar(result.headline.score, 20)} ${result.headline.score}/100`);
|
|
318
|
+
console.log(` ${D}"${result.headline.text}"${R}`);
|
|
319
|
+
console.log(` ${D}↳ ${result.headline.feedback}${R}`);
|
|
320
|
+
blank();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── Dimension breakdown
|
|
324
|
+
hr();
|
|
325
|
+
console.log(` ${B}DIMENSION BREAKDOWN${R}`);
|
|
326
|
+
blank();
|
|
327
|
+
for (const [, dim] of Object.entries(result.dimensions ?? {})) {
|
|
328
|
+
const bar = scoreBar(dim.score, 20);
|
|
329
|
+
console.log(` ${WH}${(dim.label ?? '').padEnd(16)}${R} ${bar} ${dim.score}/100`);
|
|
330
|
+
console.log(` ${D} ↳ ${dim.feedback}${R}`);
|
|
331
|
+
blank();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── Verdict
|
|
335
|
+
if (result.one_line_verdict) {
|
|
336
|
+
hr();
|
|
337
|
+
console.log(` ${B}VERDICT${R}`);
|
|
338
|
+
console.log(` ${YL}${result.one_line_verdict}${R}`);
|
|
339
|
+
blank();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ── Strengths
|
|
343
|
+
if (result.strengths?.length) {
|
|
344
|
+
hr();
|
|
345
|
+
console.log(` ${B}STRENGTHS${R}`);
|
|
346
|
+
for (const s of result.strengths) {
|
|
347
|
+
console.log(` ${GN}+${R} ${s}`);
|
|
348
|
+
}
|
|
349
|
+
blank();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── Improvements
|
|
353
|
+
if (result.improvements?.length) {
|
|
354
|
+
hr();
|
|
355
|
+
console.log(` ${B}TOP IMPROVEMENTS${R}`);
|
|
356
|
+
blank();
|
|
357
|
+
for (const imp of result.improvements) {
|
|
358
|
+
const icon = imp.priority === 'high' ? `${RD}●${R}` : `${YL}●${R}`;
|
|
359
|
+
console.log(` ${icon} ${B}${imp.issue}${R}`);
|
|
360
|
+
console.log(` ${CY}Fix:${R} ${imp.fix}`);
|
|
361
|
+
blank();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── Headline rewrites
|
|
366
|
+
if (result.headline_rewrites?.length) {
|
|
367
|
+
hr();
|
|
368
|
+
console.log(` ${B}HEADLINE REWRITES${R}`);
|
|
369
|
+
blank();
|
|
370
|
+
for (let i = 0; i < result.headline_rewrites.length; i++) {
|
|
371
|
+
console.log(` ${D}${i + 1}.${R} ${result.headline_rewrites[i]}`);
|
|
372
|
+
}
|
|
373
|
+
blank();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── Pro teaser
|
|
377
|
+
hr();
|
|
378
|
+
console.log(` ${D}Free analysis complete. ${MG}Pro unlocks:${R}`);
|
|
379
|
+
console.log(` ${D} · Competitor headline comparison${R}`);
|
|
380
|
+
console.log(` ${D} · Landing page URL audit (full CRO analysis)${R}`);
|
|
381
|
+
console.log(` ${D} · Ad copy scoring (Google, Meta, LinkedIn)${R}`);
|
|
382
|
+
console.log(` ${D} · Twitter thread grader with shareability score${R}`);
|
|
383
|
+
console.log(` ${D} · Email subject line optimizer${R}`);
|
|
384
|
+
console.log(` ${D} · 100 analyses/day vs 3 free${R}`);
|
|
385
|
+
blank();
|
|
386
|
+
console.log(` ${D}Start the web dashboard: ${CY}content-grade start${R}`);
|
|
387
|
+
console.log(` ${D}Grade a headline: ${CY}content-grade headline "Your text here"${R}`);
|
|
388
|
+
blank();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ── Headline command ──────────────────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
const HEADLINE_SYSTEM = `You are a direct response copywriting expert. Grade the provided headline.
|
|
394
|
+
|
|
395
|
+
Return ONLY valid JSON:
|
|
396
|
+
{
|
|
397
|
+
"score": <0-100>,
|
|
398
|
+
"grade": "<letter>",
|
|
399
|
+
"verdict": "<1 punchy sentence>",
|
|
400
|
+
"scores": {
|
|
401
|
+
"rule_of_one": { "score": <0-30>, "max": 30, "note": "<1 sentence>" },
|
|
402
|
+
"value_equation": { "score": <0-30>, "max": 30, "note": "<1 sentence>" },
|
|
403
|
+
"readability": { "score": <0-20>, "max": 20, "note": "<1 sentence>" },
|
|
404
|
+
"proof_promise": { "score": <0-20>, "max": 20, "note": "<1 sentence>" }
|
|
405
|
+
},
|
|
406
|
+
"rewrites": [
|
|
407
|
+
"<stronger version 1>",
|
|
408
|
+
"<stronger version 2>"
|
|
409
|
+
]
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
Use full range: generic = <35, good = 65-79, great = 80+.`;
|
|
413
|
+
|
|
414
|
+
async function cmdHeadline(text) {
|
|
415
|
+
if (!text || text.trim().length < 3) {
|
|
416
|
+
blank();
|
|
417
|
+
fail(`No headline provided.`);
|
|
418
|
+
blank();
|
|
419
|
+
console.log(` Usage:`);
|
|
420
|
+
console.log(` ${CY}content-grade headline "Your headline text"${R}`);
|
|
421
|
+
blank();
|
|
422
|
+
console.log(` Examples:`);
|
|
423
|
+
console.log(` ${D}content-grade headline "The 5-Minute Fix That Doubles Landing Page Conversions"${R}`);
|
|
424
|
+
console.log(` ${D}content-grade headline "How We Grew From 0 to 10k Users Without Ads"${R}`);
|
|
425
|
+
console.log(` ${D}content-grade headline "Stop Doing This One Thing in Your Email Subject Lines"${R}`);
|
|
426
|
+
blank();
|
|
427
|
+
process.exit(1);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
banner();
|
|
431
|
+
console.log(` ${B}Grading headline:${R}`);
|
|
432
|
+
console.log(` ${D}"${text}"${R}`);
|
|
433
|
+
blank();
|
|
434
|
+
|
|
435
|
+
if (!checkClaude()) {
|
|
436
|
+
fail(`Claude CLI not found. Run: ${CY}content-grade init${R}`);
|
|
437
|
+
process.exit(1);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
process.stdout.write(` ${D}Analyzing...${R}`);
|
|
441
|
+
|
|
442
|
+
let result;
|
|
443
|
+
try {
|
|
444
|
+
const raw = await askClaude(`Grade this headline: "${text}"`, HEADLINE_SYSTEM, 'claude-haiku-4-5-20251001');
|
|
445
|
+
process.stdout.write(`\r ${GN}✓${R} Done${' '.repeat(20)}\n`);
|
|
446
|
+
result = parseJSON(raw);
|
|
447
|
+
recordEvent({ event: 'headline_result', score: result.score });
|
|
448
|
+
} catch (err) {
|
|
449
|
+
process.stdout.write(`\n`);
|
|
450
|
+
blank();
|
|
451
|
+
const isTimeout = err.killed || /timeout|ETIMEDOUT|timed out/i.test(err.message);
|
|
452
|
+
if (isTimeout) {
|
|
453
|
+
fail(`Grading timed out (Claude took too long to respond).`);
|
|
454
|
+
blank();
|
|
455
|
+
console.log(` ${YL}Try:${R}`);
|
|
456
|
+
console.log(` ${D}· Wait a moment and retry — Claude may be under load${R}`);
|
|
457
|
+
console.log(` ${D}· Verify Claude is working: ${CY}claude -p "say hi"${R}`);
|
|
458
|
+
} else {
|
|
459
|
+
fail(`Grading failed: ${err.message}`);
|
|
460
|
+
blank();
|
|
461
|
+
console.log(` ${YL}Possible causes:${R}`);
|
|
462
|
+
console.log(` ${D}· Claude CLI rate limit — wait a moment and retry${R}`);
|
|
463
|
+
console.log(` ${D}· Network or auth issue — run: ${CY}claude -p "say hi"${R}`);
|
|
464
|
+
}
|
|
465
|
+
blank();
|
|
466
|
+
process.exit(1);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
blank();
|
|
470
|
+
hr();
|
|
471
|
+
const sc = result.score ?? 0;
|
|
472
|
+
const scoreColor = sc >= 70 ? GN : sc >= 45 ? YL : RD;
|
|
473
|
+
console.log(` ${B}SCORE${R} ${scoreColor}${B}${sc}/100${R} ${gradeLetter(sc)}`);
|
|
474
|
+
console.log(` ${scoreBar(sc, 40)}`);
|
|
475
|
+
blank();
|
|
476
|
+
console.log(` ${YL}${result.verdict}${R}`);
|
|
477
|
+
blank();
|
|
478
|
+
|
|
479
|
+
hr();
|
|
480
|
+
console.log(` ${B}FRAMEWORK BREAKDOWN${R}`);
|
|
481
|
+
blank();
|
|
482
|
+
const dims = result.scores ?? {};
|
|
483
|
+
for (const [key, dim] of Object.entries(dims)) {
|
|
484
|
+
const label = key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()).padEnd(18);
|
|
485
|
+
const pct = dim.max > 0 ? Math.round((dim.score / dim.max) * 100) : 0;
|
|
486
|
+
console.log(` ${WH}${label}${R} ${scoreBar(pct, 16)} ${dim.score}/${dim.max}`);
|
|
487
|
+
console.log(` ${D} ↳ ${dim.note}${R}`);
|
|
488
|
+
blank();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (result.rewrites?.length) {
|
|
492
|
+
hr();
|
|
493
|
+
console.log(` ${B}STRONGER VERSIONS${R}`);
|
|
494
|
+
blank();
|
|
495
|
+
for (let i = 0; i < result.rewrites.length; i++) {
|
|
496
|
+
console.log(` ${D}${i + 1}.${R} ${result.rewrites[i]}`);
|
|
497
|
+
}
|
|
498
|
+
blank();
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
hr();
|
|
502
|
+
console.log(` ${D}Compare two headlines: ${CY}content-grade start${R} → HeadlineGrader compare${R}`);
|
|
503
|
+
blank();
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ── Init command ──────────────────────────────────────────────────────────────
|
|
507
|
+
|
|
508
|
+
async function cmdInit() {
|
|
509
|
+
banner();
|
|
510
|
+
console.log(` ${B}Welcome to ContentGrade${R}`);
|
|
511
|
+
console.log(` ${D}Let's get you set up in 60 seconds.${R}`);
|
|
512
|
+
blank();
|
|
513
|
+
|
|
514
|
+
// Step 1: Claude
|
|
515
|
+
console.log(` ${B}Step 1/3:${R} Check Claude CLI`);
|
|
516
|
+
const claudeOk = checkClaude();
|
|
517
|
+
if (claudeOk) {
|
|
518
|
+
ok('Claude CLI found');
|
|
519
|
+
} else {
|
|
520
|
+
warn('Claude CLI not found');
|
|
521
|
+
blank();
|
|
522
|
+
console.log(` ContentGrade uses your local Claude CLI (no API keys, your data stays local).`);
|
|
523
|
+
blank();
|
|
524
|
+
console.log(` ${B}Install Claude CLI:${R}`);
|
|
525
|
+
console.log(` 1. Go to ${CY}https://claude.ai/code${R}`);
|
|
526
|
+
console.log(` 2. Download and install`);
|
|
527
|
+
console.log(` 3. Run: ${CY}claude login${R}`);
|
|
528
|
+
console.log(` 4. Verify: ${CY}claude -p "say hello"${R}`);
|
|
529
|
+
blank();
|
|
530
|
+
console.log(` Then re-run: ${CY}npx content-grade init${R}`);
|
|
531
|
+
blank();
|
|
532
|
+
process.exit(0);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Step 2: Quick smoke test
|
|
536
|
+
console.log(` ${B}Step 2/3:${R} Quick smoke test`);
|
|
537
|
+
process.stdout.write(` ${D}Testing claude -p...${R}`);
|
|
538
|
+
try {
|
|
539
|
+
const out = await askClaude('Respond with exactly: {"ok":true}', null, 'claude-haiku-4-5-20251001');
|
|
540
|
+
const j = parseJSON(out);
|
|
541
|
+
if (j.ok) {
|
|
542
|
+
process.stdout.write(`\r ${GN}✓${R} Claude is working${' '.repeat(20)}\n`);
|
|
543
|
+
} else {
|
|
544
|
+
throw new Error('unexpected response');
|
|
545
|
+
}
|
|
546
|
+
} catch {
|
|
547
|
+
process.stdout.write(`\n`);
|
|
548
|
+
warn('Claude responded but with unexpected output — may still work');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Step 3: Dashboard build
|
|
552
|
+
console.log(` ${B}Step 3/3:${R} Web dashboard`);
|
|
553
|
+
const distOk = existsSync(resolve(root, 'dist')) && existsSync(resolve(root, 'dist-server'));
|
|
554
|
+
if (distOk) {
|
|
555
|
+
ok('Production build found');
|
|
556
|
+
} else {
|
|
557
|
+
warn('No production build — web dashboard requires a build');
|
|
558
|
+
blank();
|
|
559
|
+
console.log(` ${D} The web dashboard is not available in this installation.${R}`);
|
|
560
|
+
console.log(` ${D} To build from source: ${CY}cd ${root} && npm run build${R}`);
|
|
561
|
+
blank();
|
|
562
|
+
console.log(` ${D} CLI commands (analyze, headline, demo) work without the build.${R}`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
blank();
|
|
566
|
+
hr();
|
|
567
|
+
console.log(` ${B}${GN}You're ready!${R}`);
|
|
568
|
+
blank();
|
|
569
|
+
console.log(` ${B}Try it now:${R}`);
|
|
570
|
+
blank();
|
|
571
|
+
console.log(` ${CY}echo "# Why Most Startups Fail\\n\\nMost startups fail because..." > /tmp/test.md${R}`);
|
|
572
|
+
console.log(` ${CY}content-grade analyze /tmp/test.md${R}`);
|
|
573
|
+
blank();
|
|
574
|
+
console.log(` ${B}Or grade a headline:${R}`);
|
|
575
|
+
console.log(` ${CY}content-grade headline "The 5-Minute Fix That Doubles Landing Page Conversions"${R}`);
|
|
576
|
+
blank();
|
|
577
|
+
if (distOk) {
|
|
578
|
+
console.log(` ${B}Or launch the web dashboard:${R}`);
|
|
579
|
+
console.log(` ${CY}content-grade start${R}`);
|
|
580
|
+
blank();
|
|
581
|
+
}
|
|
582
|
+
console.log(` ${D}Pro tier ($9/mo): 100 analyses/day + competitor comparison + URL audits${R}`);
|
|
583
|
+
blank();
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ── Start command ─────────────────────────────────────────────────────────────
|
|
587
|
+
|
|
588
|
+
function checkBuild() {
|
|
589
|
+
return existsSync(resolve(root, 'dist-server')) && existsSync(resolve(root, 'dist'));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function openBrowser(url) {
|
|
593
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
594
|
+
try { execFileSync(cmd, [url], { stdio: 'ignore' }); } catch {}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function cmdStart() {
|
|
598
|
+
banner();
|
|
599
|
+
console.log(` ${B}Pre-flight checks${R}`);
|
|
600
|
+
|
|
601
|
+
const claudeOk = checkClaude();
|
|
602
|
+
if (claudeOk) {
|
|
603
|
+
ok(`Claude CLI found`);
|
|
604
|
+
} else {
|
|
605
|
+
fail(`Claude CLI not found`);
|
|
606
|
+
blank();
|
|
607
|
+
console.log(` ${YL}Fix:${R} Install Claude CLI from ${CY}https://claude.ai/code${R} then run ${CY}claude login${R}`);
|
|
608
|
+
blank();
|
|
609
|
+
process.exit(1);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const buildOk = checkBuild();
|
|
613
|
+
if (buildOk) {
|
|
614
|
+
ok('Production build found');
|
|
615
|
+
} else {
|
|
616
|
+
warn('No production build found — web dashboard is unavailable');
|
|
617
|
+
blank();
|
|
618
|
+
console.log(` ${YL}The web dashboard requires a production build.${R}`);
|
|
619
|
+
console.log(` ${D}If you installed via npx or npm install, try reinstalling the package.${R}`);
|
|
620
|
+
console.log(` ${D}If you cloned the repo, run:${R}`);
|
|
621
|
+
console.log(` ${CY}cd ${root} && npm run build${R}`);
|
|
622
|
+
blank();
|
|
623
|
+
console.log(` ${D}The CLI commands work without the dashboard:${R}`);
|
|
624
|
+
console.log(` ${CY}content-grade analyze <file>${R} ${D}# analyze a file${R}`);
|
|
625
|
+
console.log(` ${CY}content-grade headline "<text>"${R} ${D}# grade a headline${R}`);
|
|
626
|
+
console.log(` ${CY}content-grade demo${R} ${D}# instant demo${R}`);
|
|
627
|
+
blank();
|
|
628
|
+
process.exit(1);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const PORT = parseInt(process.env.PORT || '4000', 10);
|
|
632
|
+
const serverEntry = resolve(root, 'dist-server/server/index.js');
|
|
633
|
+
|
|
634
|
+
blank();
|
|
635
|
+
console.log(` ${B}Starting server${R}`);
|
|
636
|
+
|
|
637
|
+
const server = spawn('node', [serverEntry], {
|
|
638
|
+
env: { ...process.env },
|
|
639
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
640
|
+
cwd: root,
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
let started = false;
|
|
644
|
+
|
|
645
|
+
server.stdout.on('data', (data) => {
|
|
646
|
+
const text = data.toString();
|
|
647
|
+
if (text.includes('running on') && !started) {
|
|
648
|
+
started = true;
|
|
649
|
+
const url = `http://localhost:${PORT}`;
|
|
650
|
+
ok(`Server running at ${CY}${url}${R}`);
|
|
651
|
+
blank();
|
|
652
|
+
console.log(` ${D}Tools:${R}`);
|
|
653
|
+
info(` HeadlineGrader — ${url}/headline`);
|
|
654
|
+
info(` PageRoast — ${url}/page-roast`);
|
|
655
|
+
info(` AdScorer — ${url}/ad-scorer`);
|
|
656
|
+
info(` ThreadGrader — ${url}/thread`);
|
|
657
|
+
info(` EmailForge — ${url}/email-forge`);
|
|
658
|
+
info(` AudienceDecoder — ${url}/audience`);
|
|
659
|
+
blank();
|
|
660
|
+
info(`Free tier: 3 analyses/day. Upgrade at ${url}`);
|
|
661
|
+
info(`Press Ctrl+C to stop`);
|
|
662
|
+
blank();
|
|
663
|
+
openBrowser(url);
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
server.stderr.on('data', (data) => {
|
|
668
|
+
const text = data.toString().trim();
|
|
669
|
+
if (!text) return;
|
|
670
|
+
|
|
671
|
+
// Better-sqlite3 native binding error — common after fresh install
|
|
672
|
+
if (text.includes('better-sqlite3') || text.includes('bindings.js')) {
|
|
673
|
+
blank();
|
|
674
|
+
fail(`Database error: native SQLite bindings not compiled for this Node version.`);
|
|
675
|
+
blank();
|
|
676
|
+
console.log(` ${YL}Fix (run in ${root}):${R}`);
|
|
677
|
+
console.log(` ${CY}npm rebuild better-sqlite3${R}`);
|
|
678
|
+
blank();
|
|
679
|
+
console.log(` Then retry: ${CY}content-grade start${R}`);
|
|
680
|
+
} else {
|
|
681
|
+
console.error(` ${RD}Server error:${R} ${text}`);
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
server.on('exit', (code) => {
|
|
686
|
+
if (code !== 0 && code !== null) {
|
|
687
|
+
blank();
|
|
688
|
+
fail(`Server exited with code ${code}`);
|
|
689
|
+
blank();
|
|
690
|
+
console.log(` ${D}Check the error above, or run:${R}`);
|
|
691
|
+
console.log(` ${CY}content-grade init${R} — diagnose setup`);
|
|
692
|
+
blank();
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
process.on('SIGINT', () => {
|
|
697
|
+
blank();
|
|
698
|
+
info('Shutting down...');
|
|
699
|
+
server.kill();
|
|
700
|
+
process.exit(0);
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ── Help ──────────────────────────────────────────────────────────────────────
|
|
705
|
+
|
|
706
|
+
function cmdHelp() {
|
|
707
|
+
banner();
|
|
708
|
+
console.log(` ${D}v${_version} · ${CY}https://github.com/StanislavBG/Content-Grade${R}`);
|
|
709
|
+
blank();
|
|
710
|
+
console.log(` ${B}USAGE${R}`);
|
|
711
|
+
blank();
|
|
712
|
+
console.log(` ${CY}content-grade <command> [args]${R}`);
|
|
713
|
+
blank();
|
|
714
|
+
|
|
715
|
+
console.log(` ${B}COMMANDS${R}`);
|
|
716
|
+
blank();
|
|
717
|
+
console.log(` ${CY}demo${R} Run on sample content — instant result, no file needed`);
|
|
718
|
+
blank();
|
|
719
|
+
console.log(` ${CY}analyze <file>${R} Analyze content from a file`);
|
|
720
|
+
console.log(` ${CY}check <file>${R} Same as analyze`);
|
|
721
|
+
console.log(` ${D} Works on .md, .txt, or any text file${R}`);
|
|
722
|
+
console.log(` ${D} Zero config — just needs Claude CLI${R}`);
|
|
723
|
+
blank();
|
|
724
|
+
console.log(` ${CY}headline "<text>"${R} Grade a single headline`);
|
|
725
|
+
console.log(` ${D} Scores on 4 copywriting frameworks${R}`);
|
|
726
|
+
blank();
|
|
727
|
+
console.log(` ${CY}start${R} Launch the full web dashboard`);
|
|
728
|
+
console.log(` ${D} 6 tools: headlines, pages, ads, threads...${R}`);
|
|
729
|
+
blank();
|
|
730
|
+
console.log(` ${CY}init${R} First-run setup and diagnostics`);
|
|
731
|
+
blank();
|
|
732
|
+
console.log(` ${CY}telemetry [on|off]${R} View or toggle anonymous usage tracking`);
|
|
733
|
+
blank();
|
|
734
|
+
console.log(` ${CY}help${R} Show this help`);
|
|
735
|
+
blank();
|
|
736
|
+
console.log(` ${B}FLAGS${R}`);
|
|
737
|
+
blank();
|
|
738
|
+
console.log(` ${CY}--no-telemetry${R} Skip usage tracking for this invocation`);
|
|
739
|
+
blank();
|
|
740
|
+
|
|
741
|
+
console.log(` ${B}EXAMPLES${R}`);
|
|
742
|
+
blank();
|
|
743
|
+
console.log(` ${D}# Analyze a blog post${R}`);
|
|
744
|
+
console.log(` ${CY}content-grade analyze ./my-post.md${R}`);
|
|
745
|
+
blank();
|
|
746
|
+
console.log(` ${D}# Grade a headline${R}`);
|
|
747
|
+
console.log(` ${CY}content-grade headline "How I 10x'd My Conversion Rate in 30 Days"${R}`);
|
|
748
|
+
blank();
|
|
749
|
+
console.log(` ${D}# Launch full dashboard${R}`);
|
|
750
|
+
console.log(` ${CY}content-grade start${R}`);
|
|
751
|
+
blank();
|
|
752
|
+
|
|
753
|
+
console.log(` ${B}REQUIREMENTS${R}`);
|
|
754
|
+
blank();
|
|
755
|
+
console.log(` · Claude CLI ${CY}https://claude.ai/code${R}`);
|
|
756
|
+
console.log(` · Node.js 18+`);
|
|
757
|
+
blank();
|
|
758
|
+
|
|
759
|
+
console.log(` ${B}PRO TIER${R} ${MG}$9/month${R}`);
|
|
760
|
+
blank();
|
|
761
|
+
console.log(` · 100 analyses/day (vs 3 free)`);
|
|
762
|
+
console.log(` · Competitor headline A/B comparison`);
|
|
763
|
+
console.log(` · Landing page URL audit`);
|
|
764
|
+
console.log(` · Ad copy scoring (Google, Meta, LinkedIn)`);
|
|
765
|
+
console.log(` · Email subject line optimizer`);
|
|
766
|
+
console.log(` · Audience archetype decoder`);
|
|
767
|
+
blank();
|
|
768
|
+
console.log(` ${CY}content-grade start${R} then click Upgrade`);
|
|
769
|
+
blank();
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// ── Demo command ──────────────────────────────────────────────────────────────
|
|
773
|
+
|
|
774
|
+
const DEMO_CONTENT = `# Why Your Morning Routine Is Secretly Destroying Your Productivity
|
|
775
|
+
|
|
776
|
+
Productivity is important. Many people struggle with it. In this post we will discuss some tips.
|
|
777
|
+
|
|
778
|
+
First, wake up early. Successful people wake up early. This gives you more time. Studies show that early risers are more productive.
|
|
779
|
+
|
|
780
|
+
Second, make a to-do list. Write everything down. Then do the tasks one by one. This is a simple but effective strategy.
|
|
781
|
+
|
|
782
|
+
Third, avoid distractions. Put your phone away. Social media is bad for focus. You should minimize interruptions.
|
|
783
|
+
|
|
784
|
+
Fourth, take breaks. Working too hard is not good. Make sure to rest sometimes. The Pomodoro technique can help with this.
|
|
785
|
+
|
|
786
|
+
In conclusion, these productivity tips are very helpful. Try them today and you will see a big difference in your output. Productivity is the key to success in today's world.
|
|
787
|
+
`;
|
|
788
|
+
|
|
789
|
+
async function cmdDemo() {
|
|
790
|
+
const tmpFile = `/tmp/content-grade-demo-${process.pid}-${Date.now()}.md`;
|
|
791
|
+
|
|
792
|
+
banner();
|
|
793
|
+
console.log(` ${B}Demo Mode${R} ${D}— running analysis on sample content${R}`);
|
|
794
|
+
blank();
|
|
795
|
+
console.log(` ${D}This is what ContentGrade does. Try it on your own content:${R}`);
|
|
796
|
+
console.log(` ${CY} content-grade analyze ./your-post.md${R}`);
|
|
797
|
+
blank();
|
|
798
|
+
console.log(` ${D}Analyzing sample blog post...${R}`);
|
|
799
|
+
blank();
|
|
800
|
+
|
|
801
|
+
writeFileSync(tmpFile, DEMO_CONTENT, 'utf8');
|
|
802
|
+
try {
|
|
803
|
+
await cmdAnalyze(tmpFile);
|
|
804
|
+
} finally {
|
|
805
|
+
try { unlinkSync(tmpFile); } catch {}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// ── Directory scanner ─────────────────────────────────────────────────────────
|
|
810
|
+
|
|
811
|
+
import { readdirSync } from 'fs';
|
|
812
|
+
|
|
813
|
+
function findBestContentFile(dirPath) {
|
|
814
|
+
const abs = resolve(process.cwd(), dirPath);
|
|
815
|
+
if (!existsSync(abs)) return null;
|
|
816
|
+
|
|
817
|
+
const st = statSync(abs);
|
|
818
|
+
if (st.isFile()) return abs;
|
|
819
|
+
if (!st.isDirectory()) return null;
|
|
820
|
+
|
|
821
|
+
const candidates = [];
|
|
822
|
+
function scan(dir, depth) {
|
|
823
|
+
if (depth > 2) return;
|
|
824
|
+
let entries;
|
|
825
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
826
|
+
for (const entry of entries) {
|
|
827
|
+
if (entry.startsWith('.') || entry === 'node_modules' || entry === 'dist') continue;
|
|
828
|
+
const full = resolve(dir, entry);
|
|
829
|
+
let s;
|
|
830
|
+
try { s = statSync(full); } catch { continue; }
|
|
831
|
+
if (s.isDirectory()) {
|
|
832
|
+
scan(full, depth + 1);
|
|
833
|
+
} else if (/\.(md|txt|mdx)$/i.test(entry)) {
|
|
834
|
+
candidates.push({ path: full, size: s.size, name: entry });
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
scan(abs, 0);
|
|
839
|
+
|
|
840
|
+
if (!candidates.length) return null;
|
|
841
|
+
|
|
842
|
+
// Prefer content files over READMEs; bigger = more content
|
|
843
|
+
const scored = candidates.map(f => {
|
|
844
|
+
let score = f.size;
|
|
845
|
+
const n = f.name.toLowerCase();
|
|
846
|
+
if (n === 'readme.md' || n === 'readme.txt') score -= 5000;
|
|
847
|
+
if (/post|article|blog|draft|copy|content/i.test(n)) score += 10000;
|
|
848
|
+
return { ...f, score };
|
|
849
|
+
});
|
|
850
|
+
scored.sort((a, b) => b.score - a.score);
|
|
851
|
+
return scored[0].path;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// ── Router ────────────────────────────────────────────────────────────────────
|
|
855
|
+
|
|
856
|
+
const args = process.argv.slice(2).filter(a => a !== '--no-telemetry');
|
|
857
|
+
const raw = args[0];
|
|
858
|
+
const cmd = raw?.toLowerCase();
|
|
859
|
+
|
|
860
|
+
// ── Telemetry init ────────────────────────────────────────────────────────────
|
|
861
|
+
const _telem = initTelemetry();
|
|
862
|
+
if (_telem.isNew) {
|
|
863
|
+
// Show notice once, on first run
|
|
864
|
+
blank();
|
|
865
|
+
console.log(` ${D}ContentGrade collects anonymous usage data (command name, score, duration).${R}`);
|
|
866
|
+
console.log(` ${D}No file contents, no PII. To opt out: ${CY}content-grade telemetry off${R}${D} or pass ${CY}--no-telemetry${R}`);
|
|
867
|
+
blank();
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Smart path detection: first arg is a path if it starts with ., /, ~ or is an existing FS entry
|
|
871
|
+
function looksLikePath(s) {
|
|
872
|
+
if (!s) return false;
|
|
873
|
+
if (s.startsWith('.') || s.startsWith('/') || s.startsWith('~')) return true;
|
|
874
|
+
if (/\.(md|txt|mdx)$/i.test(s)) return true;
|
|
875
|
+
try { statSync(resolve(process.cwd(), s)); return true; } catch { return false; }
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
switch (cmd) {
|
|
879
|
+
case 'analyze':
|
|
880
|
+
case 'analyse':
|
|
881
|
+
case 'check':
|
|
882
|
+
recordEvent({ event: 'command', command: 'analyze' });
|
|
883
|
+
cmdAnalyze(args[1]).catch(err => {
|
|
884
|
+
blank();
|
|
885
|
+
fail(`Unexpected error: ${err.message}`);
|
|
886
|
+
blank();
|
|
887
|
+
process.exit(1);
|
|
888
|
+
});
|
|
889
|
+
break;
|
|
890
|
+
|
|
891
|
+
case 'demo':
|
|
892
|
+
recordEvent({ event: 'command', command: 'demo' });
|
|
893
|
+
cmdDemo().catch(err => {
|
|
894
|
+
blank();
|
|
895
|
+
fail(`Demo error: ${err.message}`);
|
|
896
|
+
blank();
|
|
897
|
+
process.exit(1);
|
|
898
|
+
});
|
|
899
|
+
break;
|
|
900
|
+
|
|
901
|
+
case 'headline':
|
|
902
|
+
case 'grade':
|
|
903
|
+
recordEvent({ event: 'command', command: 'headline' });
|
|
904
|
+
cmdHeadline(args.slice(1).join(' ')).catch(err => {
|
|
905
|
+
blank();
|
|
906
|
+
fail(`Unexpected error: ${err.message}`);
|
|
907
|
+
blank();
|
|
908
|
+
process.exit(1);
|
|
909
|
+
});
|
|
910
|
+
break;
|
|
911
|
+
|
|
912
|
+
case 'start':
|
|
913
|
+
case 'serve':
|
|
914
|
+
recordEvent({ event: 'command', command: 'start' });
|
|
915
|
+
cmdStart();
|
|
916
|
+
break;
|
|
917
|
+
|
|
918
|
+
case 'init':
|
|
919
|
+
case 'setup':
|
|
920
|
+
recordEvent({ event: 'command', command: 'init' });
|
|
921
|
+
cmdInit().catch(err => {
|
|
922
|
+
blank();
|
|
923
|
+
fail(`Setup error: ${err.message}`);
|
|
924
|
+
blank();
|
|
925
|
+
process.exit(1);
|
|
926
|
+
});
|
|
927
|
+
break;
|
|
928
|
+
|
|
929
|
+
case 'help':
|
|
930
|
+
case '--help':
|
|
931
|
+
case '-h':
|
|
932
|
+
cmdHelp();
|
|
933
|
+
break;
|
|
934
|
+
|
|
935
|
+
case 'version':
|
|
936
|
+
case '--version':
|
|
937
|
+
case '-v':
|
|
938
|
+
case '-V':
|
|
939
|
+
console.log(`content-grade v${_version}`);
|
|
940
|
+
break;
|
|
941
|
+
|
|
942
|
+
case 'telemetry': {
|
|
943
|
+
const sub = args[1];
|
|
944
|
+
if (sub === 'off') {
|
|
945
|
+
disableTelemetry();
|
|
946
|
+
blank();
|
|
947
|
+
console.log(` ${GN}✓${R} Telemetry disabled. No usage data will be collected.`);
|
|
948
|
+
console.log(` ${D}Re-enable: ${CY}content-grade telemetry on${R}`);
|
|
949
|
+
blank();
|
|
950
|
+
} else if (sub === 'on') {
|
|
951
|
+
enableTelemetry();
|
|
952
|
+
blank();
|
|
953
|
+
console.log(` ${GN}✓${R} Telemetry enabled. Anonymous usage data will be collected.`);
|
|
954
|
+
console.log(` ${D}Opt out at any time: ${CY}content-grade telemetry off${R}`);
|
|
955
|
+
blank();
|
|
956
|
+
} else {
|
|
957
|
+
const s = telemetryStatus();
|
|
958
|
+
blank();
|
|
959
|
+
console.log(` ${B}Telemetry status${R}`);
|
|
960
|
+
blank();
|
|
961
|
+
console.log(` ${D}Enabled: ${R}${s.enabled ? `${GN}yes${R}` : `${RD}no${R}`}`);
|
|
962
|
+
console.log(` ${D}Install ID: ${R}${s.installId ?? '(none)'}`);
|
|
963
|
+
console.log(` ${D}Local log: ${R}${s.eventsFile}`);
|
|
964
|
+
console.log(` ${D}Remote URL: ${R}${s.remoteUrl}`);
|
|
965
|
+
blank();
|
|
966
|
+
console.log(` ${D}Commands:${R}`);
|
|
967
|
+
console.log(` ${CY}content-grade telemetry off${R} ${D}disable telemetry${R}`);
|
|
968
|
+
console.log(` ${CY}content-grade telemetry on${R} ${D}re-enable telemetry${R}`);
|
|
969
|
+
console.log(` ${D}Or pass ${CY}--no-telemetry${R}${D} to any command for a one-time opt-out.${R}`);
|
|
970
|
+
blank();
|
|
971
|
+
}
|
|
972
|
+
break;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
case undefined:
|
|
976
|
+
// No command — run demo immediately for instant value
|
|
977
|
+
recordEvent({ event: 'command', command: 'demo' });
|
|
978
|
+
cmdDemo().catch(err => {
|
|
979
|
+
blank();
|
|
980
|
+
fail(`Demo error: ${err.message}`);
|
|
981
|
+
blank();
|
|
982
|
+
process.exit(1);
|
|
983
|
+
});
|
|
984
|
+
break;
|
|
985
|
+
|
|
986
|
+
default:
|
|
987
|
+
if (looksLikePath(raw)) {
|
|
988
|
+
// Directory: find best content file inside it
|
|
989
|
+
let target;
|
|
990
|
+
try {
|
|
991
|
+
const st = statSync(resolve(process.cwd(), raw));
|
|
992
|
+
if (st.isDirectory()) {
|
|
993
|
+
target = findBestContentFile(raw);
|
|
994
|
+
if (!target) {
|
|
995
|
+
blank();
|
|
996
|
+
fail(`No content files found in: ${raw}`);
|
|
997
|
+
blank();
|
|
998
|
+
console.log(` ${YL}ContentGrade looks for .md, .txt, and .mdx files.${R}`);
|
|
999
|
+
console.log(` ${D}Create a markdown file there, or try:${R}`);
|
|
1000
|
+
blank();
|
|
1001
|
+
console.log(` ${CY}content-grade demo${R} — instant demo on built-in sample`);
|
|
1002
|
+
blank();
|
|
1003
|
+
process.exit(1);
|
|
1004
|
+
}
|
|
1005
|
+
const rel = target.startsWith(process.cwd()) ? target.slice(process.cwd().length + 1) : target;
|
|
1006
|
+
blank();
|
|
1007
|
+
console.log(` ${D}Found: ${CY}${rel}${R}`);
|
|
1008
|
+
} else {
|
|
1009
|
+
target = raw;
|
|
1010
|
+
}
|
|
1011
|
+
} catch {
|
|
1012
|
+
target = raw; // let cmdAnalyze handle the "not found" error
|
|
1013
|
+
}
|
|
1014
|
+
recordEvent({ event: 'command', command: 'analyze' });
|
|
1015
|
+
cmdAnalyze(target).catch(err => {
|
|
1016
|
+
blank();
|
|
1017
|
+
fail(`Unexpected error: ${err.message}`);
|
|
1018
|
+
blank();
|
|
1019
|
+
process.exit(1);
|
|
1020
|
+
});
|
|
1021
|
+
} else {
|
|
1022
|
+
blank();
|
|
1023
|
+
fail(`Unknown command: ${B}${raw}${R}`);
|
|
1024
|
+
blank();
|
|
1025
|
+
console.log(` Available commands:`);
|
|
1026
|
+
console.log(` ${CY}analyze <file>${R} ${CY}headline "<text>"${R} ${CY}demo${R} ${CY}start${R} ${CY}init${R} ${CY}help${R}`);
|
|
1027
|
+
blank();
|
|
1028
|
+
console.log(` ${D}Pass a file/directory directly: ${CY}content-grade ./my-post.md${R}`);
|
|
1029
|
+
console.log(` ${D}Run ${CY}content-grade help${R}${D} for full usage${R}`);
|
|
1030
|
+
blank();
|
|
1031
|
+
process.exit(1);
|
|
1032
|
+
}
|
|
1033
|
+
}
|