@whatcanirun/cli 0.1.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.
@@ -0,0 +1,40 @@
1
+ // -----------------------------------------------------------------------------
2
+ // Types
3
+ // -----------------------------------------------------------------------------
4
+
5
+ export interface RuntimeInfo {
6
+ name: string;
7
+ version: string;
8
+ build_flags?: string;
9
+ }
10
+
11
+ export interface BenchTrial {
12
+ promptTps: number;
13
+ generationTps: number;
14
+ peakMemoryGb: number;
15
+ }
16
+
17
+ export interface BenchResult {
18
+ promptTokens: number;
19
+ completionTokens: number;
20
+ trials: BenchTrial[];
21
+ averages: {
22
+ promptTps: number;
23
+ generationTps: number;
24
+ peakMemoryGb: number;
25
+ };
26
+ }
27
+
28
+ export interface BenchOpts {
29
+ model: string;
30
+ promptTokens: number;
31
+ genTokens: number;
32
+ numTrials: number;
33
+ onProgress?: (msg: string) => void;
34
+ }
35
+
36
+ export interface RuntimeAdapter {
37
+ name: string;
38
+ detect(): Promise<RuntimeInfo | null>;
39
+ benchmark(opts: BenchOpts): Promise<BenchResult>;
40
+ }
@@ -0,0 +1,56 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ import { getToken } from '../auth/token';
4
+
5
+ // -----------------------------------------------------------------------------
6
+ // Types
7
+ // -----------------------------------------------------------------------------
8
+
9
+ export interface UploadResult {
10
+ run_id: string;
11
+ status: string;
12
+ run_url: string;
13
+ }
14
+
15
+ // -----------------------------------------------------------------------------
16
+ // Constants
17
+ // -----------------------------------------------------------------------------
18
+
19
+ const API_BASE = process.env.WCIR_API_URL || 'https://whatcani.run';
20
+
21
+ // -----------------------------------------------------------------------------
22
+ // Functions
23
+ // -----------------------------------------------------------------------------
24
+
25
+ export async function uploadBundle(bundlePath: string): Promise<UploadResult> {
26
+ // 1. Read the bundle zip and compute its SHA-256
27
+ const zipBytes = readFileSync(bundlePath);
28
+ const hashBuffer = await crypto.subtle.digest('SHA-256', zipBytes);
29
+ const bundleSha256 = Array.from(new Uint8Array(hashBuffer))
30
+ .map((b) => b.toString(16).padStart(2, '0'))
31
+ .join('');
32
+ const blob = new Blob([zipBytes], { type: 'application/zip' });
33
+
34
+ // 2. Build multipart form
35
+ const form = new FormData();
36
+ form.append('bundle', blob, bundlePath.split('/').pop() || 'bundle.zip');
37
+ form.append('bundle_sha256', bundleSha256);
38
+
39
+ const token = getToken();
40
+ if (token) {
41
+ form.append('token', token);
42
+ }
43
+
44
+ // 3. Submit
45
+ const res = await fetch(`${API_BASE}/api/v0/runs`, {
46
+ method: 'POST',
47
+ body: form,
48
+ });
49
+
50
+ if (!res.ok) {
51
+ const body = await res.text();
52
+ throw new Error(`Upload failed (${res.status}): ${body}`);
53
+ }
54
+
55
+ return (await res.json()) as UploadResult;
56
+ }
@@ -0,0 +1,77 @@
1
+ import { randomBytes } from 'crypto';
2
+ import { existsSync, readdirSync } from 'fs';
3
+ import { homedir } from 'os';
4
+ import { join } from 'path';
5
+
6
+ // -----------------------------------------------------------------------------
7
+ // Constants
8
+ // -----------------------------------------------------------------------------
9
+
10
+ export const DEFAULT_BUNDLES_DIR = join(homedir(), '.whatcanirun', 'bundles');
11
+
12
+ const RUNTIME_SLUGS: Record<string, string> = {
13
+ mlx_lm: 'mlx',
14
+ 'llama.cpp': 'llamacpp',
15
+ };
16
+
17
+ // -----------------------------------------------------------------------------
18
+ // Slugification
19
+ // -----------------------------------------------------------------------------
20
+
21
+ export function slugifyRuntime(name: string): string {
22
+ return RUNTIME_SLUGS[name] ?? name.replace(/[^a-z0-9]/gi, '').toLowerCase();
23
+ }
24
+
25
+ export function slugifyModel(displayName: string): string {
26
+ return displayName
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9._-]/g, '-')
29
+ .replace(/-+/g, '-')
30
+ .replace(/^-|-$/g, '')
31
+ .slice(0, 40)
32
+ .replace(/-$/, '');
33
+ }
34
+
35
+ // -----------------------------------------------------------------------------
36
+ // Bundle ID & Filename
37
+ // -----------------------------------------------------------------------------
38
+
39
+ export function generateBundleId(opts: { runtime: string; model: string }): string {
40
+ const rt = slugifyRuntime(opts.runtime);
41
+ const model = slugifyModel(opts.model);
42
+ const hex = randomBytes(3).toString('hex');
43
+ return `${rt}-${model}-${hex}`;
44
+ }
45
+
46
+ export function bundleFilename(bundleId: string): string {
47
+ return `${bundleId}.zip`;
48
+ }
49
+
50
+ // -----------------------------------------------------------------------------
51
+ // Bundle Path Resolution
52
+ // -----------------------------------------------------------------------------
53
+
54
+ export function resolveBundlePath(bundleArg: string): string {
55
+ // Direct path
56
+ if (existsSync(bundleArg)) return bundleArg;
57
+
58
+ // Exact ID match
59
+ const exact = join(DEFAULT_BUNDLES_DIR, `${bundleArg}.zip`);
60
+ if (existsSync(exact)) return exact;
61
+
62
+ // Substring match in bundles dir
63
+ if (existsSync(DEFAULT_BUNDLES_DIR)) {
64
+ const files = readdirSync(DEFAULT_BUNDLES_DIR).filter((f) => f.endsWith('.zip'));
65
+ const matches = files.filter((f) => f.replace(/\.zip$/, '').includes(bundleArg));
66
+
67
+ if (matches.length === 1) {
68
+ return join(DEFAULT_BUNDLES_DIR, matches[0]!);
69
+ }
70
+ if (matches.length > 1) {
71
+ const names = matches.map((m) => m.replace(/\.zip$/, '')).join(', ');
72
+ throw new Error(`Multiple bundles match "${bundleArg}": ${names}`);
73
+ }
74
+ }
75
+
76
+ throw new Error(`Bundle not found: ${bundleArg}`);
77
+ }
@@ -0,0 +1,125 @@
1
+ // -----------------------------------------------------------------------------
2
+ // Constants
3
+ // -----------------------------------------------------------------------------
4
+
5
+ const RESET = '\x1b[0m';
6
+ const BOLD = '\x1b[1m';
7
+ const DIM = '\x1b[2m';
8
+ const GREEN = '\x1b[32m';
9
+ const YELLOW = '\x1b[33m';
10
+ const RED = '\x1b[31m';
11
+ const CYAN = '\x1b[36m';
12
+
13
+ // -----------------------------------------------------------------------------
14
+ // Functions
15
+ // -----------------------------------------------------------------------------
16
+
17
+ export function info(msg: string) {
18
+ console.log(`${DIM}${msg}${RESET}`);
19
+ }
20
+
21
+ export function success(msg: string) {
22
+ console.log(`${GREEN}${msg}${RESET}`);
23
+ }
24
+
25
+ export function warn(msg: string) {
26
+ console.error(`${YELLOW}warning:${RESET} ${msg}`);
27
+ }
28
+
29
+ export function error(msg: string) {
30
+ console.error(`${RED}error:${RESET} ${msg}`);
31
+ }
32
+
33
+ export function header(msg: string) {
34
+ console.log(`${BOLD}${msg}${RESET}`);
35
+ }
36
+
37
+ export function label(key: string, value: string) {
38
+ console.log(`${CYAN}${key}:${RESET} ${value}`);
39
+ }
40
+
41
+ export function bundleSaved(path: string) {
42
+ console.log(`${DIM}Bundle saved to${RESET} ${CYAN}${path}${RESET}`);
43
+ }
44
+
45
+ export function blank() {
46
+ console.log();
47
+ }
48
+
49
+ // -----------------------------------------------------------------------------
50
+ // Spinner
51
+ // -----------------------------------------------------------------------------
52
+
53
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
54
+
55
+ export class Spinner {
56
+ private frame = 0;
57
+ private interval: ReturnType<typeof setInterval> | null = null;
58
+ private text: string;
59
+ private total = 0;
60
+ private current = 0;
61
+ private detail = '';
62
+
63
+ constructor(text: string) {
64
+ this.text = text;
65
+ }
66
+
67
+ start(): this {
68
+ this.render();
69
+ this.interval = setInterval(() => this.render(), 80);
70
+ return this;
71
+ }
72
+
73
+ update(text: string) {
74
+ this.text = text;
75
+ }
76
+
77
+ setTotal(total: number) {
78
+ this.total = total;
79
+ this.current = 0;
80
+ }
81
+
82
+ tick(detail?: string) {
83
+ this.current = Math.min(this.current + 1, this.total);
84
+ if (detail) this.detail = detail;
85
+ }
86
+
87
+ stop(finalText?: string) {
88
+ if (this.interval) clearInterval(this.interval);
89
+ process.stderr.write('\r\x1b[K');
90
+ if (finalText) {
91
+ console.log(`${DIM}${finalText}${RESET}`);
92
+ }
93
+ }
94
+
95
+ private render() {
96
+ const f = SPINNER_FRAMES[this.frame % SPINNER_FRAMES.length];
97
+
98
+ if (this.total <= 0) {
99
+ process.stderr.write(`\r\x1b[K${DIM}${f} ${this.text}${RESET}`);
100
+ this.frame++;
101
+ return;
102
+ }
103
+
104
+ const pulse = Math.sin(this.frame * 0.15) * 0.5 + 0.5; // 0..1
105
+ const bright = Math.round(138 + pulse * 117); // 138..255
106
+ const pulseColor = `\x1b[38;2;${bright};${bright};${bright}m`;
107
+
108
+ const WHITE = '\x1b[97m';
109
+
110
+ // Progress bar: filled is white, empty is dim.
111
+ const width = 20;
112
+ const filled = Math.round((this.current / this.total) * width);
113
+ const empty = width - filled;
114
+ const bar = ` ${WHITE}${'█'.repeat(filled)}${RESET}${DIM}${'░'.repeat(empty)}${RESET}`;
115
+
116
+ // Counter: N is white, /total is dim.
117
+ const counter = ` ${WHITE}${this.current}${RESET}${DIM}/${this.total}${RESET}`;
118
+
119
+ // Detail pulses.
120
+ const detail = this.detail ? ` ${pulseColor}${this.detail}${RESET}` : '';
121
+
122
+ process.stderr.write(`\r\x1b[K${DIM}${f} ${this.text}${RESET}${bar}${counter}${detail}`);
123
+ this.frame++;
124
+ }
125
+ }