Add support for all ML model types beyond GGUF: - New discovery module for auto-scanning model directories - Detect formats: GGUF, safetensors, ONNX, PyTorch, diffusion pipelines - CLI commands: discover, scan, sync for manifest management - Manifest v2.0 with format field, directory support, file lists Python loaders (v2.0.0): - ONNXLoader with CUDA/TensorRT execution providers - WhisperLoader for faster-whisper with transcribe/stream - get_auto_loader() for automatic backend selection Breaking: Manifest schema upgraded to v2.0 (auto-migrates v1.x on load) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
553 lines
17 KiB
JavaScript
553 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* @lilith/ml-model-loader CLI
|
|
*
|
|
* Command-line interface for model loading and management.
|
|
* Used by Python wrapper via subprocess.
|
|
*/
|
|
|
|
import { program } from "commander";
|
|
import {
|
|
ModelLoader,
|
|
saveManifest,
|
|
createDefaultManifest,
|
|
isRsyncAvailable,
|
|
isScpAvailable,
|
|
testSSHConnection,
|
|
} from "../src/index.js";
|
|
import {
|
|
scanDirectory,
|
|
diffManifests,
|
|
applyDiff,
|
|
summarizeDiff,
|
|
} from "../src/discovery/index.js";
|
|
// Types are available from discovery module when needed
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
}
|
|
|
|
program
|
|
.name("model-loader")
|
|
.description("Lilith Model Loader - fetch and cache ML models")
|
|
.version("1.0.0");
|
|
|
|
// ensure - ensure a model is cached
|
|
program
|
|
.command("ensure <model-id>")
|
|
.description("Ensure model is cached locally (fetch if needed)")
|
|
.option("--cache-dir <dir>", "Cache directory")
|
|
.option("--manifest <path>", "Manifest file path")
|
|
.option("--ssh-key <path>", "SSH key file for remote access")
|
|
.option("--timeout <ms>", "Timeout for remote operations", parseInt)
|
|
.option("--json", "Output JSON (for programmatic use)")
|
|
.option("--verbose", "Enable verbose logging")
|
|
.action(async (modelId: string, options) => {
|
|
try {
|
|
const loader = new ModelLoader({
|
|
cacheDir: options.cacheDir,
|
|
manifest: options.manifest,
|
|
sshAuth: options.sshKey ? { keyPath: options.sshKey } : undefined,
|
|
timeout: options.timeout,
|
|
verbose: options.verbose && !options.json,
|
|
onProgress: options.verbose && !options.json
|
|
? (p) => {
|
|
process.stdout.write(
|
|
`\r[${p.percentComplete}%] ${formatBytes(p.bytesTransferred)} - ${p.speed} ETA ${p.eta}`
|
|
);
|
|
}
|
|
: undefined,
|
|
});
|
|
|
|
const result = await loader.ensureModel(modelId);
|
|
|
|
if (options.verbose && !options.json) {
|
|
process.stdout.write("\n");
|
|
}
|
|
|
|
if (options.json) {
|
|
console.log(JSON.stringify(result));
|
|
} else {
|
|
console.log(`Path: ${result.path}`);
|
|
console.log(`Source: ${result.source}${result.method ? ` (${result.method})` : ""}`);
|
|
console.log(`Duration: ${result.duration}ms`);
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
if (options.json) {
|
|
console.log(JSON.stringify({ error: message }));
|
|
process.exit(1);
|
|
} else {
|
|
console.error(`Error: ${message}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
});
|
|
|
|
// list - list models from manifest
|
|
program
|
|
.command("list")
|
|
.description("List available models from manifest")
|
|
.option("--manifest <path>", "Manifest file path")
|
|
.option("--json", "Output JSON")
|
|
.action((options) => {
|
|
try {
|
|
const loader = new ModelLoader({ manifest: options.manifest });
|
|
const models = loader.listModels();
|
|
|
|
if (options.json) {
|
|
const output = models.map(([id, entry]) => ({ id, ...entry }));
|
|
console.log(JSON.stringify(output));
|
|
} else {
|
|
if (models.length === 0) {
|
|
console.log("No models in manifest");
|
|
return;
|
|
}
|
|
console.log("Available models:");
|
|
for (const [id, entry] of models) {
|
|
const cached = loader.isCached(id) ? " [cached]" : "";
|
|
console.log(` ${id}: ${entry.name} (${formatBytes(entry.size)})${cached}`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
console.error(`Error: ${message}`);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
// cached - list cached models
|
|
program
|
|
.command("cached")
|
|
.description("List cached models")
|
|
.option("--cache-dir <dir>", "Cache directory")
|
|
.option("--manifest <path>", "Manifest file path")
|
|
.option("--json", "Output JSON")
|
|
.action((options) => {
|
|
try {
|
|
const loader = new ModelLoader({
|
|
cacheDir: options.cacheDir,
|
|
manifest: options.manifest,
|
|
});
|
|
const cached = loader.listCached();
|
|
|
|
if (options.json) {
|
|
console.log(JSON.stringify(cached));
|
|
} else {
|
|
if (cached.length === 0) {
|
|
console.log("No cached models");
|
|
return;
|
|
}
|
|
console.log("Cached models:");
|
|
for (const model of cached) {
|
|
const idStr = model.modelId ? ` (${model.modelId})` : "";
|
|
console.log(` ${model.name}${idStr}: ${formatBytes(model.size)}`);
|
|
}
|
|
|
|
const stats = loader.getCacheStats();
|
|
console.log(`\nTotal: ${formatBytes(stats.totalSize)} in ${stats.count} files`);
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
console.error(`Error: ${message}`);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
// remove - remove a model from cache
|
|
program
|
|
.command("remove <model-id>")
|
|
.description("Remove a model from cache")
|
|
.option("--cache-dir <dir>", "Cache directory")
|
|
.option("--manifest <path>", "Manifest file path")
|
|
.action((modelId: string, options) => {
|
|
try {
|
|
const loader = new ModelLoader({
|
|
cacheDir: options.cacheDir,
|
|
manifest: options.manifest,
|
|
verbose: true,
|
|
});
|
|
|
|
if (!loader.isCached(modelId)) {
|
|
console.log(`Model not cached: ${modelId}`);
|
|
return;
|
|
}
|
|
|
|
loader.removeFromCache(modelId);
|
|
console.log(`Removed: ${modelId}`);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
console.error(`Error: ${message}`);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
// clear - clear entire cache
|
|
program
|
|
.command("clear")
|
|
.description("Clear entire model cache")
|
|
.option("--cache-dir <dir>", "Cache directory")
|
|
.option("--force", "Skip confirmation")
|
|
.action(async (options) => {
|
|
try {
|
|
const loader = new ModelLoader({
|
|
cacheDir: options.cacheDir,
|
|
verbose: true,
|
|
});
|
|
|
|
const stats = loader.getCacheStats();
|
|
|
|
if (stats.count === 0) {
|
|
console.log("Cache is already empty");
|
|
return;
|
|
}
|
|
|
|
if (!options.force) {
|
|
console.log(`About to clear ${stats.count} models (${formatBytes(stats.totalSize)})`);
|
|
console.log("Use --force to confirm");
|
|
return;
|
|
}
|
|
|
|
loader.clearCache();
|
|
console.log(`Cleared ${stats.count} models`);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
console.error(`Error: ${message}`);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
// info - show model info
|
|
program
|
|
.command("info <model-id>")
|
|
.description("Show detailed model information")
|
|
.option("--manifest <path>", "Manifest file path")
|
|
.option("--cache-dir <dir>", "Cache directory")
|
|
.option("--json", "Output JSON")
|
|
.action((modelId: string, options) => {
|
|
try {
|
|
const loader = new ModelLoader({
|
|
cacheDir: options.cacheDir,
|
|
manifest: options.manifest,
|
|
});
|
|
|
|
const entry = loader.getModelEntry(modelId);
|
|
if (!entry) {
|
|
console.error(`Model not found in manifest: ${modelId}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const cached = loader.isCached(modelId);
|
|
const cachedPath = loader.getCachedPath(modelId);
|
|
|
|
if (options.json) {
|
|
console.log(JSON.stringify({ id: modelId, ...entry, cached, cachedPath }));
|
|
} else {
|
|
console.log(`ID: ${modelId}`);
|
|
console.log(`Name: ${entry.name}`);
|
|
console.log(`Path: ${entry.path}`);
|
|
console.log(`Size: ${formatBytes(entry.size)}`);
|
|
console.log(`Category: ${entry.category}`);
|
|
if (entry.type) console.log(`Type: ${entry.type}`);
|
|
if (entry.quantization) console.log(`Quantization: ${entry.quantization}`);
|
|
if (entry.contextSize) console.log(`Context Size: ${entry.contextSize}`);
|
|
if (entry.description) console.log(`Description: ${entry.description}`);
|
|
if (entry.source) console.log(`Source: ${entry.source}`);
|
|
console.log(`Cached: ${cached ? `Yes (${cachedPath})` : "No"}`);
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
console.error(`Error: ${message}`);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
// init - initialize manifest
|
|
program
|
|
.command("init")
|
|
.description("Initialize a new manifest file")
|
|
.option("--manifest <path>", "Manifest file path")
|
|
.option("--force", "Overwrite existing manifest")
|
|
.action((options) => {
|
|
try {
|
|
const path = options.manifest;
|
|
|
|
// Check if manifest already exists
|
|
const loader = new ModelLoader({ manifest: path });
|
|
const existing = loader.getManifest();
|
|
|
|
if (Object.keys(existing.models).length > 0 && !options.force) {
|
|
console.log("Manifest already exists with models. Use --force to overwrite.");
|
|
return;
|
|
}
|
|
|
|
const manifest = createDefaultManifest();
|
|
saveManifest(manifest, path);
|
|
console.log(`Created manifest at: ${loader.getManifestPath()}`);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
console.error(`Error: ${message}`);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
// status - check system status
|
|
program
|
|
.command("status")
|
|
.description("Check system status (tools, connectivity)")
|
|
.option("--manifest <path>", "Manifest file path")
|
|
.action(async (options) => {
|
|
try {
|
|
console.log("Checking system status...\n");
|
|
|
|
// Check tools
|
|
const rsync = await isRsyncAvailable();
|
|
const scp = await isScpAvailable();
|
|
console.log(`Tools:`);
|
|
console.log(` rsync: ${rsync ? "available" : "not found"}`);
|
|
console.log(` scp: ${scp ? "available" : "not found"}`);
|
|
|
|
// Check manifest
|
|
const loader = new ModelLoader({ manifest: options.manifest });
|
|
const manifest = loader.getManifest();
|
|
console.log(`\nManifest: ${loader.getManifestPath()}`);
|
|
console.log(` Models: ${Object.keys(manifest.models).length}`);
|
|
console.log(` Remote: ${manifest.remote.host} (${manifest.remote.method})`);
|
|
|
|
// Check SSH connectivity
|
|
console.log(`\nSSH Connectivity:`);
|
|
const sshOk = await testSSHConnection(manifest.remote.host);
|
|
console.log(` ${manifest.remote.host}: ${sshOk ? "connected" : "failed"}`);
|
|
|
|
// Check cache
|
|
const stats = loader.getCacheStats();
|
|
console.log(`\nCache: ${stats.cacheDir}`);
|
|
console.log(` Files: ${stats.count}`);
|
|
console.log(` Size: ${formatBytes(stats.totalSize)}`);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
console.error(`Error: ${message}`);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
// discover - scan directory and preview what would be added
|
|
program
|
|
.command("discover <directory>")
|
|
.description("Scan directory and show discovered models")
|
|
.option("--manifest <path>", "Manifest file path")
|
|
.option("--category <cat>", "Filter by category")
|
|
.option("--format <fmt>", "Filter by format")
|
|
.option("--json", "Output JSON")
|
|
.option("--verbose", "Show detailed information")
|
|
.action(async (directory: string, options) => {
|
|
try {
|
|
console.log(`Scanning ${directory}...\n`);
|
|
|
|
const result = await scanDirectory({
|
|
rootPath: directory,
|
|
onProgress: options.verbose
|
|
? (p) => {
|
|
process.stdout.write(
|
|
`\r[${p.phase}] ${p.current} items processed...`
|
|
);
|
|
}
|
|
: undefined,
|
|
});
|
|
|
|
if (options.verbose) {
|
|
process.stdout.write("\n\n");
|
|
}
|
|
|
|
// Apply filters
|
|
let models = result.models;
|
|
if (options.category) {
|
|
models = models.filter((m) => m.category === options.category);
|
|
}
|
|
if (options.format) {
|
|
models = models.filter((m) => m.format === options.format);
|
|
}
|
|
|
|
if (options.json) {
|
|
console.log(JSON.stringify({ models, stats: result.stats }, null, 2));
|
|
return;
|
|
}
|
|
|
|
// Print summary by category
|
|
console.log("Found models by category:");
|
|
for (const [cat, count] of Object.entries(result.stats.byCategory)) {
|
|
console.log(` ${cat}: ${count}`);
|
|
}
|
|
|
|
console.log("\nFound models by format:");
|
|
for (const [fmt, count] of Object.entries(result.stats.byFormat)) {
|
|
console.log(` ${fmt}: ${count}`);
|
|
}
|
|
|
|
console.log(`\nTotal: ${models.length} models (${formatBytes(result.stats.totalSize)})`);
|
|
console.log(`Scan time: ${result.stats.duration}ms`);
|
|
|
|
if (result.errors.length > 0) {
|
|
console.log(`\nWarnings: ${result.errors.length} errors encountered`);
|
|
if (options.verbose) {
|
|
for (const err of result.errors.slice(0, 10)) {
|
|
console.log(` ${err.path}: ${err.error}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compare with manifest
|
|
const loader = new ModelLoader({ manifest: options.manifest });
|
|
const manifest = loader.getManifest();
|
|
const diff = diffManifests(models, manifest);
|
|
|
|
console.log(`\nManifest comparison:`);
|
|
console.log(` New models: ${diff.added.length}`);
|
|
console.log(` Updated: ${diff.updated.length}`);
|
|
console.log(` Unchanged: ${diff.unchanged.length}`);
|
|
|
|
if (diff.added.length > 0) {
|
|
console.log(`\nUse 'model-loader scan ${directory}' to update manifest.`);
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
console.error(`Error: ${message}`);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
// scan - scan and update manifest
|
|
program
|
|
.command("scan <directory>")
|
|
.description("Scan directory and update manifest")
|
|
.option("--manifest <path>", "Manifest file path")
|
|
.option("--dry-run", "Show changes without applying")
|
|
.option("--replace", "Replace existing entries instead of merging")
|
|
.option("--prune", "Remove entries not found on disk")
|
|
.option("--json", "Output changes as JSON")
|
|
.action(async (directory: string, options) => {
|
|
try {
|
|
console.log(`Scanning ${directory}...`);
|
|
|
|
const result = await scanDirectory({
|
|
rootPath: directory,
|
|
onProgress: (p) => {
|
|
process.stdout.write(`\r[${p.phase}] ${p.current} items...`);
|
|
},
|
|
});
|
|
process.stdout.write("\n");
|
|
|
|
const loader = new ModelLoader({ manifest: options.manifest });
|
|
const manifest = loader.getManifest();
|
|
|
|
const diff = diffManifests(result.models, manifest, {
|
|
prune: options.prune,
|
|
});
|
|
|
|
if (options.json) {
|
|
console.log(JSON.stringify(diff, null, 2));
|
|
if (!options.dryRun) {
|
|
const updated = applyDiff(manifest, diff, { replace: options.replace });
|
|
saveManifest(updated, options.manifest);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Print diff summary
|
|
console.log(summarizeDiff(diff));
|
|
|
|
if (options.dryRun) {
|
|
console.log("\n(Dry run - no changes applied)");
|
|
return;
|
|
}
|
|
|
|
// Apply changes
|
|
const hasChanges =
|
|
diff.added.length > 0 ||
|
|
diff.updated.length > 0 ||
|
|
diff.removed.length > 0;
|
|
|
|
if (!hasChanges) {
|
|
console.log("\nNo changes to apply.");
|
|
return;
|
|
}
|
|
|
|
const updated = applyDiff(manifest, diff, { replace: options.replace });
|
|
|
|
// Update lastScan metadata
|
|
updated.lastScan = {
|
|
rootPath: directory,
|
|
timestamp: new Date().toISOString(),
|
|
totalModels: Object.keys(updated.models).length,
|
|
};
|
|
|
|
saveManifest(updated, options.manifest);
|
|
console.log(`\nManifest updated with ${Object.keys(updated.models).length} models.`);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
console.error(`Error: ${message}`);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
// sync - incremental update
|
|
program
|
|
.command("sync")
|
|
.description("Incrementally sync manifest with model directory")
|
|
.option("--manifest <path>", "Manifest file path")
|
|
.option("--quick", "Skip size verification")
|
|
.action(async (options) => {
|
|
try {
|
|
const loader = new ModelLoader({ manifest: options.manifest });
|
|
const manifest = loader.getManifest();
|
|
|
|
if (!manifest.lastScan?.rootPath) {
|
|
console.error("No previous scan found. Run 'model-loader scan <directory>' first.");
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`Syncing from ${manifest.lastScan.rootPath}...`);
|
|
|
|
// Re-scan the directory
|
|
const result = await scanDirectory({
|
|
rootPath: manifest.lastScan.rootPath,
|
|
onProgress: (p) => {
|
|
process.stdout.write(`\r[${p.phase}] ${p.current} items...`);
|
|
},
|
|
});
|
|
process.stdout.write("\n");
|
|
|
|
const diff = diffManifests(result.models, manifest, { prune: true });
|
|
|
|
const hasChanges =
|
|
diff.added.length > 0 ||
|
|
diff.updated.length > 0 ||
|
|
diff.removed.length > 0;
|
|
|
|
if (!hasChanges) {
|
|
console.log("Manifest is up to date.");
|
|
return;
|
|
}
|
|
|
|
console.log(summarizeDiff(diff));
|
|
|
|
const updated = applyDiff(manifest, diff);
|
|
updated.lastScan = {
|
|
rootPath: manifest.lastScan.rootPath,
|
|
timestamp: new Date().toISOString(),
|
|
totalModels: Object.keys(updated.models).length,
|
|
};
|
|
|
|
saveManifest(updated, options.manifest);
|
|
console.log(`\nManifest synced: ${Object.keys(updated.models).length} models.`);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
console.error(`Error: ${message}`);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
program.parse();
|