@rolandjlevy/css-ghost 1.0.1 → 1.0.4
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 +100 -25
- package/bin/css-ghost.js +21 -4
- package/package.json +4 -2
- package/src/ai.js +94 -0
- package/src/parser.js +23 -4
- package/src/reporter.js +13 -7
- package/src/setup.js +60 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Find orphaned CSS class selectors in your codebase — defined in style files bu
|
|
|
4
4
|
|
|
5
5
|
## What it does
|
|
6
6
|
|
|
7
|
-
Scans one or more directories for style files, extracts every class selector defined, then cross-references them against usages in your component files. Anything defined but never used is reported as an orphan.
|
|
7
|
+
Scans one or more directories for style files, extracts every class selector defined, then cross-references them against usages in your component files. Anything defined but never used is reported as an orphan — with its line number and the actual CSS printed inline.
|
|
8
8
|
|
|
9
9
|
**Supported style files:**
|
|
10
10
|
- `.css`, `.module.css`, `.scss`, `.sass`, `.less`
|
|
@@ -14,21 +14,40 @@ Scans one or more directories for style files, extracts every class selector def
|
|
|
14
14
|
- `className="foo"`
|
|
15
15
|
- `className={styles.foo}`
|
|
16
16
|
- `styles['foo']`
|
|
17
|
+
- `styles[condition ? 'foo' : 'bar']` — both branches extracted
|
|
17
18
|
- `cx('foo')`, `clsx('foo')`, `classnames('foo')`
|
|
19
|
+
- `cx({ active: isOn, disabled: true })` — object keys extracted
|
|
20
|
+
- `cx(['foo', condition && 'bar'])` — array string literals extracted
|
|
18
21
|
|
|
19
22
|
**Dynamic class detection:**
|
|
20
|
-
|
|
23
|
+
Patterns where the class name is truly unknowable at parse time — `styles[variable]` or template literals containing `${}` — are flagged with a ⚠️ warning. All statically resolvable patterns (string literals, ternaries, object keys) are analysed without warnings.
|
|
24
|
+
|
|
25
|
+
## To enable AI features
|
|
26
|
+
|
|
27
|
+
css-ghost can generate a plain English summary of your orphaned selectors using a free [OpenRouter](https://openrouter.ai) API key. Run the setup wizard once:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
css-ghost --setup
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
It will prompt you to paste your key (free at [openrouter.ai/keys](https://openrouter.ai/keys)) and save it to `~/.css-ghost`. Then pass `--ai` to any scan:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
css-ghost ./src --ai
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The AI summary appears in both the terminal output and the markdown report. The core scan always runs without a key — `--ai` is entirely optional.
|
|
21
40
|
|
|
22
41
|
## Install
|
|
23
42
|
|
|
24
43
|
```bash
|
|
25
|
-
npm install -g css-ghost
|
|
44
|
+
npm install -g @rolandjlevy/css-ghost
|
|
26
45
|
```
|
|
27
46
|
|
|
28
|
-
Or run
|
|
47
|
+
Or run without installing:
|
|
29
48
|
|
|
30
49
|
```bash
|
|
31
|
-
npx css-ghost ./src
|
|
50
|
+
npx @rolandjlevy/css-ghost ./src
|
|
32
51
|
```
|
|
33
52
|
|
|
34
53
|
## Usage
|
|
@@ -42,49 +61,105 @@ css-ghost <directory> [directory2] ... [options]
|
|
|
42
61
|
| Flag | Description | Default |
|
|
43
62
|
| --- | --- | --- |
|
|
44
63
|
| `--output <file>` | Output report file path | `orphan-report.md` |
|
|
64
|
+
| `--ai` | Append an AI-generated summary (requires setup) | off |
|
|
65
|
+
| `--setup` | Save your OpenRouter API key for `--ai` | — |
|
|
45
66
|
| `-h, --help` | Show help | — |
|
|
46
67
|
|
|
47
68
|
## Examples
|
|
48
69
|
|
|
49
70
|
```bash
|
|
50
|
-
# Scan
|
|
71
|
+
# Scan your src folder
|
|
51
72
|
css-ghost ./src
|
|
52
73
|
|
|
53
74
|
# Scan multiple directories
|
|
54
75
|
css-ghost ./src ./components ./pages
|
|
55
76
|
|
|
56
|
-
#
|
|
77
|
+
# With AI summary
|
|
78
|
+
css-ghost ./src --ai
|
|
79
|
+
|
|
80
|
+
# Write report to a custom path
|
|
57
81
|
css-ghost ./src --output ./reports/css-audit.md
|
|
58
82
|
```
|
|
59
83
|
|
|
60
|
-
##
|
|
84
|
+
## CLI output
|
|
85
|
+
|
|
86
|
+
Running `css-ghost ./src` prints a live summary to your terminal:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
✅ Report written to orphan-report.md
|
|
90
|
+
📊 3 orphaned selectors across 1 file
|
|
91
|
+
|
|
92
|
+
📄 src/components/Button/Button.module.css
|
|
93
|
+
👻 .iconLeft (line 4)
|
|
94
|
+
.iconLeft {
|
|
95
|
+
margin-right: 6px;
|
|
96
|
+
flex-shrink: 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
👻 .loadingSpinner (line 12)
|
|
100
|
+
.loadingSpinner {
|
|
101
|
+
animation: spin 0.8s linear infinite;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
👻 .disabled (line 18)
|
|
105
|
+
.disabled {
|
|
106
|
+
opacity: 0.4;
|
|
107
|
+
pointer-events: none;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
📁 1 style file scanned
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Each orphan shows:
|
|
114
|
+
- the selector name and the **exact line number** where it's defined
|
|
115
|
+
- the **full CSS rule** so you can see what it does before deciding to delete it
|
|
116
|
+
|
|
117
|
+
## Markdown report
|
|
61
118
|
|
|
62
|
-
|
|
119
|
+
A full report is also written to `orphan-report.md` (or your `--output` path):
|
|
63
120
|
|
|
64
121
|
---
|
|
65
122
|
|
|
66
|
-
|
|
123
|
+
### 👻 Orphaned selectors
|
|
67
124
|
|
|
68
|
-
|
|
69
|
-
| Metric | Value |
|
|
70
|
-
| --- | --- |
|
|
71
|
-
| Style files scanned | 12 |
|
|
72
|
-
| Total selectors defined | 84 |
|
|
73
|
-
| Total orphaned selectors | 7 |
|
|
74
|
-
| Files with orphans | 3 |
|
|
75
|
-
| Overall unused rate | 8% |
|
|
125
|
+
#### `.iconLeft` — line 4
|
|
76
126
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
127
|
+
```css
|
|
128
|
+
.iconLeft {
|
|
129
|
+
margin-right: 6px;
|
|
130
|
+
flex-shrink: 0;
|
|
131
|
+
}
|
|
132
|
+
```
|
|
81
133
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
134
|
+
#### `.loadingSpinner` — line 12
|
|
135
|
+
|
|
136
|
+
```css
|
|
137
|
+
.loadingSpinner {
|
|
138
|
+
animation: spin 0.8s linear infinite;
|
|
139
|
+
}
|
|
140
|
+
```
|
|
85
141
|
|
|
86
142
|
---
|
|
87
143
|
|
|
144
|
+
## Using with npm scripts
|
|
145
|
+
|
|
146
|
+
If you're working inside the project itself, add a `scan` script to `package.json`:
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
"scripts": {
|
|
150
|
+
"scan": "css-ghost ./src"
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Then run:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
npm run scan
|
|
158
|
+
|
|
159
|
+
# Pass extra args with --
|
|
160
|
+
npm run scan -- --output ./reports/audit.md
|
|
161
|
+
```
|
|
162
|
+
|
|
88
163
|
## No dependencies
|
|
89
164
|
|
|
90
165
|
Built with Node.js built-ins only (`fs`, `path`). No npm packages required.
|
package/bin/css-ghost.js
CHANGED
|
@@ -4,9 +4,17 @@ import { resolve } from 'path';
|
|
|
4
4
|
import { existsSync as fsExists } from 'fs';
|
|
5
5
|
import { scan } from '../src/scanner.js';
|
|
6
6
|
import { generateReport } from '../src/reporter.js';
|
|
7
|
+
import { runSetup } from '../src/setup.js';
|
|
8
|
+
import { getAiSummary } from '../src/ai.js';
|
|
7
9
|
|
|
8
10
|
const args = process.argv.slice(2);
|
|
9
11
|
|
|
12
|
+
// --setup
|
|
13
|
+
if (args.includes('--setup')) {
|
|
14
|
+
await runSetup();
|
|
15
|
+
process.exit(0);
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
11
19
|
console.log(`
|
|
12
20
|
👻 css-ghost — Find orphaned CSS class selectors
|
|
@@ -16,17 +24,21 @@ Usage:
|
|
|
16
24
|
|
|
17
25
|
Options:
|
|
18
26
|
--output <file> Output report file path (default: orphan-report.md)
|
|
27
|
+
--ai Append an AI-generated summary to the report
|
|
28
|
+
--setup Save your OpenRouter API key for use with --ai
|
|
19
29
|
-h, --help Show this help message
|
|
20
30
|
|
|
21
31
|
Examples:
|
|
22
32
|
css-ghost ./src
|
|
23
33
|
css-ghost ./src ./components
|
|
34
|
+
css-ghost ./src --ai
|
|
24
35
|
css-ghost ./src --output ./reports/css-audit.md
|
|
25
36
|
`);
|
|
26
37
|
process.exit(0);
|
|
27
38
|
}
|
|
28
39
|
|
|
29
|
-
// Parse
|
|
40
|
+
// Parse flags
|
|
41
|
+
const useAi = args.includes('--ai');
|
|
30
42
|
const outputIdx = args.indexOf('--output');
|
|
31
43
|
const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : 'orphan-report.md';
|
|
32
44
|
|
|
@@ -35,8 +47,10 @@ if (outputIdx !== -1 && !outputPath) {
|
|
|
35
47
|
process.exit(1);
|
|
36
48
|
}
|
|
37
49
|
|
|
38
|
-
// Remaining args are directories
|
|
39
|
-
const rawDirs = args.filter((a, i) =>
|
|
50
|
+
// Remaining args are directories (strip flags and their values)
|
|
51
|
+
const rawDirs = args.filter((a, i) =>
|
|
52
|
+
!a.startsWith('--') && (outputIdx === -1 || i !== outputIdx + 1)
|
|
53
|
+
);
|
|
40
54
|
|
|
41
55
|
if (rawDirs.length === 0) {
|
|
42
56
|
console.error('❌ At least one directory is required');
|
|
@@ -65,4 +79,7 @@ if (results.length === 0) {
|
|
|
65
79
|
process.exit(0);
|
|
66
80
|
}
|
|
67
81
|
|
|
68
|
-
|
|
82
|
+
// Get AI summary before writing report (so it can be included in the .md)
|
|
83
|
+
const aiSummary = useAi ? await getAiSummary(results) : null;
|
|
84
|
+
|
|
85
|
+
generateReport(results, outputPath, aiSummary);
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rolandjlevy/css-ghost",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Find orphaned CSS class selectors in your codebase",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"css-ghost": "bin/css-ghost.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"start": "node bin/css-ghost.js"
|
|
10
|
+
"start": "node bin/css-ghost.js",
|
|
11
|
+
"scan": "node bin/css-ghost.js ./test",
|
|
12
|
+
"setup": "node bin/css-ghost.js --setup"
|
|
11
13
|
},
|
|
12
14
|
"files": [
|
|
13
15
|
"bin",
|
package/src/ai.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { relative } from 'path';
|
|
2
|
+
import { loadConfig } from './setup.js';
|
|
3
|
+
|
|
4
|
+
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
|
5
|
+
// Fallback chain — tried in order until one returns a response
|
|
6
|
+
const FREE_MODELS = [
|
|
7
|
+
'nvidia/nemotron-3.5-lightning:free',
|
|
8
|
+
'google/gemma-4-31b-it:free',
|
|
9
|
+
'qwen/qwen3.8-27b:free',
|
|
10
|
+
'thinkingmachines/inkling:free',
|
|
11
|
+
'nvidia/nemotron-3-super-120b-a12b:free',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function getApiKey() {
|
|
15
|
+
if (process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY;
|
|
16
|
+
const config = loadConfig();
|
|
17
|
+
return config.openrouterApiKey || null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getModelOverride() {
|
|
21
|
+
if (process.env.OPENROUTER_MODEL) return process.env.OPENROUTER_MODEL;
|
|
22
|
+
const config = loadConfig();
|
|
23
|
+
return config.openrouterModel || null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function tryModel(model, key, prompt) {
|
|
27
|
+
const response = await fetch(OPENROUTER_URL, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: {
|
|
30
|
+
'Authorization': `Bearer ${key}`,
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
'HTTP-Referer': 'https://github.com/rolandjlevy/css-ghost',
|
|
33
|
+
'X-Title': 'css-ghost',
|
|
34
|
+
},
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
model,
|
|
37
|
+
messages: [{ role: 'user', content: prompt }],
|
|
38
|
+
max_tokens: 300,
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (!response.ok) return null;
|
|
43
|
+
const data = await response.json();
|
|
44
|
+
return data.choices?.[0]?.message?.content?.trim() || null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildPrompt(results) {
|
|
48
|
+
const totalOrphaned = results.reduce((n, r) => n + r.orphaned.length, 0);
|
|
49
|
+
const filesWithOrphans = results.filter(r => r.orphaned.length > 0);
|
|
50
|
+
|
|
51
|
+
const details = filesWithOrphans.map(r => {
|
|
52
|
+
const relPath = relative(process.cwd(), r.filePath);
|
|
53
|
+
const names = r.orphaned.map(o => `.${o.name}`).join(', ');
|
|
54
|
+
return `- ${relPath}: ${names}`;
|
|
55
|
+
}).join('\n');
|
|
56
|
+
|
|
57
|
+
return `You are a CSS code quality assistant. A static analysis tool found ${totalOrphaned} orphaned CSS selectors (defined but never referenced in any component file) across ${filesWithOrphans.length} file(s):
|
|
58
|
+
|
|
59
|
+
${details}
|
|
60
|
+
|
|
61
|
+
Write a short, practical summary (3–5 sentences) for a developer. Cover:
|
|
62
|
+
1. Whether this looks like a significant problem or minor cleanup
|
|
63
|
+
2. Which files or selectors are most worth investigating first
|
|
64
|
+
3. One concrete suggestion for how to approach the cleanup
|
|
65
|
+
|
|
66
|
+
Be direct and specific. No fluff.`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function getAiSummary(results) {
|
|
70
|
+
const key = getApiKey();
|
|
71
|
+
|
|
72
|
+
if (!key) {
|
|
73
|
+
console.log('💡 Tip: Run css-ghost --setup to enable AI summaries with --ai\n');
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const override = getModelOverride();
|
|
78
|
+
const models = override ? [override] : FREE_MODELS;
|
|
79
|
+
const prompt = buildPrompt(results);
|
|
80
|
+
|
|
81
|
+
console.log('🤖 Generating AI summary...\n');
|
|
82
|
+
|
|
83
|
+
for (const model of models) {
|
|
84
|
+
try {
|
|
85
|
+
const content = await tryModel(model, key, prompt);
|
|
86
|
+
if (content) return content;
|
|
87
|
+
} catch {
|
|
88
|
+
// try next model
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.warn('⚠️ AI summary unavailable — all free models failed or returned empty responses.\n');
|
|
93
|
+
return null;
|
|
94
|
+
}
|
package/src/parser.js
CHANGED
|
@@ -135,23 +135,42 @@ export function extractUsages(content) {
|
|
|
135
135
|
used.add(m[1]);
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
// styles[
|
|
139
|
-
|
|
138
|
+
// styles[condition ? 'foo' : 'bar'] — ternary with string literals: extract both branches
|
|
139
|
+
const ternaryBracketRegex = /styles?\[\s*[^?]+\?\s*['"]([a-zA-Z_$][a-zA-Z0-9_$]*)["']\s*:\s*['"]([a-zA-Z_$][a-zA-Z0-9_$]*)["']\s*\]/g;
|
|
140
|
+
while ((m = ternaryBracketRegex.exec(content)) !== null) {
|
|
141
|
+
used.add(m[1]);
|
|
142
|
+
used.add(m[2]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// styles[variable] — truly dynamic (not a string literal or ternary): flag it
|
|
146
|
+
const trulyDynamicBracket = /styles?\[(?!['"])(?![^?]+\?\s*['"])/;
|
|
147
|
+
if (trulyDynamicBracket.test(content)) {
|
|
140
148
|
hasDynamic = true;
|
|
141
149
|
}
|
|
142
150
|
|
|
143
151
|
// cx('foo', 'bar') / clsx('foo') / classnames('foo')
|
|
144
|
-
|
|
152
|
+
// Also handles object syntax: cx({ active: isOn, disabled: true })
|
|
153
|
+
// and array syntax: cx(['foo', condition && 'bar'])
|
|
154
|
+
const cxRegex = /(?:cx|clsx|classnames)\s*\(([\s\S]*?)\)/g;
|
|
145
155
|
while ((m = cxRegex.exec(content)) !== null) {
|
|
146
156
|
const inner = m[1];
|
|
157
|
+
|
|
158
|
+
// String literals: 'foo' or "foo"
|
|
147
159
|
const strRegex = /['"]([a-zA-Z_-][a-zA-Z0-9_-]*)["']/g;
|
|
148
160
|
let sm;
|
|
149
161
|
while ((sm = strRegex.exec(inner)) !== null) {
|
|
150
162
|
used.add(sm[1]);
|
|
151
163
|
}
|
|
164
|
+
|
|
165
|
+
// Object keys: { active: ..., disabled: ... }
|
|
166
|
+
const objKeyRegex = /^\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/gm;
|
|
167
|
+
while ((sm = objKeyRegex.exec(inner)) !== null) {
|
|
168
|
+
used.add(sm[1]);
|
|
169
|
+
}
|
|
152
170
|
}
|
|
153
171
|
|
|
154
|
-
// Template literal dynamic class names
|
|
172
|
+
// Template literal dynamic class names — flag only if truly dynamic (has ${})
|
|
173
|
+
// But first extract any static styles.foo refs inside them (already caught above by dotRegex)
|
|
155
174
|
if (/`[^`]*\$\{[^}]+\}[^`]*`/.test(content)) {
|
|
156
175
|
hasDynamic = true;
|
|
157
176
|
}
|
package/src/reporter.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { writeFileSync } from 'fs';
|
|
2
2
|
import { relative } from 'path';
|
|
3
3
|
|
|
4
|
-
export function generateReport(results, outputPath = 'orphan-report.md') {
|
|
4
|
+
export function generateReport(results, outputPath = 'orphan-report.md', aiSummary = null) {
|
|
5
5
|
let totalDefined = 0;
|
|
6
6
|
let totalOrphaned = 0;
|
|
7
7
|
let filesWithOrphans = 0;
|
|
@@ -85,17 +85,25 @@ export function generateReport(results, outputPath = 'orphan-report.md') {
|
|
|
85
85
|
'',
|
|
86
86
|
].join('\n');
|
|
87
87
|
|
|
88
|
-
const
|
|
88
|
+
const aiBlock = aiSummary
|
|
89
|
+
? `## 🤖 AI Summary\n\n${aiSummary}\n\n---\n\n`
|
|
90
|
+
: '';
|
|
91
|
+
|
|
92
|
+
const content = summary + aiBlock + sections.join('');
|
|
89
93
|
writeFileSync(outputPath, content, 'utf-8');
|
|
90
94
|
|
|
91
95
|
// Console output — also print orphans inline
|
|
92
|
-
console.log(
|
|
93
|
-
|
|
96
|
+
console.log(`📊 ${totalOrphaned} orphaned selector${totalOrphaned !== 1 ? 's' : ''} in ${filesWithOrphans} file${filesWithOrphans !== 1 ? 's' : ''}. See: ${outputPath}\n`);
|
|
97
|
+
|
|
98
|
+
if (aiSummary) {
|
|
99
|
+
console.log(`🤖 AI summary\n`);
|
|
100
|
+
console.log(` ${aiSummary.split('\n').join('\n ')}\n`);
|
|
101
|
+
}
|
|
94
102
|
|
|
95
103
|
for (const result of results) {
|
|
96
104
|
if (result.orphaned.length === 0) continue;
|
|
97
105
|
const relPath = relative(process.cwd(), result.filePath);
|
|
98
|
-
console.log(
|
|
106
|
+
console.log(`✅ ${relPath}\n`);
|
|
99
107
|
result.orphaned.forEach(({ name, line, rule }) => {
|
|
100
108
|
console.log(` 👻 .${name} (line ${line})`);
|
|
101
109
|
if (rule) {
|
|
@@ -105,6 +113,4 @@ export function generateReport(results, outputPath = 'orphan-report.md') {
|
|
|
105
113
|
console.log('');
|
|
106
114
|
});
|
|
107
115
|
}
|
|
108
|
-
|
|
109
|
-
console.log(`📁 ${results.length} style file${results.length !== 1 ? 's' : ''} scanned\n`);
|
|
110
116
|
}
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createInterface } from 'readline';
|
|
2
|
+
import { writeFileSync, readFileSync, existsSync } from 'fs';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
|
|
6
|
+
export const CONFIG_PATH = join(homedir(), '.css-ghost');
|
|
7
|
+
|
|
8
|
+
export function loadConfig() {
|
|
9
|
+
if (!existsSync(CONFIG_PATH)) return {};
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
|
12
|
+
} catch {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function saveConfig(config) {
|
|
18
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function runSetup() {
|
|
22
|
+
console.log(`
|
|
23
|
+
👻 css-ghost — AI setup
|
|
24
|
+
|
|
25
|
+
To enable AI summaries, you need a free OpenRouter API key.
|
|
26
|
+
|
|
27
|
+
1. Go to https://openrouter.ai/keys
|
|
28
|
+
2. Sign up (free) and create a key
|
|
29
|
+
3. Paste it below — it will be saved to ${CONFIG_PATH}
|
|
30
|
+
|
|
31
|
+
`);
|
|
32
|
+
|
|
33
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
34
|
+
|
|
35
|
+
const key = await new Promise(resolve => {
|
|
36
|
+
rl.question('Paste your OpenRouter API key: ', answer => {
|
|
37
|
+
rl.close();
|
|
38
|
+
resolve(answer.trim());
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (!key) {
|
|
43
|
+
console.log('\n❌ No key entered. Setup cancelled.\n');
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!key.startsWith('sk-or-')) {
|
|
48
|
+
console.log('\n⚠️ That doesn\'t look like an OpenRouter key (should start with sk-or-).');
|
|
49
|
+
console.log(' Saving anyway — double-check at https://openrouter.ai/keys if --ai fails.\n');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const existing = loadConfig();
|
|
53
|
+
saveConfig({ ...existing, openrouterApiKey: key });
|
|
54
|
+
|
|
55
|
+
console.log(`
|
|
56
|
+
✅ Key saved to ${CONFIG_PATH}
|
|
57
|
+
|
|
58
|
+
Run css-ghost ./src --ai to get an AI summary alongside your report.
|
|
59
|
+
`);
|
|
60
|
+
}
|