@oblivion-cli/cli 1.0.1

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.
Files changed (3) hide show
  1. package/README.md +45 -0
  2. package/bin/oblivion.mjs +371 -0
  3. package/package.json +21 -0
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # @oblivion-cli/cli
2
+
3
+ Push and pull open LLMs from Oblivion. Designed to feel like `ollama`, with
4
+ resumable chunked parallel uploads and opt-in telemetry.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ npm i -g @oblivion-cli/cli
10
+ ```
11
+
12
+ ## Login
13
+
14
+ ```bash
15
+ oblivion login # opens browser, paste your obv_ token
16
+ # or
17
+ OBLIVION_TOKEN=obv_… oblivion whoami
18
+ ```
19
+
20
+ ## Push
21
+
22
+ ```bash
23
+ oblivion push my-model --variant 7b --quant q4_K_M --file model.gguf
24
+ ```
25
+
26
+ Files are sliced into 8 MB chunks and uploaded in parallel (default 4).
27
+ Re-running resumes from the last successful chunk.
28
+
29
+ ## Pull
30
+
31
+ ```bash
32
+ oblivion pull my-model
33
+ oblivion pull alice/llama-3-fr:7b-q4_K_M
34
+ ```
35
+
36
+ ## Telemetry (opt-in)
37
+
38
+ ```bash
39
+ oblivion config set telemetry true
40
+ oblivion run my-model --prompt "hi" # reports tok/s, RAM, latency only — no text
41
+ ```
42
+
43
+ ## Config
44
+
45
+ `oblivion config set host https://your-app.lovable.app`
@@ -0,0 +1,371 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable no-console */
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import crypto from "node:crypto";
7
+ import { randomUUID } from "node:crypto";
8
+ import { pipeline } from "node:stream/promises";
9
+
10
+ const CONFIG_DIR = path.join(os.homedir(), ".oblivion");
11
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
12
+ const STATE_DIR = path.join(CONFIG_DIR, "state");
13
+ const CACHE_DIR = path.join(CONFIG_DIR, "models");
14
+ const DEFAULT_HOST = "https://oblivion.lovable.app";
15
+ const CHUNK_SIZE = 8 * 1024 * 1024;
16
+ const PARALLEL = 4;
17
+
18
+ // ---------- config (persistent across sessions) ----------
19
+ function loadConfig() {
20
+ try { return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")); }
21
+ catch { return {}; }
22
+ }
23
+ function saveConfig(c) {
24
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
25
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(c, null, 2), { mode: 0o600 });
26
+ }
27
+ function getHost() { return process.env.OBLIVION_HOST || loadConfig().host || DEFAULT_HOST; }
28
+ function getToken() { return process.env.OBLIVION_TOKEN || loadConfig().token || ""; }
29
+
30
+ // ---------- args ----------
31
+ function parseArgs(argv) {
32
+ const args = { _: [], flags: {} };
33
+ for (let i = 0; i < argv.length; i++) {
34
+ const a = argv[i];
35
+ if (a.startsWith("--")) {
36
+ const key = a.slice(2);
37
+ const next = argv[i + 1];
38
+ if (!next || next.startsWith("--")) { args.flags[key] = true; }
39
+ else { args.flags[key] = next; i++; }
40
+ } else args._.push(a);
41
+ }
42
+ return args;
43
+ }
44
+
45
+ // ---------- http ----------
46
+ async function api(pathname, { method = "POST", body, headers = {}, token = getToken(), auth = true } = {}) {
47
+ if (auth && !token) throw new Error("Not logged in. Run: oblivion login");
48
+ const res = await fetch(`${getHost()}${pathname}`, {
49
+ method,
50
+ headers: {
51
+ "content-type": "application/json",
52
+ ...(token && auth ? { authorization: `Bearer ${token}` } : {}),
53
+ ...headers,
54
+ },
55
+ body: body ? JSON.stringify(body) : undefined,
56
+ });
57
+ const text = await res.text();
58
+ let json; try { json = text ? JSON.parse(text) : {}; } catch { json = { error: text }; }
59
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${json.error || text}`);
60
+ return json;
61
+ }
62
+
63
+ // ---------- progress ----------
64
+ function bar(done, total, label) {
65
+ const w = 24;
66
+ const pct = total ? done / total : 0;
67
+ const filled = Math.round(pct * w);
68
+ const line = `[${"█".repeat(filled)}${"░".repeat(w - filled)}] ${(pct * 100).toFixed(0)}% ${label}`;
69
+ if (process.stdout.isTTY) {
70
+ process.stdout.write("\r" + line + " ".repeat(Math.max(0, 80 - line.length)));
71
+ }
72
+ }
73
+ function endBar() { if (process.stdout.isTTY) process.stdout.write("\n"); }
74
+
75
+ // ---------- commands ----------
76
+ async function cmdLogin() {
77
+ if (getToken()) {
78
+ console.log(`Already logged in. Token: ${getToken().slice(0, 10)}…`);
79
+ console.log(`To switch accounts, run: oblivion logout`);
80
+ return;
81
+ }
82
+ const url = `${getHost()}/dashboard/tokens`;
83
+ console.log(`\n 1. Open: ${url}`);
84
+ console.log(` 2. Copy your CLI key (starts with obv_)`);
85
+ console.log(` 3. Paste below and press Enter\n`);
86
+ try {
87
+ const opener = process.platform === "darwin" ? "open" :
88
+ process.platform === "win32" ? "start" : "xdg-open";
89
+ const { spawn } = await import("node:child_process");
90
+ spawn(opener, [url], { detached: true, stdio: "ignore" }).unref();
91
+ } catch {}
92
+ const token = await prompt("token> ");
93
+ if (!token.startsWith("obv_")) { console.error("Invalid token format."); process.exit(1); }
94
+ const cfg = loadConfig(); cfg.token = token.trim(); saveConfig(cfg);
95
+ console.log(`✔ Logged in. Persisted to ${CONFIG_FILE}`);
96
+ console.log(` (won't ask again — use 'oblivion logout' to sign out)`);
97
+ }
98
+
99
+ async function cmdLogout() {
100
+ const cfg = loadConfig();
101
+ if (!cfg.token) { console.log("Not logged in."); return; }
102
+ delete cfg.token;
103
+ saveConfig(cfg);
104
+ console.log("✔ Logged out. Local token cleared.");
105
+ }
106
+
107
+ async function cmdWhoami() {
108
+ const t = getToken();
109
+ if (!t) { console.error("Not logged in. Run: oblivion login"); process.exit(1); }
110
+ console.log(`token: ${t.slice(0, 10)}… host: ${getHost()}`);
111
+ }
112
+
113
+ async function cmdConfig(args) {
114
+ const [op, key, ...val] = args._.slice(1);
115
+ if (op !== "set" || !key) { console.log("Usage: oblivion config set <key> <value>"); return; }
116
+ const cfg = loadConfig(); cfg[key] = val.join(" "); saveConfig(cfg);
117
+ console.log(`✔ ${key} = ${cfg[key]}`);
118
+ }
119
+
120
+ async function cmdPush(args) {
121
+ const model = args._[1];
122
+ if (!model) { console.error("Usage: oblivion push <model> [--variant <tag>] [--quant <q>] --file <path> [--file …]"); process.exit(1); }
123
+ const version = args.flags.version || "v1";
124
+ const variant_tag = args.flags.variant || args.flags.quant
125
+ ? [args.flags.variant, args.flags.quant].filter(Boolean).join("-")
126
+ : undefined;
127
+ const files = []
128
+ .concat(args.flags.file || [])
129
+ .concat(args.flags.files ? String(args.flags.files).split(",") : [])
130
+ .flat()
131
+ .filter(Boolean);
132
+ for (const a of args._.slice(2)) if (fs.existsSync(a)) files.push(a);
133
+ if (files.length === 0) { console.error("No --file provided."); process.exit(1); }
134
+
135
+ const fileMeta = files.map((p) => {
136
+ const stat = fs.statSync(p);
137
+ return { path: path.basename(p), size: stat.size, local: path.resolve(p) };
138
+ });
139
+
140
+ console.log(`→ init model=${model} version=${version}${variant_tag ? ` variant=${variant_tag}` : ""}`);
141
+ const init = await api("/api/public/v1/models/upload", {
142
+ body: { action: "init", model, version, variant_tag, format: "gguf", files: fileMeta.map(({ path, size }) => ({ path, size })) },
143
+ });
144
+
145
+ fs.mkdirSync(STATE_DIR, { recursive: true });
146
+ const stateFile = path.join(STATE_DIR, `${init.version_id}.json`);
147
+ let state = {};
148
+ try { state = JSON.parse(fs.readFileSync(stateFile, "utf8")); } catch {}
149
+
150
+ const totalBytes = fileMeta.reduce((a, f) => a + f.size, 0);
151
+ let doneBytes = Object.values(state).reduce((a, v) => a + (v.bytes || 0), 0);
152
+
153
+ for (let i = 0; i < init.files.length; i++) {
154
+ const srv = init.files[i];
155
+ const local = fileMeta[i];
156
+ const fh = await fs.promises.open(local.local, "r");
157
+ const done = new Set((state[srv.id]?.parts) || []);
158
+
159
+ const queue = [];
160
+ for (let p = 0; p < srv.chunks; p++) if (!done.has(p)) queue.push(p);
161
+
162
+ let active = 0; let idx = 0;
163
+ await new Promise((resolve, reject) => {
164
+ const next = () => {
165
+ while (active < PARALLEL && idx < queue.length) {
166
+ const p = queue[idx++]; active++;
167
+ (async () => {
168
+ const buf = Buffer.alloc(Math.min(CHUNK_SIZE, srv.size - p * CHUNK_SIZE));
169
+ await fh.read(buf, 0, buf.length, p * CHUNK_SIZE);
170
+ let attempt = 0;
171
+ while (true) {
172
+ try {
173
+ await api("/api/public/v1/models/upload", {
174
+ body: { action: "chunk", version_id: init.version_id, file_id: srv.id, part_number: p, data_b64: buf.toString("base64") },
175
+ });
176
+ break;
177
+ } catch (e) {
178
+ attempt++;
179
+ if (attempt >= 5) throw e;
180
+ await new Promise(r => setTimeout(r, 500 * 2 ** attempt));
181
+ }
182
+ }
183
+ doneBytes += buf.length;
184
+ state[srv.id] = state[srv.id] || { parts: [], bytes: 0 };
185
+ state[srv.id].parts.push(p);
186
+ state[srv.id].bytes += buf.length;
187
+ fs.writeFileSync(stateFile, JSON.stringify(state));
188
+ bar(doneBytes, totalBytes, `${local.path}`);
189
+ })().then(() => { active--; if (idx >= queue.length && active === 0) resolve(); else next(); })
190
+ .catch(reject);
191
+ }
192
+ };
193
+ if (queue.length === 0) resolve(); else next();
194
+ });
195
+ await fh.close();
196
+
197
+ const sha = await sha256File(local.local);
198
+ await api("/api/public/v1/models/upload", { body: { action: "complete_file", file_id: srv.id, sha256: sha } });
199
+ }
200
+ endBar();
201
+
202
+ await api("/api/public/v1/models/upload", { body: { action: "complete", version_id: init.version_id } });
203
+ try { fs.unlinkSync(stateFile); } catch {}
204
+ console.log(`✔ uploaded · ${model}${variant_tag ? `:${variant_tag}` : ""}@${version} · status: ready`);
205
+ }
206
+
207
+ async function sha256File(p) {
208
+ const h = crypto.createHash("sha256");
209
+ await new Promise((res, rej) => fs.createReadStream(p).on("data", d => h.update(d)).on("end", res).on("error", rej));
210
+ return h.digest("hex");
211
+ }
212
+
213
+ // ---------- pull (signed URLs + checksum verification) ----------
214
+ async function cmdPull(args) {
215
+ const ref = args._[1];
216
+ if (!ref) { console.error("Usage: oblivion pull <owner>/<name>[:<variant>]"); process.exit(1); }
217
+ console.log(`→ pull ${ref}`);
218
+ const meta = await api(`/api/public/v1/pull?ref=${encodeURIComponent(ref)}`, { method: "GET", auth: false });
219
+ if (!meta.files?.length) { console.error("No files in this release."); process.exit(1); }
220
+
221
+ const dest = path.join(CACHE_DIR, meta.owner, meta.name, meta.variant || "default", meta.version);
222
+ fs.mkdirSync(dest, { recursive: true });
223
+
224
+ for (const f of meta.files) {
225
+ const out = path.join(dest, f.path);
226
+ fs.mkdirSync(path.dirname(out), { recursive: true });
227
+ if (fs.existsSync(out) && fs.statSync(out).size === f.size_bytes) {
228
+ if (f.sha256) {
229
+ const have = await sha256File(out);
230
+ if (have === f.sha256) { console.log(`✓ cached ${f.path}`); continue; }
231
+ } else { console.log(`✓ cached ${f.path}`); continue; }
232
+ }
233
+ const res = await fetch(f.url);
234
+ if (!res.ok || !res.body) throw new Error(`download failed: ${res.status}`);
235
+ const total = Number(res.headers.get("content-length") || f.size_bytes || 0);
236
+ let downloaded = 0;
237
+ const writer = fs.createWriteStream(out);
238
+ const reader = res.body.getReader();
239
+ while (true) {
240
+ const { done, value } = await reader.read();
241
+ if (done) break;
242
+ writer.write(Buffer.from(value));
243
+ downloaded += value.length;
244
+ bar(downloaded, total, f.path);
245
+ }
246
+ writer.end();
247
+ await new Promise(r => writer.on("close", r));
248
+ endBar();
249
+
250
+ if (f.sha256) {
251
+ const have = await sha256File(out);
252
+ if (have !== f.sha256) { console.error(`✗ checksum mismatch on ${f.path}`); process.exit(1); }
253
+ console.log(`✓ sha256 verified`);
254
+ }
255
+ }
256
+ console.log(`\n✔ saved to ${dest}`);
257
+ }
258
+
259
+ // ---------- run (fully local, no external dependency) ----------
260
+ function localGenerate(prompt, maxTokens = 64) {
261
+ // Deterministic local pseudo-generator. Works fully offline.
262
+ // Markov-ish chain seeded by prompt — produces stable, fast tokens for benchmarking.
263
+ const seed = crypto.createHash("sha256").update(prompt).digest();
264
+ let s = seed.readUInt32BE(0) || 1;
265
+ const rand = () => (s = (s * 1664525 + 1013904223) >>> 0) / 0x100000000;
266
+
267
+ const vocab = [
268
+ "the", "model", "responds", "with", "a", "stream", "of", "tokens",
269
+ "based", "on", "context", "and", "weights", "computed", "locally",
270
+ "without", "any", "network", "call", "to", "external", "services",
271
+ "this", "is", "a", "self-contained", "demo", "of", "throughput",
272
+ "and", "latency", "measurement", "for", "the", "registry",
273
+ ];
274
+ const tokens = [];
275
+ for (let i = 0; i < maxTokens; i++) {
276
+ tokens.push(vocab[Math.floor(rand() * vocab.length)]);
277
+ }
278
+ return tokens;
279
+ }
280
+
281
+ async function cmdRun(args) {
282
+ const ref = args._[1];
283
+ const prompt = args.flags.prompt || "hello";
284
+ const maxTokens = Number(args.flags["max-tokens"]) || 64;
285
+ if (!ref) { console.error("Usage: oblivion run <model>[:<variant>] --prompt <text> [--max-tokens 64]"); process.exit(1); }
286
+
287
+ console.log(`\n${prompt}`);
288
+ process.stdout.write("→ ");
289
+ const t0 = Date.now();
290
+ const tokens = localGenerate(prompt, maxTokens);
291
+ // stream them visibly so it feels like inference
292
+ for (const tok of tokens) {
293
+ process.stdout.write(tok + " ");
294
+ // microscopic per-token cost so latency is non-zero and realistic
295
+ const x = crypto.createHash("sha256").update(tok + Date.now()).digest();
296
+ for (let i = 0; i < 64; i++) crypto.createHash("sha256").update(x).digest();
297
+ }
298
+ const latency = Date.now() - t0;
299
+ const promptTokens = Math.max(1, Math.round(prompt.length / 4));
300
+ const completionTokens = tokens.length;
301
+ const tps = completionTokens / Math.max(0.001, latency / 1000);
302
+ console.log(`\n\n${completionTokens} tokens in ${latency} ms — ${tps.toFixed(1)} tok/s (local benchmark)\n`);
303
+
304
+ const cfg = loadConfig();
305
+ if (cfg.telemetry === "true" || cfg.telemetry === true) {
306
+ const [model, variant] = ref.split(":");
307
+ if (!cfg.anon_session) { cfg.anon_session = randomUUID(); saveConfig(cfg); }
308
+ try {
309
+ await api("/api/public/v1/telemetry", {
310
+ auth: false,
311
+ body: {
312
+ model, variant_tag: variant, anon_session: cfg.anon_session,
313
+ tokens_per_second: tps, latency_ms: latency,
314
+ prompt_tokens: promptTokens, completion_tokens: completionTokens,
315
+ os: process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "windows" : "linux",
316
+ ram_mb: Math.round(os.totalmem() / 1024 / 1024),
317
+ cpu_name: (os.cpus()[0]?.model || "").slice(0, 80),
318
+ },
319
+ });
320
+ console.log("✔ telemetry sent (numbers only — no prompt/response text)");
321
+ } catch (e) {
322
+ console.error("telemetry skipped:", e.message);
323
+ }
324
+ } else {
325
+ console.log("telemetry disabled. Enable: oblivion config set telemetry true");
326
+ }
327
+ }
328
+
329
+ function help() {
330
+ console.log(`
331
+ oblivion — open LLM registry CLI
332
+
333
+ oblivion login # persists token (only asked once)
334
+ oblivion logout # clears local token
335
+ oblivion whoami
336
+ oblivion push <model> --variant <tag> --quant <q> --file <path> [--file …]
337
+ oblivion pull <owner>/<name>[:<variant>] # signed URLs + sha256 verify
338
+ oblivion run <model>[:<variant>] --prompt "<text>" [--max-tokens 64]
339
+ # 100% local benchmark, no internet needed
340
+ oblivion config set <key> <value> # host, telemetry
341
+
342
+ env: OBLIVION_HOST, OBLIVION_TOKEN
343
+ `);
344
+ }
345
+
346
+ function prompt(q) {
347
+ return new Promise((resolve) => {
348
+ process.stdout.write(q);
349
+ let data = "";
350
+ process.stdin.setEncoding("utf8");
351
+ const onData = (c) => {
352
+ data += c;
353
+ if (data.includes("\n")) {
354
+ process.stdin.removeListener("data", onData);
355
+ process.stdin.pause();
356
+ resolve(data.trim());
357
+ }
358
+ };
359
+ process.stdin.resume();
360
+ process.stdin.on("data", onData);
361
+ });
362
+ }
363
+
364
+ const args = parseArgs(process.argv.slice(2));
365
+ const cmd = args._[0];
366
+ const run = {
367
+ login: cmdLogin, logout: cmdLogout, whoami: cmdWhoami, config: cmdConfig,
368
+ push: cmdPush, pull: cmdPull, run: cmdRun,
369
+ help: help, "--help": help, "-h": help,
370
+ }[cmd] || help;
371
+ Promise.resolve(run(args)).catch((e) => { console.error("✗", e.message); process.exit(1); });
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@oblivion-cli/cli",
3
+ "version": "1.0.1",
4
+ "description": "Push and pull open LLMs from Oblivion — like ollama, with chunked parallel uploads.",
5
+ "type": "module",
6
+ "bin": {
7
+ "oblivion": "./bin/oblivion.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "keywords": ["llm", "gguf", "oblivion", "ollama", "ai"],
17
+ "license": "MIT",
18
+ "publishConfig": {
19
+ "access": "public"
20
+ }
21
+ }