llm-fit 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/README.md +134 -0
- package/commands/install.js +116 -0
- package/commands/models.js +60 -0
- package/commands/recommend.js +62 -0
- package/commands/scan.js +97 -0
- package/commands/update-models.js +52 -0
- package/data/models.json +842 -0
- package/index.js +59 -0
- package/package.json +1 -0
- package/services/huggingface.js +130 -0
- package/services/recommender.js +135 -0
- package/services/systemScanner.js +101 -0
- package/utils/banner.js +19 -0
- package/utils/formatter.js +142 -0
- package/utils/scoreEngine.js +152 -0
package/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# llm-fit
|
|
2
|
+
|
|
3
|
+
`llm-fit` is a production-ready, open-source CLI that scans your hardware and recommends local LLMs and coding-focused models that fit your machine.
|
|
4
|
+
|
|
5
|
+
It supports Windows first, and also works on macOS and Linux.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Hardware scan (RAM, CPU, GPU, VRAM, OS, WSL detection)
|
|
10
|
+
- Tiered recommendations: Recommended, Might Work, Not Recommended
|
|
11
|
+
- Curated model catalog with category filters
|
|
12
|
+
- Optional model installs through Ollama
|
|
13
|
+
- Sync of trending GGUF models from Hugging Face
|
|
14
|
+
- ESM-only Node.js codebase for modern ecosystem compatibility
|
|
15
|
+
|
|
16
|
+
## Requirements
|
|
17
|
+
|
|
18
|
+
- Node.js 18+
|
|
19
|
+
- npm
|
|
20
|
+
- Ollama (optional, only needed for `install` command)
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
### From source
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
git clone https://github.com/your-username/llm-fit
|
|
28
|
+
cd llm-fit
|
|
29
|
+
npm install
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Link globally for development
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm link
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Use it
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
llm-fit scan
|
|
42
|
+
llm-fit recommend
|
|
43
|
+
llm-fit recommend --category coding
|
|
44
|
+
llm-fit models
|
|
45
|
+
llm-fit models --category general
|
|
46
|
+
llm-fit models --json
|
|
47
|
+
llm-fit install llama3.2:3b
|
|
48
|
+
llm-fit update-models
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Commands
|
|
52
|
+
|
|
53
|
+
### `llm-fit scan`
|
|
54
|
+
|
|
55
|
+
Scans your local machine and prints a hardware summary.
|
|
56
|
+
|
|
57
|
+
### `llm-fit recommend [--category coding|general|all]`
|
|
58
|
+
|
|
59
|
+
Scans your machine and returns three recommendation tiers.
|
|
60
|
+
|
|
61
|
+
- `--category coding` shows coding models only
|
|
62
|
+
- `--category general` shows general-purpose models only
|
|
63
|
+
- `--category all` (default) shows all curated models
|
|
64
|
+
|
|
65
|
+
### `llm-fit models [--category coding|general|all] [--json]`
|
|
66
|
+
|
|
67
|
+
Lists curated models in a table format or raw JSON.
|
|
68
|
+
|
|
69
|
+
### `llm-fit install <modelName>`
|
|
70
|
+
|
|
71
|
+
Installs a model via Ollama.
|
|
72
|
+
|
|
73
|
+
- Accepts either local `name` or `ollama_tag`
|
|
74
|
+
- If a model is unknown to local DB, install is still attempted
|
|
75
|
+
|
|
76
|
+
### `llm-fit update-models`
|
|
77
|
+
|
|
78
|
+
Fetches trending GGUF models from Hugging Face and appends only new entries without overwriting curated records.
|
|
79
|
+
|
|
80
|
+
## Output Design
|
|
81
|
+
|
|
82
|
+
- Cyan section headers for scan/results sections
|
|
83
|
+
- Green/Yellow/Red tiers for recommendation status
|
|
84
|
+
- 80-column-friendly table and boxed output
|
|
85
|
+
|
|
86
|
+
## Project Structure
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
llm-fit/
|
|
90
|
+
├── index.js
|
|
91
|
+
├── commands/
|
|
92
|
+
│ ├── scan.js
|
|
93
|
+
│ ├── recommend.js
|
|
94
|
+
│ ├── models.js
|
|
95
|
+
│ ├── install.js
|
|
96
|
+
│ └── update-models.js
|
|
97
|
+
├── services/
|
|
98
|
+
│ ├── systemScanner.js
|
|
99
|
+
│ ├── recommender.js
|
|
100
|
+
│ └── huggingface.js
|
|
101
|
+
├── data/
|
|
102
|
+
│ └── models.json
|
|
103
|
+
├── utils/
|
|
104
|
+
│ └── formatter.js
|
|
105
|
+
├── package.json
|
|
106
|
+
└── README.md
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Extensibility Notes
|
|
110
|
+
|
|
111
|
+
- `services/recommender.js` is intentionally stateless so the scoring function can be replaced by an AI-driven ranking service later.
|
|
112
|
+
- `data/models.json` is schema-version-ready by design; a top-level migration strategy can be introduced in a future release.
|
|
113
|
+
- `services/huggingface.js` uses only Node built-ins (`https`) so retry, caching, and offline fallback strategies can be added without external HTTP libraries.
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
Run directly without global linking:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
node index.js --help
|
|
121
|
+
node index.js scan
|
|
122
|
+
node index.js recommend --category coding
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Contributing
|
|
126
|
+
|
|
127
|
+
1. Fork the repository
|
|
128
|
+
2. Create a feature branch
|
|
129
|
+
3. Make changes with clear commit messages
|
|
130
|
+
4. Open a pull request with before/after command output samples
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { execSync, spawn } from 'node:child_process';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import ora from 'ora';
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = path.dirname(__filename);
|
|
10
|
+
const MODELS_PATH = path.join(__dirname, '..', 'data', 'models.json');
|
|
11
|
+
|
|
12
|
+
async function loadModels() {
|
|
13
|
+
const raw = await fs.readFile(MODELS_PATH, 'utf8');
|
|
14
|
+
const parsed = JSON.parse(raw.replace(/^\uFEFF/, ''));
|
|
15
|
+
|
|
16
|
+
if (!Array.isArray(parsed)) {
|
|
17
|
+
throw new Error('Model database is invalid: expected an array');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return parsed;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveInstallTarget(models, modelNameInput) {
|
|
24
|
+
const normalized = modelNameInput.toLowerCase();
|
|
25
|
+
const found = models.find((model) => {
|
|
26
|
+
const byName = String(model.name || '').toLowerCase() === normalized;
|
|
27
|
+
const byTag = String(model.ollama_tag || '').toLowerCase() === normalized;
|
|
28
|
+
return byName || byTag;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (!found) {
|
|
32
|
+
return {
|
|
33
|
+
model: null,
|
|
34
|
+
tag: modelNameInput
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
model: found,
|
|
40
|
+
tag: found.ollama_tag || found.name
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function ensureOllamaInstalled() {
|
|
45
|
+
const checkCmd = process.platform === 'win32' ? 'where ollama' : 'which ollama';
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
execSync(checkCmd, { stdio: 'ignore' });
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error(
|
|
51
|
+
'Ollama is not installed. Install it from https://ollama.com/download'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function pullModelViaOllama(tag) {
|
|
57
|
+
const spinner = ora(`Preparing Ollama install for ${tag}...`).start();
|
|
58
|
+
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
try {
|
|
61
|
+
ensureOllamaInstalled();
|
|
62
|
+
spinner.text = `Installing ${tag} via Ollama...`;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
spinner.fail('Ollama not found');
|
|
65
|
+
reject(error);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const child = spawn('ollama', ['pull', tag], {
|
|
70
|
+
stdio: 'inherit'
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
child.on('close', (code) => {
|
|
74
|
+
if (code === 0) {
|
|
75
|
+
spinner.succeed(`Installed ${tag}`);
|
|
76
|
+
resolve();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
spinner.fail(`Install failed for ${tag}`);
|
|
81
|
+
reject(new Error(`Ollama exited with code ${code}`));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
child.on('error', (error) => {
|
|
85
|
+
spinner.fail(`Install failed for ${tag}`);
|
|
86
|
+
reject(new Error(`Failed to launch Ollama: ${error.message}`));
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function registerInstallCommand(program) {
|
|
92
|
+
program
|
|
93
|
+
.command('install <modelName>')
|
|
94
|
+
.description('Install a model via Ollama')
|
|
95
|
+
.action(async (modelName) => {
|
|
96
|
+
try {
|
|
97
|
+
const models = await loadModels();
|
|
98
|
+
const resolved = resolveInstallTarget(models, modelName);
|
|
99
|
+
|
|
100
|
+
if (!resolved.model) {
|
|
101
|
+
console.log(
|
|
102
|
+
chalk.yellow(
|
|
103
|
+
`⚠️ Model '${modelName}' not found in local database. Attempting install anyway.`
|
|
104
|
+
)
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
await pullModelViaOllama(resolved.tag);
|
|
109
|
+
process.exit(0);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
const message = error instanceof Error ? error.message : 'Unknown install error';
|
|
112
|
+
console.error(chalk.red(`❌ ${message}`));
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { formatModelsTable, formatSectionHeader } from '../utils/formatter.js';
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
const MODELS_PATH = path.join(__dirname, '..', 'data', 'models.json');
|
|
9
|
+
|
|
10
|
+
async function loadModels() {
|
|
11
|
+
const raw = await fs.readFile(MODELS_PATH, 'utf8');
|
|
12
|
+
const parsed = JSON.parse(raw.replace(/^\uFEFF/, ''));
|
|
13
|
+
|
|
14
|
+
if (!Array.isArray(parsed)) {
|
|
15
|
+
throw new Error('Model database is invalid: expected an array');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return parsed;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function filterByCategory(models, category) {
|
|
22
|
+
if (category === 'all') {
|
|
23
|
+
return models;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return models.filter((model) => model.category === category);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function registerModelsCommand(program) {
|
|
30
|
+
program
|
|
31
|
+
.command('models')
|
|
32
|
+
.description('List all curated models')
|
|
33
|
+
.option('--category <category>', 'coding|general|all', 'all')
|
|
34
|
+
.option('--json', 'Output as raw JSON', false)
|
|
35
|
+
.action(async (options) => {
|
|
36
|
+
const category = String(options.category || 'all').toLowerCase();
|
|
37
|
+
if (!['coding', 'general', 'all'].includes(category)) {
|
|
38
|
+
console.error("error: invalid --category. Use 'coding', 'general', or 'all'.");
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const models = await loadModels();
|
|
44
|
+
const filtered = filterByCategory(models, category);
|
|
45
|
+
|
|
46
|
+
if (options.json) {
|
|
47
|
+
console.log(JSON.stringify(filtered, null, 2));
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.log(formatSectionHeader('Model Catalog'));
|
|
52
|
+
console.log(formatModelsTable(filtered));
|
|
53
|
+
process.exit(0);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
const message = error instanceof Error ? error.message : 'Unknown models error';
|
|
56
|
+
console.error(`error: ${message}`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import ora from 'ora';
|
|
5
|
+
import { scanSystemWithScore } from './scan.js';
|
|
6
|
+
import { rankModelsForSystem } from '../services/recommender.js';
|
|
7
|
+
import {
|
|
8
|
+
formatRecommendationBuckets,
|
|
9
|
+
formatSectionHeader
|
|
10
|
+
} from '../utils/formatter.js';
|
|
11
|
+
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
13
|
+
const __dirname = path.dirname(__filename);
|
|
14
|
+
const MODELS_PATH = path.join(__dirname, '..', 'data', 'models.json');
|
|
15
|
+
|
|
16
|
+
async function loadModels() {
|
|
17
|
+
const raw = await fs.readFile(MODELS_PATH, 'utf8');
|
|
18
|
+
const parsed = JSON.parse(raw.replace(/^\uFEFF/, ''));
|
|
19
|
+
|
|
20
|
+
if (!Array.isArray(parsed)) {
|
|
21
|
+
throw new Error('Model database is invalid: expected an array');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return parsed;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function registerRecommendCommand(program) {
|
|
28
|
+
program
|
|
29
|
+
.command('recommend')
|
|
30
|
+
.description('Recommend models based on your hardware')
|
|
31
|
+
.option('--category <category>', 'coding|general|all', 'all')
|
|
32
|
+
.action(async (options) => {
|
|
33
|
+
const category = String(options.category || 'all').toLowerCase();
|
|
34
|
+
if (!['coding', 'general', 'all'].includes(category)) {
|
|
35
|
+
console.error("error: invalid --category. Use 'coding', 'general', or 'all'.");
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const spinner = ora('Scanning your system hardware...').start();
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const [{ system, score }, models] = await Promise.all([
|
|
43
|
+
scanSystemWithScore(),
|
|
44
|
+
loadModels()
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
spinner.succeed('Hardware scan complete');
|
|
48
|
+
|
|
49
|
+
const buckets = rankModelsForSystem(system, models, category);
|
|
50
|
+
console.log(formatSectionHeader('Model Recommendations'));
|
|
51
|
+
console.log(`Readiness score: ${score.score}/100 (${score.grade} - ${score.label})`);
|
|
52
|
+
console.log(formatRecommendationBuckets(buckets));
|
|
53
|
+
process.exit(0);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
spinner.fail('Recommendation failed');
|
|
56
|
+
const message =
|
|
57
|
+
error instanceof Error ? error.message : 'Unknown recommendation error';
|
|
58
|
+
console.error(`error: ${message}`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
package/commands/scan.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import ora from 'ora';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { scanSystemHardware } from '../services/systemScanner.js';
|
|
4
|
+
import { formatSectionHeader, formatSystemBox } from '../utils/formatter.js';
|
|
5
|
+
import { computeScore } from '../utils/scoreEngine.js';
|
|
6
|
+
|
|
7
|
+
const BAR_WIDTH = 30;
|
|
8
|
+
|
|
9
|
+
function getScoreColor(score) {
|
|
10
|
+
if (score >= 70) {
|
|
11
|
+
return chalk.green;
|
|
12
|
+
}
|
|
13
|
+
if (score >= 40) {
|
|
14
|
+
return chalk.yellow;
|
|
15
|
+
}
|
|
16
|
+
return chalk.red;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function toBar(value, max) {
|
|
20
|
+
const ratio = max <= 0 ? 0 : value / max;
|
|
21
|
+
const filledCount = Math.max(0, Math.min(BAR_WIDTH, Math.round(ratio * BAR_WIDTH)));
|
|
22
|
+
return `${'█'.repeat(filledCount)}${'░'.repeat(BAR_WIDTH - filledCount)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatVerdictLines(verdict) {
|
|
26
|
+
const [firstSentence, ...rest] = String(verdict).split('. ').filter(Boolean);
|
|
27
|
+
if (rest.length === 0) {
|
|
28
|
+
return [firstSentence.endsWith('.') ? firstSentence : `${firstSentence}.`];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const first = firstSentence.endsWith('.') ? firstSentence : `${firstSentence}.`;
|
|
32
|
+
const second = rest.join('. ');
|
|
33
|
+
return [first, second.endsWith('.') ? second : `${second}.`];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatReadinessSection(scoreResult) {
|
|
37
|
+
const colorize = getScoreColor(scoreResult.score);
|
|
38
|
+
const headlineBar = toBar(scoreResult.score, 100);
|
|
39
|
+
const verdictLines = formatVerdictLines(scoreResult.verdict);
|
|
40
|
+
const lines = [
|
|
41
|
+
formatSectionHeader('LLM Readiness Score'),
|
|
42
|
+
'',
|
|
43
|
+
` ${colorize(headlineBar)} ${scoreResult.score} / 100`,
|
|
44
|
+
'',
|
|
45
|
+
` Grade : ${scoreResult.grade} - ${scoreResult.label}`,
|
|
46
|
+
` Verdict : ${verdictLines[0]}`
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
if (verdictLines[1]) {
|
|
50
|
+
lines.push(` ${verdictLines[1]}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
lines.push('', ' Breakdown:');
|
|
54
|
+
|
|
55
|
+
const breakdownRows = [
|
|
56
|
+
['RAM', scoreResult.breakdown.ram, 40],
|
|
57
|
+
['CPU Cores', scoreResult.breakdown.cpu, 25],
|
|
58
|
+
['GPU', scoreResult.breakdown.gpu, 25],
|
|
59
|
+
['VRAM', scoreResult.breakdown.vram, 10]
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
for (const [label, value, max] of breakdownRows) {
|
|
63
|
+
const paddedLabel = String(label).padEnd(11, ' ');
|
|
64
|
+
lines.push(` ${paddedLabel} ${toBar(value, max)} ${value} / ${max}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return lines.join('\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function scanSystemWithScore() {
|
|
71
|
+
const system = await scanSystemHardware();
|
|
72
|
+
const score = computeScore(system);
|
|
73
|
+
return { system, score };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function registerScanCommand(program) {
|
|
77
|
+
program
|
|
78
|
+
.command('scan')
|
|
79
|
+
.description('Scan and print system hardware information')
|
|
80
|
+
.action(async () => {
|
|
81
|
+
const spinner = ora('Scanning your system hardware...').start();
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const { system, score } = await scanSystemWithScore();
|
|
85
|
+
spinner.succeed('Hardware scan complete');
|
|
86
|
+
console.log(formatSectionHeader('System Info'));
|
|
87
|
+
console.log(formatSystemBox(system));
|
|
88
|
+
console.log(formatReadinessSection(score));
|
|
89
|
+
process.exit(0);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
spinner.fail('Hardware scan failed');
|
|
92
|
+
const message = error instanceof Error ? error.message : 'Unknown scan error';
|
|
93
|
+
console.error(`error: ${message}`);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import ora from 'ora';
|
|
5
|
+
import {
|
|
6
|
+
fetchTrendingGgufModels,
|
|
7
|
+
mergeModelsWithLocalDb,
|
|
8
|
+
writeJsonAtomic
|
|
9
|
+
} from '../services/huggingface.js';
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = path.dirname(__filename);
|
|
13
|
+
const MODELS_PATH = path.join(__dirname, '..', 'data', 'models.json');
|
|
14
|
+
|
|
15
|
+
async function readLocalModels() {
|
|
16
|
+
const raw = await fs.readFile(MODELS_PATH, 'utf8');
|
|
17
|
+
const parsed = JSON.parse(raw.replace(/^\uFEFF/, ''));
|
|
18
|
+
|
|
19
|
+
if (!Array.isArray(parsed)) {
|
|
20
|
+
throw new Error('Model database is invalid: expected an array');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return parsed;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function registerUpdateModelsCommand(program) {
|
|
27
|
+
program
|
|
28
|
+
.command('update-models')
|
|
29
|
+
.description('Sync trending GGUF models from Hugging Face')
|
|
30
|
+
.action(async () => {
|
|
31
|
+
const spinner = ora('Syncing models from Hugging Face...').start();
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const localModels = await readLocalModels();
|
|
35
|
+
const fetched = await fetchTrendingGgufModels();
|
|
36
|
+
const { merged, addedCount } = mergeModelsWithLocalDb(localModels, fetched);
|
|
37
|
+
|
|
38
|
+
await writeJsonAtomic(MODELS_PATH, merged);
|
|
39
|
+
|
|
40
|
+
spinner.succeed('Model database updated');
|
|
41
|
+
console.log(
|
|
42
|
+
`Added ${addedCount} new models. Database now has ${merged.length} models.`
|
|
43
|
+
);
|
|
44
|
+
process.exit(0);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
spinner.fail('Model sync failed');
|
|
47
|
+
const message = error instanceof Error ? error.message : 'Unknown sync error';
|
|
48
|
+
console.error(`error: ${message}`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|