Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/cli/models-command.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { getConfig } from "../config/index.js";
import {
runLocalModelsAdd,
runLocalModelsDevices,
runLocalModelsList,
runLocalModelsListEmbeddings,
runLocalModelsPull,
runLocalModelsPullEmbedding,
runLocalModelsRemove,
runLocalModelsSearch,
runLocalModelsStart,
runLocalModelsStatus,
runLocalModelsStop,
Expand All @@ -32,6 +34,16 @@ const HELP =
" (stops daemon first; does not auto-restart)",
" remove <id> Delete a downloaded model (refuses if active + daemon running)",
"",
"Hugging Face (any GGUF, not just the curated catalog):",
" search <query> Search GGUF repos on Hugging Face",
" add <ref> Register a repo/file as a custom model, then `pull` it",
" Accepts repo URLs, /resolve/ or /blob/ file URLs,",
" hf://owner/repo[@rev]/file.gguf, a pasted",
" `hf download …` command, and bare owner/name ids.",
" Picks a 4-bit quant and any mmproj projector",
" automatically when you name only a repo.",
" Export HF_TOKEN for gated or private repos.",
"",
"GPU subcommands:",
" devices List GPU devices (llama-server --list-devices); active marked with *",
" use-device <auto|cpu|Vulkan0> Set the managed daemon's GPU (auto-picks best discrete by default)",
Expand All @@ -45,6 +57,8 @@ const HELP =
"",
"Examples:",
" atomic-agent models list",
" atomic-agent models search qwen3 coder",
" atomic-agent models add https://huggingface.co/unsloth/Qwen3.5-4B-GGUF",
" atomic-agent models pull qwen-3.5-4b",
" atomic-agent models use qwen-3.5-4b",
" atomic-agent models pull-embedding nomic-embed-text-v1.5",
Expand Down Expand Up @@ -79,6 +93,10 @@ export async function modelsCommand(args: string[]): Promise<number> {
return runLocalModelsUpdate();
case "remove":
return runLocalModelsRemove(args[1]);
case "add":
return runLocalModelsAdd(args[1]);
case "search":
return runLocalModelsSearch(args.slice(1));
case "devices":
return runLocalModelsDevices();
case "use-device":
Expand Down
84 changes: 78 additions & 6 deletions src/cli/models-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { join } from "node:path";
import { getConfig, resetConfigCache } from "../config/index.js";
import { ensureUserConfigFileSync, writeUserConfigFileSync } from "../config/config-file.js";
import {
addCustomModel,
removeCustomModel,
} from "../config/custom-models-store.js";
import type { UserConfigFile } from "../config/config-schema.js";
import {
checkForBackendUpdate,
Expand All @@ -18,10 +22,12 @@ import {
isKnownLocalModelId,
isMmprojDownloaded,
isModelDownloaded,
listLocalModels,
listVulkanDevices,
LOCAL_MODELS_CATALOG,
readBackendVersion,
removeModel,
resolveCustomModelFromHuggingFace,
searchHuggingFaceGgufModels,
resolveChatTemplatePath,
resolveManagedDevice,
resolveMmprojFilePath,
Expand Down Expand Up @@ -63,7 +69,7 @@ export async function runLocalModelsList(): Promise<number> {
process.stdout.write(
"ID | FAMILY | SIZE | CONTEXT | DL | ACTIVE\n",
);
for (const m of LOCAL_MODELS_CATALOG) {
for (const m of listLocalModels()) {
const dl = isModelDownloaded(dataDir, m) ? "yes" : "no";
const active =
cfg.localModels.managed.modelId === m.id && cfg.localModels.mode === "managed" ? "*" : " ";
Expand All @@ -77,7 +83,7 @@ export async function runLocalModelsList(): Promise<number> {
export async function runLocalModelsPull(idArg: string | undefined): Promise<number> {
if (!idArg || !isKnownLocalModelId(idArg)) {
process.stderr.write(
`unknown model id. Valid: ${LOCAL_MODELS_CATALOG.map((m) => m.id).join(", ")}\n`,
`unknown model id. Valid: ${listLocalModels().map((m) => m.id).join(", ")}\n`,
);
return 1;
}
Expand Down Expand Up @@ -118,7 +124,7 @@ export async function runLocalModelsPull(idArg: string | undefined): Promise<num
export async function runLocalModelsUse(idArg: string | undefined): Promise<number> {
if (!idArg || !isKnownLocalModelId(idArg)) {
process.stderr.write(
`unknown model id. Valid: ${LOCAL_MODELS_CATALOG.map((m) => m.id).join(", ")}\n`,
`unknown model id. Valid: ${listLocalModels().map((m) => m.id).join(", ")}\n`,
);
return 1;
}
Expand Down Expand Up @@ -582,7 +588,7 @@ export async function runLocalModelsUpdate(): Promise<number> {
export async function runLocalModelsRemove(idArg: string | undefined): Promise<number> {
if (!idArg || !isKnownLocalModelId(idArg)) {
process.stderr.write(
`unknown model id. Valid: ${LOCAL_MODELS_CATALOG.map((m) => m.id).join(", ")}\n`,
`unknown model id. Valid: ${listLocalModels().map((m) => m.id).join(", ")}\n`,
);
return 1;
}
Expand All @@ -601,6 +607,72 @@ export async function runLocalModelsRemove(idArg: string | undefined): Promise<n
}
}
await removeModel(dataDir, idArg);
process.stdout.write(`removed ${idArg}\n`);
// Custom entries are user data, not catalog: dropping the files without
// dropping the entry would leave a permanently "not downloaded" row.
const forgotten = idArg.startsWith("custom-") && removeCustomModel(idArg);
process.stdout.write(
`removed ${idArg}${forgotten ? " (and its custom-model entry)" : ""}\n`,
);
return 0;
}

/**
* `models add <url|owner/name>` — register a GGUF from any Hugging Face
* repo as a `custom-…` catalog entry. Does not download: the user chains
* `models pull <id>` (or Enter in the TUI) exactly as for curated models.
*/
export async function runLocalModelsAdd(refArg: string | undefined): Promise<number> {
if (!refArg) {
process.stderr.write(
"usage: atomic-agent models add <huggingface-url | owner/name>\n" +
" e.g. atomic-agent models add https://huggingface.co/unsloth/Qwen3.5-4B-GGUF\n",
);
return 1;
}
try {
const def = await resolveCustomModelFromHuggingFace(refArg);
addCustomModel(def);
process.stdout.write(
`added ${def.id}\n` +
` file: ${def.filename} (${def.sizeLabel})\n` +
` source: ${def.huggingFaceUrl}\n` +
(def.supportsVision ? ` mmproj: ${def.mmprojFilename}\n` : "") +
`\nnext: atomic-agent models pull ${def.id} && atomic-agent models use ${def.id}\n`,
);
return 0;
} catch (e) {
process.stderr.write(`${e instanceof Error ? e.message : String(e)}\n`);
return 1;
}
}

/** `models search <query>` — GGUF repos on Hugging Face, most downloaded first. */
export async function runLocalModelsSearch(
args: readonly string[],
): Promise<number> {
const query = args.join(" ").trim();
if (query.length === 0) {
process.stderr.write("usage: atomic-agent models search <query>\n");
return 1;
}
try {
const hits = await searchHuggingFaceGgufModels(query);
if (hits.length === 0) {
process.stdout.write(`no GGUF repos matched ${JSON.stringify(query)}\n`);
return 0;
}
process.stdout.write("REPO | DOWNLOADS | LIKES\n");
for (const hit of hits) {
process.stdout.write(
`${hit.repoId.padEnd(50)} | ${String(hit.downloads).padStart(9)} | ${hit.likes}\n`,
);
}
process.stdout.write(
`\nadd one with: atomic-agent models add ${hits[0]!.repoId}\n`,
);
return 0;
} catch (e) {
process.stderr.write(`${e instanceof Error ? e.message : String(e)}\n`);
return 1;
}
}
Loading