#!/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 ") .description("Ensure model is cached locally (fetch if needed)") .option("--cache-dir ", "Cache directory") .option("--manifest ", "Manifest file path") .option("--ssh-key ", "SSH key file for remote access") .option("--timeout ", "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 ", "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 ", "Cache directory") .option("--manifest ", "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 ") .description("Remove a model from cache") .option("--cache-dir ", "Cache directory") .option("--manifest ", "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 ", "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 ") .description("Show detailed model information") .option("--manifest ", "Manifest file path") .option("--cache-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 ", "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 ", "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 ") .description("Scan directory and show discovered models") .option("--manifest ", "Manifest file path") .option("--category ", "Filter by category") .option("--format ", "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 ") .description("Scan directory and update manifest") .option("--manifest ", "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 ", "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 ' 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();