content-grade 1.0.1 → 1.0.3

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
@@ -15,17 +15,27 @@
15
15
  # 1. Verify Claude CLI is installed and logged in
16
16
  claude -p "say hi"
17
17
 
18
- # 2. Run an instant demo — no file needed
18
+ # 2. Run instant demo — no file needed, see results in ~30 seconds
19
19
  npx content-grade
20
20
 
21
- # 3. Analyze your own content
22
- npx content-grade ./my-post.md
21
+ # 3. Analyze your own content (grade is an alias for analyze)
22
+ npx content-grade grade ./my-post.md
23
+ npx content-grade analyze ./my-post.md
23
24
 
24
- # 4. Grade a single headline
25
+ # 4. CI pipeline get raw JSON or score-only output
26
+ npx content-grade analyze ./post.md --json
27
+ npx content-grade analyze ./post.md --quiet # prints score number only
28
+
29
+ # 5. Grade a headline
25
30
  npx content-grade headline "Why Most Startups Fail at Month 18"
31
+
32
+ # 6. Unlock Pro (batch mode, 100/day limit)
33
+ npx content-grade activate
26
34
  ```
27
35
 
28
- **One requirement:** [Claude CLI](https://claude.ai/code) must be installed and logged in. That's it — no API keys, no accounts, no data leaves your machine.
36
+ **One requirement:** [Claude CLI](https://claude.ai/code) must be installed and logged in. No API keys, no accounts, no data leaves your machine.
37
+
38
+ **Free tier:** 50 analyses/day, no signup needed. `npx content-grade grade README.md` works immediately.
29
39
 
30
40
  ---
31
41
 
@@ -72,11 +82,7 @@ npx content-grade headline "Why Most Startups Fail at Month 18"
72
82
  1. Why 90% of SaaS Startups Fail at Month 18 (and How to Be the 10%)
73
83
  2. The Month 18 Startup Trap: What Kills Growth-Stage Companies
74
84
 
75
- Free analysis complete. Pro unlocks:
76
- · Competitor headline comparison
77
- · Landing page URL audit (full CRO analysis)
78
- · Ad copy scoring (Google, Meta, LinkedIn)
79
- · 100 analyses/day vs 3 free
85
+ Unlock team features at contentgrade.dev
80
86
  ```
81
87
 
82
88
  ---
@@ -86,19 +92,30 @@ npx content-grade headline "Why Most Startups Fail at Month 18"
86
92
  | Command | What it does |
87
93
  |---------|-------------|
88
94
  | *(no args)* | Instant demo on built-in sample content |
95
+ | `<url>` | Fetch and analyze a live URL — `npx content-grade https://example.com/blog/post` |
89
96
  | `<file>` | Analyze a file directly — `npx content-grade ./post.md` |
90
97
  | `.` or `<dir>` | Scan a directory, find and analyze the best content file |
91
98
  | `demo` | Same as no args |
92
99
  | `analyze <file>` | Full content audit: score, grade, dimensions, improvements |
100
+ | `grade <file>` | Alias for `analyze` (e.g. `grade README.md`) |
93
101
  | `check <file>` | Alias for `analyze` |
94
- | `analyse <file>` | Alias for `analyze` (British spelling) |
102
+ | `batch <dir>` | **[Pro]** Analyze all .md/.txt files in a directory |
95
103
  | `headline "<text>"` | Grade a headline on 4 copywriting frameworks |
96
- | `grade "<text>"` | Alias for `headline` |
104
+ | `activate` | Enter license key to unlock Pro features (batch, 100/day) |
97
105
  | `init` | First-run setup: verify Claude CLI, run smoke test |
98
106
  | `start` | Launch the full web dashboard (6 tools) |
99
107
  | `telemetry [on\|off]` | View or toggle anonymous usage tracking |
100
108
  | `help` | Full usage and examples |
101
109
 
110
+ **Flags:**
111
+
112
+ | Flag | What it does |
113
+ |------|-------------|
114
+ | `--json` | Raw JSON output — pipe to `jq` for CI integration |
115
+ | `--quiet` | Score number only — for shell scripts |
116
+ | `--version` | Print version |
117
+ | `--no-telemetry` | Skip usage tracking for this run |
118
+
102
119
  **Global flags:**
103
120
 
104
121
  | Flag | Description |
@@ -122,7 +139,7 @@ Launch with `content-grade start` — opens at [http://localhost:4000](http://lo
122
139
  | **EmailForge** | `/email-forge` | Subject line + body copy for click-through optimization |
123
140
  | **AudienceDecoder** | `/audience` | Twitter handle → audience archetypes and content patterns |
124
141
 
125
- Free tier: **3 analyses/day per tool**. Pro ($9/mo): **100 analyses/day** + all tools.
142
+ Free tier: **50 analyses/day per tool**. Pro ($9/mo): **100 analyses/day** + all tools.
126
143
 
127
144
  ---
128
145
 
@@ -537,8 +554,8 @@ curl -s -X POST http://localhost:4000/api/demos/email-forge \
537
554
  "gated": true,
538
555
  "isPro": false,
539
556
  "remaining": 0,
540
- "limit": 3,
541
- "message": "Free daily limit reached (3/day). Get 100 analyses/day with Pro"
557
+ "limit": 50,
558
+ "message": "Free daily limit reached (50/day). Get 100 analyses/day with Pro"
542
559
  }
543
560
  ```
544
561
 
@@ -16,14 +16,65 @@
16
16
  import { execFileSync, execFile, spawn } from 'child_process';
17
17
  import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, statSync } from 'fs';
18
18
  import { resolve, dirname, basename, extname } from 'path';
19
+ import { homedir } from 'os';
19
20
  import { fileURLToPath } from 'url';
20
21
  import { promisify } from 'util';
22
+ import { get as httpsGet } from 'https';
23
+ import { get as httpGet } from 'http';
21
24
  import { initTelemetry, recordEvent, disableTelemetry, enableTelemetry, telemetryStatus, isOptedOut } from './telemetry.js';
22
25
 
23
26
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
27
  const root = resolve(__dirname, '..');
25
28
  const execFileAsync = promisify(execFile);
26
29
 
30
+ // ── Config + license ──────────────────────────────────────────────────────────
31
+
32
+ const CONFIG_DIR = resolve(homedir(), '.config', 'content-grade');
33
+ const CONFIG_FILE = resolve(CONFIG_DIR, 'config.json');
34
+ const USAGE_FILE = resolve(CONFIG_DIR, 'usage.json');
35
+
36
+ function loadConfig() {
37
+ try { return JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch { return {}; }
38
+ }
39
+
40
+ function saveConfig(data) {
41
+ mkdirSync(CONFIG_DIR, { recursive: true });
42
+ writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), 'utf8');
43
+ }
44
+
45
+ function getLicenseKey() {
46
+ return process.env.CONTENT_GRADE_KEY || loadConfig().licenseKey || null;
47
+ }
48
+
49
+ function isProUser() { return Boolean(getLicenseKey()); }
50
+
51
+ function getTodayKey() { return new Date().toISOString().slice(0, 10); }
52
+
53
+ function getUsage() {
54
+ try {
55
+ const d = JSON.parse(readFileSync(USAGE_FILE, 'utf8'));
56
+ if (d.date !== getTodayKey()) return { date: getTodayKey(), count: 0 };
57
+ return d;
58
+ } catch { return { date: getTodayKey(), count: 0 }; }
59
+ }
60
+
61
+ function incrementUsage() {
62
+ mkdirSync(CONFIG_DIR, { recursive: true });
63
+ const u = getUsage();
64
+ u.count = (u.count || 0) + 1;
65
+ writeFileSync(USAGE_FILE, JSON.stringify(u, null, 2), 'utf8');
66
+ return u.count;
67
+ }
68
+
69
+ const FREE_DAILY_LIMIT = 50;
70
+
71
+ function checkDailyLimit() {
72
+ if (isProUser()) return { ok: true };
73
+ const u = getUsage();
74
+ if (u.count >= FREE_DAILY_LIMIT) return { ok: false, count: u.count, limit: FREE_DAILY_LIMIT };
75
+ return { ok: true, count: u.count, limit: FREE_DAILY_LIMIT };
76
+ }
77
+
27
78
  let _version = '1.0.0';
28
79
  try { _version = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')).version ?? '1.0.0'; } catch {}
29
80
 
@@ -99,11 +150,27 @@ async function askClaude(prompt, systemPrompt, model = 'claude-haiku-4-5-2025100
99
150
  if (systemPrompt) args.push('--system-prompt', systemPrompt);
100
151
  args.push(prompt);
101
152
 
153
+ if (_verboseMode) {
154
+ blank();
155
+ console.log(` ${D}[verbose] model: ${model}${R}`);
156
+ console.log(` ${D}[verbose] claude: ${claudePath}${R}`);
157
+ console.log(` ${D}[verbose] prompt length: ${prompt.length} chars${R}`);
158
+ blank();
159
+ }
160
+
161
+ const t0 = Date.now();
102
162
  const { stdout } = await execFileAsync(claudePath, args, {
103
163
  timeout: 120000,
104
164
  maxBuffer: 10 * 1024 * 1024,
105
165
  });
106
- return stdout.trim();
166
+ const raw = stdout.trim();
167
+
168
+ if (_verboseMode) {
169
+ console.log(` ${D}[verbose] response: ${raw.length} chars in ${Date.now() - t0}ms${R}`);
170
+ blank();
171
+ }
172
+
173
+ return raw;
107
174
  }
108
175
 
109
176
  function parseJSON(raw) {
@@ -187,7 +254,7 @@ async function cmdAnalyze(filePath) {
187
254
  blank();
188
255
  console.log(` ${D}No file? Try the demo: ${CY}content-grade demo${R}`);
189
256
  blank();
190
- process.exit(1);
257
+ process.exit(2);
191
258
  }
192
259
 
193
260
  const absPath = resolve(process.cwd(), filePath);
@@ -198,7 +265,7 @@ async function cmdAnalyze(filePath) {
198
265
  console.log(` ${YL}Check the path and try again.${R}`);
199
266
  console.log(` ${D}Tip: use a relative path like ./my-file.md or an absolute path.${R}`);
200
267
  blank();
201
- process.exit(1);
268
+ process.exit(2);
202
269
  }
203
270
 
204
271
  // Guard: reject directories
@@ -209,7 +276,7 @@ async function cmdAnalyze(filePath) {
209
276
  blank();
210
277
  console.log(` ${D}Pass a text file (.md, .txt) — not a directory.${R}`);
211
278
  blank();
212
- process.exit(1);
279
+ process.exit(2);
213
280
  }
214
281
 
215
282
  // Guard: reject files over 500 KB before reading into memory
@@ -220,7 +287,7 @@ async function cmdAnalyze(filePath) {
220
287
  blank();
221
288
  console.log(` ${YL}Tip:${R} Copy the relevant section into a new file and analyze that.`);
222
289
  blank();
223
- process.exit(1);
290
+ process.exit(2);
224
291
  }
225
292
 
226
293
  const content = readFileSync(absPath, 'utf8');
@@ -233,20 +300,35 @@ async function cmdAnalyze(filePath) {
233
300
  console.log(` ${YL}ContentGrade analyzes written content — blog posts, emails, ad copy, landing pages.${R}`);
234
301
  console.log(` ${D}Supported formats: .md, .txt, .mdx, or any plain-text file.${R}`);
235
302
  blank();
236
- process.exit(1);
303
+ process.exit(2);
237
304
  }
238
305
 
239
306
  if (content.trim().length < 20) {
240
307
  blank();
241
308
  fail(`File is too short to analyze (${content.trim().length} chars). Add some content and try again.`);
242
309
  blank();
310
+ process.exit(2);
311
+ }
312
+
313
+ // Free tier daily limit
314
+ const limitCheck = checkDailyLimit();
315
+ if (!limitCheck.ok) {
316
+ blank();
317
+ fail(`Daily limit reached (${limitCheck.count}/${limitCheck.limit} free checks used today).`);
318
+ blank();
319
+ console.log(` ${B}Options:${R}`);
320
+ console.log(` ${D}· Wait until tomorrow (limit resets at midnight)${R}`);
321
+ console.log(` ${D}· Unlock 100 checks/day: ${CY}content-grade activate${R}`);
322
+ blank();
243
323
  process.exit(1);
244
324
  }
245
325
 
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();
326
+ if (!_jsonMode && !_quietMode) {
327
+ banner();
328
+ console.log(` ${B}Analyzing:${R} ${CY}${basename(absPath)}${R}`);
329
+ console.log(` ${D}${content.length.toLocaleString()} characters · detecting content type...${R}`);
330
+ blank();
331
+ }
250
332
 
251
333
  // Check Claude
252
334
  if (!checkClaude()) {
@@ -271,7 +353,7 @@ async function cmdAnalyze(filePath) {
271
353
 
272
354
  const truncated = content.length > 6000 ? content.slice(0, 6000) + '\n\n[Content truncated for analysis]' : content;
273
355
 
274
- process.stdout.write(` ${D}Running analysis (this takes ~15 seconds)...${R}`);
356
+ if (!_jsonMode && !_quietMode) process.stdout.write(` ${D}Running analysis (this takes ~15 seconds)...${R}`);
275
357
 
276
358
  let result;
277
359
  try {
@@ -280,9 +362,12 @@ async function cmdAnalyze(filePath) {
280
362
  ANALYZE_SYSTEM,
281
363
  'claude-sonnet-4-6'
282
364
  );
283
- process.stdout.write(`\r ${GN}✓${R} Analysis complete${' '.repeat(30)}\n`);
365
+ if (!_jsonMode && !_quietMode) process.stdout.write(`\r ${GN}✓${R} Analysis complete${' '.repeat(30)}\n`);
284
366
  result = parseJSON(raw);
285
367
  recordEvent({ event: 'analyze_result', score: result.total_score, content_type: result.content_type });
368
+ // Machine-readable output modes (exit cleanly, skip styled output)
369
+ if (_jsonMode) { process.stdout.write(JSON.stringify(result, null, 2) + '\n'); return; }
370
+ if (_quietMode) { process.stdout.write(`${result.total_score}\n`); return; }
286
371
  } catch (err) {
287
372
  process.stdout.write(`\n`);
288
373
  blank();
@@ -373,18 +458,22 @@ async function cmdAnalyze(filePath) {
373
458
  blank();
374
459
  }
375
460
 
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}`);
461
+ // Track usage
462
+ incrementUsage();
463
+
464
+ // Upsell show Pro path after user has seen value
385
465
  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}`);
466
+ if (isProUser()) {
467
+ console.log(` ${D}Pro active · ${CY}contentgrade.dev${R}`);
468
+ } else {
469
+ const usage = getUsage();
470
+ const remaining = Math.max(0, FREE_DAILY_LIMIT - usage.count);
471
+ hr();
472
+ console.log(` ${MG}${B}Unlock batch mode:${R} ${CY}content-grade activate${R}`);
473
+ console.log(` ${D} · Analyze entire directories in one command${R}`);
474
+ console.log(` ${D} · 100 checks/day (${remaining} remaining today on free tier)${R}`);
475
+ console.log(` ${D} · Get a license at ${CY}contentgrade.dev${R}`);
476
+ }
388
477
  blank();
389
478
  }
390
479
 
@@ -424,6 +513,19 @@ async function cmdHeadline(text) {
424
513
  console.log(` ${D}content-grade headline "How We Grew From 0 to 10k Users Without Ads"${R}`);
425
514
  console.log(` ${D}content-grade headline "Stop Doing This One Thing in Your Email Subject Lines"${R}`);
426
515
  blank();
516
+ process.exit(2);
517
+ }
518
+
519
+ // Free tier daily limit
520
+ const limitCheck = checkDailyLimit();
521
+ if (!limitCheck.ok) {
522
+ blank();
523
+ fail(`Daily limit reached (${limitCheck.count}/${limitCheck.limit} free checks used today).`);
524
+ blank();
525
+ console.log(` ${B}Options:${R}`);
526
+ console.log(` ${D}· Wait until tomorrow (limit resets at midnight)${R}`);
527
+ console.log(` ${D}· Unlock 100 checks/day: ${CY}content-grade activate${R}`);
528
+ blank();
427
529
  process.exit(1);
428
530
  }
429
531
 
@@ -579,8 +681,209 @@ async function cmdInit() {
579
681
  console.log(` ${CY}content-grade start${R}`);
580
682
  blank();
581
683
  }
582
- console.log(` ${D}Pro tier ($9/mo): 100 analyses/day + competitor comparison + URL audits${R}`);
684
+ console.log(` ${D}Pro tier ($9/mo): 100 analyses/day (vs 50 free) + competitor comparison + URL audits${R}`);
685
+ blank();
686
+ }
687
+
688
+ // ── Activate command ──────────────────────────────────────────────────────────
689
+
690
+ async function cmdActivate() {
691
+ banner();
692
+ console.log(` ${B}Activate ContentGrade Pro${R}`);
693
+ blank();
694
+
695
+ const existing = getLicenseKey();
696
+ if (existing) {
697
+ ok(`Pro license already activated`);
698
+ blank();
699
+ console.log(` ${D}Key: ${existing.slice(0, 8)}...${R}`);
700
+ console.log(` ${D}Config: ${CONFIG_FILE}${R}`);
701
+ blank();
702
+ console.log(` ${B}Pro features:${R}`);
703
+ console.log(` ${D} content-grade batch ./posts/ ${R}${D}# analyze all files in a directory${R}`);
704
+ console.log(` ${D} 100 checks/day (vs 50 free)${R}`);
705
+ blank();
706
+ return;
707
+ }
708
+
709
+ console.log(` ${D}Pro tier unlocks:${R}`);
710
+ console.log(` ${D} · ${CY}content-grade batch <dir>${R}${D} — analyze entire directories${R}`);
711
+ console.log(` ${D} · 100 checks/day (vs 50 free)${R}`);
712
+ blank();
713
+ console.log(` ${D}Get a license: ${CY}contentgrade.dev${R}${D} → Pricing → Team${R}`);
714
+ blank();
715
+
716
+ process.stdout.write(` ${CY}License key:${R} `);
717
+
718
+ const key = await new Promise(res => {
719
+ let input = '';
720
+ const isRaw = process.stdin.isTTY;
721
+ if (isRaw) process.stdin.setRawMode(true);
722
+ process.stdin.resume();
723
+ process.stdin.setEncoding('utf8');
724
+ process.stdin.on('data', function onData(chunk) {
725
+ if (chunk === '\r' || chunk === '\n') {
726
+ if (isRaw) process.stdin.setRawMode(false);
727
+ process.stdin.pause();
728
+ process.stdin.removeListener('data', onData);
729
+ process.stdout.write('\n');
730
+ res(input.trim());
731
+ } else if (chunk === '\u0003') {
732
+ if (isRaw) process.stdin.setRawMode(false);
733
+ process.stdin.pause();
734
+ process.stdout.write('\n');
735
+ process.exit(0);
736
+ } else if (chunk === '\u007f') {
737
+ if (input.length > 0) { input = input.slice(0, -1); process.stdout.write('\b \b'); }
738
+ } else {
739
+ input += chunk;
740
+ process.stdout.write('*');
741
+ }
742
+ });
743
+ });
744
+
745
+ if (!key || key.length < 8) {
746
+ blank();
747
+ fail(`Invalid key — must be at least 8 characters.`);
748
+ blank();
749
+ process.exit(2);
750
+ }
751
+
752
+ const config = loadConfig();
753
+ config.licenseKey = key;
754
+ config.activatedAt = new Date().toISOString();
755
+ saveConfig(config);
756
+
757
+ blank();
758
+ ok(`License activated! Pro features unlocked.`);
759
+ blank();
760
+ console.log(` ${B}Try it:${R}`);
761
+ console.log(` ${CY} content-grade batch ./posts/${R} ${D}# analyze all files${R}`);
762
+ blank();
763
+ }
764
+
765
+ // ── Batch command (Pro) ───────────────────────────────────────────────────────
766
+
767
+ async function cmdBatch(dirPath) {
768
+ if (!isProUser()) {
769
+ blank();
770
+ console.log(` ${B}${MG}Batch Analysis — Pro Feature${R}`);
771
+ blank();
772
+ console.log(` ${D}Batch mode requires a Pro license.${R}`);
773
+ blank();
774
+ console.log(` ${D}Free tier: analyze files one at a time.${R}`);
775
+ console.log(` ${CY}content-grade analyze ./post.md${R}`);
776
+ blank();
777
+ console.log(` ${B}Unlock batch mode:${R}`);
778
+ console.log(` ${CY}content-grade activate${R} ${D}(enter your license key)${R}`);
779
+ blank();
780
+ console.log(` ${D}Get a license: ${CY}contentgrade.dev${R}`);
781
+ blank();
782
+ process.exit(1);
783
+ }
784
+
785
+ if (!dirPath) {
786
+ blank();
787
+ fail(`No directory specified.`);
788
+ blank();
789
+ console.log(` Usage: ${CY}content-grade batch <directory>${R}`);
790
+ console.log(` Example: ${CY}content-grade batch ./posts${R}`);
791
+ blank();
792
+ process.exit(2);
793
+ }
794
+
795
+ const absDir = resolve(process.cwd(), dirPath);
796
+ if (!existsSync(absDir)) {
797
+ blank();
798
+ fail(`Directory not found: ${absDir}`);
799
+ blank();
800
+ process.exit(2);
801
+ }
802
+
803
+ const st = statSync(absDir);
804
+ if (!st.isDirectory()) {
805
+ blank();
806
+ fail(`${dirPath} is not a directory. Use ${CY}content-grade analyze${R} for single files.`);
807
+ blank();
808
+ process.exit(2);
809
+ }
810
+
811
+ const files = [];
812
+ function collectFiles(dir) {
813
+ let entries;
814
+ try { entries = readdirSync(dir); } catch { return; }
815
+ for (const entry of entries) {
816
+ if (entry.startsWith('.') || entry === 'node_modules' || entry === 'dist') continue;
817
+ const full = resolve(dir, entry);
818
+ let s;
819
+ try { s = statSync(full); } catch { continue; }
820
+ if (s.isDirectory()) collectFiles(full);
821
+ else if (/\.(md|txt|mdx)$/i.test(entry)) files.push(full);
822
+ }
823
+ }
824
+ collectFiles(absDir);
825
+
826
+ if (!files.length) {
827
+ blank();
828
+ fail(`No .md, .txt, or .mdx files found in ${dirPath}`);
829
+ blank();
830
+ process.exit(1);
831
+ }
832
+
833
+ if (!checkClaude()) {
834
+ fail(`Claude CLI not found. Run: ${CY}content-grade init${R}`);
835
+ process.exit(1);
836
+ }
837
+
838
+ banner();
839
+ console.log(` ${B}Batch Analysis${R} ${D}${files.length} files in ${dirPath}${R}`);
583
840
  blank();
841
+
842
+ const results = [];
843
+ for (let i = 0; i < files.length; i++) {
844
+ const f = files[i];
845
+ const rel = f.startsWith(process.cwd()) ? f.slice(process.cwd().length + 1) : f;
846
+ process.stdout.write(` ${D}[${i + 1}/${files.length}]${R} ${rel}...`);
847
+
848
+ let content;
849
+ try { content = readFileSync(f, 'utf8'); } catch { process.stdout.write(` ${RD}unreadable${R}\n`); continue; }
850
+ if (content.trim().length < 20 || content.includes('\x00')) { process.stdout.write(` ${YL}skipped${R}\n`); continue; }
851
+
852
+ try {
853
+ const truncated = content.length > 6000 ? content.slice(0, 6000) + '\n\n[truncated]' : content;
854
+ const raw = await askClaude(
855
+ `Analyze this content:\n---\n${truncated}\n---`,
856
+ ANALYZE_SYSTEM,
857
+ 'claude-haiku-4-5-20251001'
858
+ );
859
+ const r = parseJSON(raw);
860
+ incrementUsage();
861
+ const sc = r.total_score;
862
+ const scoreColor = sc >= 70 ? GN : sc >= 45 ? YL : RD;
863
+ process.stdout.write(`\r ${scoreColor}${String(sc).padStart(3)}/100${R} ${gradeLetter(sc)} ${rel}${' '.repeat(10)}\n`);
864
+ results.push({ file: rel, score: sc, grade: r.grade });
865
+ } catch { process.stdout.write(`\r ${RD}failed${R} ${rel}\n`); }
866
+ }
867
+
868
+ blank();
869
+ hr();
870
+ console.log(` ${B}BATCH SUMMARY${R} ${D}${results.length}/${files.length} analyzed${R}`);
871
+ blank();
872
+
873
+ results.sort((a, b) => b.score - a.score);
874
+ for (const r of results) {
875
+ const scoreColor = r.score >= 70 ? GN : r.score >= 45 ? YL : RD;
876
+ console.log(` ${scoreColor}${String(r.score).padStart(3)}${R} ${gradeLetter(r.score)} ${r.file}`);
877
+ }
878
+
879
+ if (results.length) {
880
+ const avg = Math.round(results.reduce((sum, r) => sum + r.score, 0) / results.length);
881
+ blank();
882
+ console.log(` ${D}Average score: ${avg}/100${R}`);
883
+ }
884
+ blank();
885
+
886
+ if (_jsonMode) process.stdout.write(JSON.stringify(results, null, 2) + '\n');
584
887
  }
585
888
 
586
889
  // ── Start command ─────────────────────────────────────────────────────────────
@@ -657,7 +960,7 @@ function cmdStart() {
657
960
  info(` EmailForge — ${url}/email-forge`);
658
961
  info(` AudienceDecoder — ${url}/audience`);
659
962
  blank();
660
- info(`Free tier: 3 analyses/day. Upgrade at ${url}`);
963
+ info(`Free tier: 50 analyses/day. Upgrade at ${url}`);
661
964
  info(`Press Ctrl+C to stop`);
662
965
  blank();
663
966
  openBrowser(url);
@@ -714,28 +1017,36 @@ function cmdHelp() {
714
1017
 
715
1018
  console.log(` ${B}COMMANDS${R}`);
716
1019
  blank();
717
- console.log(` ${CY}demo${R} Run on sample content — instant result, no file needed`);
1020
+ console.log(` ${CY}demo${R} Run on sample content — instant result, no file needed`);
718
1021
  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}`);
1022
+ console.log(` ${CY}analyze <file>${R} Analyze content from a file`);
1023
+ console.log(` ${CY}grade <file>${R} Same as analyze (alias)`);
1024
+ console.log(` ${CY}check <file>${R} Same as analyze (alias)`);
1025
+ console.log(` ${D} Accepts .md, .txt, .mdx or any plain-text file${R}`);
1026
+ console.log(` ${D} Zero config — just needs Claude CLI${R}`);
723
1027
  blank();
724
- console.log(` ${CY}headline "<text>"${R} Grade a single headline`);
725
- console.log(` ${D} Scores on 4 copywriting frameworks${R}`);
1028
+ console.log(` ${CY}batch <directory>${R} ${MG}[Pro]${R} Analyze all .md/.txt files in a directory`);
726
1029
  blank();
727
- console.log(` ${CY}start${R} Launch the full web dashboard`);
728
- console.log(` ${D} 6 tools: headlines, pages, ads, threads...${R}`);
1030
+ console.log(` ${CY}headline "<text>"${R} Grade a single headline (4 copywriting frameworks)`);
729
1031
  blank();
730
- console.log(` ${CY}init${R} First-run setup and diagnostics`);
1032
+ console.log(` ${CY}activate${R} Enter license key to unlock Pro features`);
731
1033
  blank();
732
- console.log(` ${CY}telemetry [on|off]${R} View or toggle anonymous usage tracking`);
1034
+ console.log(` ${CY}start${R} Launch the full web dashboard`);
1035
+ console.log(` ${D} 6 tools: headlines, pages, ads, threads, emails, audiences${R}`);
733
1036
  blank();
734
- console.log(` ${CY}help${R} Show this help`);
1037
+ console.log(` ${CY}init${R} First-run setup and diagnostics`);
1038
+ blank();
1039
+ console.log(` ${CY}telemetry [on|off]${R} View or toggle anonymous usage tracking`);
1040
+ blank();
1041
+ console.log(` ${CY}help${R} Show this help`);
735
1042
  blank();
736
1043
  console.log(` ${B}FLAGS${R}`);
737
1044
  blank();
738
- console.log(` ${CY}--no-telemetry${R} Skip usage tracking for this invocation`);
1045
+ console.log(` ${CY}--json${R} Output raw JSON (great for CI pipelines)`);
1046
+ console.log(` ${CY}--quiet${R} Output score number only (for scripting)`);
1047
+ console.log(` ${CY}--verbose${R} Show debug info: model, timing, raw response length`);
1048
+ console.log(` ${CY}--version${R} Print version and exit`);
1049
+ console.log(` ${CY}--no-telemetry${R} Skip usage tracking for this invocation`);
739
1050
  blank();
740
1051
 
741
1052
  console.log(` ${B}EXAMPLES${R}`);
@@ -743,9 +1054,21 @@ function cmdHelp() {
743
1054
  console.log(` ${D}# Analyze a blog post${R}`);
744
1055
  console.log(` ${CY}content-grade analyze ./my-post.md${R}`);
745
1056
  blank();
1057
+ console.log(` ${D}# Grade README.md (alias)${R}`);
1058
+ console.log(` ${CY}content-grade grade README.md${R}`);
1059
+ blank();
1060
+ console.log(` ${D}# CI pipeline — exit 1 if score < threshold${R}`);
1061
+ console.log(` ${CY}content-grade analyze ./blog.md --json | jq '.total_score'${R}`);
1062
+ blank();
1063
+ console.log(` ${D}# Get score number only (for scripts)${R}`);
1064
+ console.log(` ${CY}content-grade analyze ./post.md --quiet${R}`);
1065
+ blank();
746
1066
  console.log(` ${D}# Grade a headline${R}`);
747
1067
  console.log(` ${CY}content-grade headline "How I 10x'd My Conversion Rate in 30 Days"${R}`);
748
1068
  blank();
1069
+ console.log(` ${D}# Batch analyze a content directory (Pro)${R}`);
1070
+ console.log(` ${CY}content-grade batch ./posts${R}`);
1071
+ blank();
749
1072
  console.log(` ${D}# Launch full dashboard${R}`);
750
1073
  console.log(` ${CY}content-grade start${R}`);
751
1074
  blank();
@@ -758,7 +1081,7 @@ function cmdHelp() {
758
1081
 
759
1082
  console.log(` ${B}PRO TIER${R} ${MG}$9/month${R}`);
760
1083
  blank();
761
- console.log(` · 100 analyses/day (vs 3 free)`);
1084
+ console.log(` · 100 analyses/day (vs 50 free)`);
762
1085
  console.log(` · Competitor headline A/B comparison`);
763
1086
  console.log(` · Landing page URL audit`);
764
1087
  console.log(` · Ad copy scoring (Google, Meta, LinkedIn)`);
@@ -787,6 +1110,18 @@ In conclusion, these productivity tips are very helpful. Try them today and you
787
1110
  `;
788
1111
 
789
1112
  async function cmdDemo() {
1113
+ // If Claude isn't installed, skip the demo and run guided setup instead
1114
+ if (!checkClaude()) {
1115
+ console.log('');
1116
+ console.log(` ${B}${CY}Welcome to ContentGrade${R}`);
1117
+ console.log('');
1118
+ console.log(` ${D}ContentGrade needs Claude CLI to run analysis.${R}`);
1119
+ console.log(` ${D}Let's get you set up — it only takes a minute.${R}`);
1120
+ console.log('');
1121
+ await cmdInit();
1122
+ return;
1123
+ }
1124
+
790
1125
  const tmpFile = `/tmp/content-grade-demo-${process.pid}-${Date.now()}.md`;
791
1126
 
792
1127
  banner();
@@ -799,6 +1134,107 @@ async function cmdDemo() {
799
1134
  blank();
800
1135
 
801
1136
  writeFileSync(tmpFile, DEMO_CONTENT, 'utf8');
1137
+ try {
1138
+ await cmdAnalyze(tmpFile);
1139
+ // Show telemetry notice after user has seen value (first run only)
1140
+ if (_showTelemNotice) {
1141
+ console.log(` ${D}Anonymous usage data is collected by default. Opt out: ${CY}content-grade telemetry off${R}`);
1142
+ blank();
1143
+ }
1144
+ } finally {
1145
+ try { unlinkSync(tmpFile); } catch {}
1146
+ }
1147
+ }
1148
+
1149
+ // ── URL fetcher ───────────────────────────────────────────────────────────────
1150
+
1151
+ function fetchUrl(url) {
1152
+ return new Promise((resolve, reject) => {
1153
+ const get = url.startsWith('https') ? httpsGet : httpGet;
1154
+ const req = get(url, { headers: { 'User-Agent': 'ContentGrade/1.0 (+https://github.com/StanislavBG/Content-Grade)' }, timeout: 15000 }, (res) => {
1155
+ // Follow one redirect
1156
+ if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) {
1157
+ fetchUrl(res.headers.location).then(resolve).catch(reject);
1158
+ res.resume();
1159
+ return;
1160
+ }
1161
+ if (res.statusCode < 200 || res.statusCode >= 300) {
1162
+ reject(new Error(`HTTP ${res.statusCode}`));
1163
+ res.resume();
1164
+ return;
1165
+ }
1166
+ const chunks = [];
1167
+ res.on('data', c => chunks.push(c));
1168
+ res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8', 0, 500000)));
1169
+ res.on('error', reject);
1170
+ });
1171
+ req.on('error', reject);
1172
+ req.on('timeout', () => { req.destroy(); reject(new Error('Request timed out')); });
1173
+ });
1174
+ }
1175
+
1176
+ function htmlToText(html) {
1177
+ return html
1178
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
1179
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
1180
+ .replace(/<nav[\s\S]*?<\/nav>/gi, ' ')
1181
+ .replace(/<footer[\s\S]*?<\/footer>/gi, ' ')
1182
+ .replace(/<header[\s\S]*?<\/header>/gi, ' ')
1183
+ .replace(/<!--[\s\S]*?-->/g, ' ')
1184
+ .replace(/<(h[1-6])[^>]*>([\s\S]*?)<\/\1>/gi, (_, _t, txt) => `\n\n## ${txt}\n\n`)
1185
+ .replace(/<(p|li|blockquote)[^>]*>([\s\S]*?)<\/\1>/gi, (_, _t, txt) => `\n${txt}\n`)
1186
+ .replace(/<br\s*\/?>/gi, '\n')
1187
+ .replace(/<[^>]+>/g, ' ')
1188
+ .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, ' ')
1189
+ .replace(/[ \t]{2,}/g, ' ')
1190
+ .replace(/\n{3,}/g, '\n\n')
1191
+ .trim();
1192
+ }
1193
+
1194
+ async function cmdAnalyzeUrl(url) {
1195
+ banner();
1196
+ console.log(` ${B}Analyzing URL:${R} ${CY}${url}${R}`);
1197
+ blank();
1198
+
1199
+ if (!checkClaude()) {
1200
+ fail(`Claude CLI not found.`);
1201
+ blank();
1202
+ console.log(` ContentGrade uses your local Claude CLI — no API keys needed.`);
1203
+ console.log(` Install from ${CY}https://claude.ai/code${R} then run ${CY}claude login${R}`);
1204
+ blank();
1205
+ process.exit(1);
1206
+ }
1207
+
1208
+ process.stdout.write(` ${D}Fetching page...${R}`);
1209
+ let html;
1210
+ try {
1211
+ html = await fetchUrl(url);
1212
+ process.stdout.write(`\r ${GN}✓${R} Page fetched${' '.repeat(20)}\n`);
1213
+ } catch (err) {
1214
+ process.stdout.write(`\n`);
1215
+ blank();
1216
+ fail(`Could not fetch URL: ${err.message}`);
1217
+ blank();
1218
+ console.log(` ${YL}Check:${R}`);
1219
+ console.log(` ${D}· URL is accessible (try opening it in a browser)${R}`);
1220
+ console.log(` ${D}· You have internet access${R}`);
1221
+ blank();
1222
+ process.exit(1);
1223
+ }
1224
+
1225
+ const text = htmlToText(html);
1226
+ if (text.length < 50) {
1227
+ blank();
1228
+ fail(`Could not extract readable content from ${url}`);
1229
+ blank();
1230
+ console.log(` ${D}The page may be JavaScript-rendered. Try saving the page content to a .md file and running:${R}`);
1231
+ console.log(` ${CY} content-grade analyze ./page.md${R}`);
1232
+ blank();
1233
+ process.exit(1);
1234
+ }
1235
+
1236
+ const tmpFile = `/tmp/content-grade-url-${process.pid}-${Date.now()}.md`;
1237
+ writeFileSync(tmpFile, `# ${url}\n\n${text}`, 'utf8');
802
1238
  try {
803
1239
  await cmdAnalyze(tmpFile);
804
1240
  } finally {
@@ -853,19 +1289,18 @@ function findBestContentFile(dirPath) {
853
1289
 
854
1290
  // ── Router ────────────────────────────────────────────────────────────────────
855
1291
 
856
- const args = process.argv.slice(2).filter(a => a !== '--no-telemetry');
1292
+ const _rawArgs = process.argv.slice(2);
1293
+ const _jsonMode = _rawArgs.includes('--json');
1294
+ const _quietMode = _rawArgs.includes('--quiet');
1295
+ const _verboseMode = _rawArgs.includes('--verbose') || _rawArgs.includes('-v') && !_rawArgs.includes('--version');
1296
+ const args = _rawArgs.filter(a => !['--no-telemetry', '--json', '--quiet', '--verbose'].includes(a));
857
1297
  const raw = args[0];
858
1298
  const cmd = raw?.toLowerCase();
859
1299
 
860
1300
  // ── Telemetry init ────────────────────────────────────────────────────────────
861
1301
  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
- }
1302
+ // Defer first-run telemetry notice so users see value first
1303
+ const _showTelemNotice = _telem.isNew;
869
1304
 
870
1305
  // Smart path detection: first arg is a path if it starts with ., /, ~ or is an existing FS entry
871
1306
  function looksLikePath(s) {
@@ -879,6 +1314,7 @@ switch (cmd) {
879
1314
  case 'analyze':
880
1315
  case 'analyse':
881
1316
  case 'check':
1317
+ case 'grade':
882
1318
  recordEvent({ event: 'command', command: 'analyze' });
883
1319
  cmdAnalyze(args[1]).catch(err => {
884
1320
  blank();
@@ -899,7 +1335,6 @@ switch (cmd) {
899
1335
  break;
900
1336
 
901
1337
  case 'headline':
902
- case 'grade':
903
1338
  recordEvent({ event: 'command', command: 'headline' });
904
1339
  cmdHeadline(args.slice(1).join(' ')).catch(err => {
905
1340
  blank();
@@ -926,6 +1361,27 @@ switch (cmd) {
926
1361
  });
927
1362
  break;
928
1363
 
1364
+ case 'activate':
1365
+ case 'license':
1366
+ recordEvent({ event: 'command', command: 'activate' });
1367
+ cmdActivate().catch(err => {
1368
+ blank();
1369
+ fail(`Activation error: ${err.message}`);
1370
+ blank();
1371
+ process.exit(1);
1372
+ });
1373
+ break;
1374
+
1375
+ case 'batch':
1376
+ recordEvent({ event: 'command', command: 'batch' });
1377
+ cmdBatch(args[1]).catch(err => {
1378
+ blank();
1379
+ fail(`Batch error: ${err.message}`);
1380
+ blank();
1381
+ process.exit(1);
1382
+ });
1383
+ break;
1384
+
929
1385
  case 'help':
930
1386
  case '--help':
931
1387
  case '-h':
@@ -984,7 +1440,15 @@ switch (cmd) {
984
1440
  break;
985
1441
 
986
1442
  default:
987
- if (looksLikePath(raw)) {
1443
+ if (/^https?:\/\//i.test(raw)) {
1444
+ recordEvent({ event: 'command', command: 'analyze_url' });
1445
+ cmdAnalyzeUrl(raw).catch(err => {
1446
+ blank();
1447
+ fail(`Unexpected error: ${err.message}`);
1448
+ blank();
1449
+ process.exit(1);
1450
+ });
1451
+ } else if (looksLikePath(raw)) {
988
1452
  // Directory: find best content file inside it
989
1453
  let target;
990
1454
  try {
package/bin/telemetry.js CHANGED
@@ -201,6 +201,40 @@ export function telemetryStatus() {
201
201
  };
202
202
  }
203
203
 
204
+ // ── Rate limiting ─────────────────────────────────────────────────────────────
205
+
206
+ const FREE_DAILY_LIMIT = 50;
207
+
208
+ /**
209
+ * Check whether the user is within the free daily limit for a given command type.
210
+ * Type: 'analyze' | 'headline'
211
+ * Returns { ok: boolean, used: number, remaining: number, isPro: boolean }
212
+ */
213
+ export function checkDailyLimit(type = 'analyze') {
214
+ const cfg = loadConfig();
215
+ if (cfg?.licenseKey) return { ok: true, used: 0, remaining: Infinity, isPro: true };
216
+
217
+ const today = new Date().toISOString().slice(0, 10);
218
+ const used = (cfg?.dailyUsage?.date === today ? cfg.dailyUsage[type] ?? 0 : 0);
219
+ const remaining = FREE_DAILY_LIMIT - used;
220
+ return { ok: remaining > 0, used, remaining: Math.max(0, remaining), isPro: false };
221
+ }
222
+
223
+ /**
224
+ * Increment the daily usage counter for a command type after a successful run.
225
+ * Returns the new usage count.
226
+ */
227
+ export function incrementDailyUsage(type = 'analyze') {
228
+ const cfg = loadConfig() || {};
229
+ const today = new Date().toISOString().slice(0, 10);
230
+ if (!cfg.dailyUsage || cfg.dailyUsage.date !== today) {
231
+ cfg.dailyUsage = { date: today, analyze: 0, headline: 0 };
232
+ }
233
+ cfg.dailyUsage[type] = (cfg.dailyUsage[type] ?? 0) + 1;
234
+ saveConfig(cfg);
235
+ return cfg.dailyUsage[type];
236
+ }
237
+
204
238
  // ── Internal ──────────────────────────────────────────────────────────────────
205
239
 
206
240
  function _write(event) {
@@ -5,7 +5,7 @@ import { getDb } from '../db.js';
5
5
  import { askClaude } from '../claude.js';
6
6
  import { hasActiveSubscriptionLive, hasPurchased } from '../services/stripe.js';
7
7
  // ── Usage tracking utilities ──────────────────────────────────────
8
- const FREE_TIER_LIMIT = 3;
8
+ const FREE_TIER_LIMIT = 50;
9
9
  const PRO_TIER_LIMIT = 100;
10
10
  const HEADLINE_GRADER_ENDPOINT = 'headline-grader';
11
11
  const UPGRADE_URL = 'https://contentgrade.ai/#pricing';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "content-grade",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "AI-powered content analysis CLI. Score any blog post, landing page, or ad copy in under 30 seconds — runs on Claude CLI, no API key needed.",
5
5
  "type": "module",
6
6
  "bin": {