@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.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # @whatcanirun/cli
2
+
3
+ Standardized local LLM inference benchmarks.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun install
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ # Run a benchmark
15
+ whatcanirun run --model ./models/llama-3.2-1b.gguf --runtime llama.cpp
16
+
17
+ # Run with options
18
+ whatcanirun run \
19
+ --model ./models/llama-3.2-1b.gguf \
20
+ --runtime llama.cpp \
21
+ --scenario chat_long_v1 \
22
+ --quant q4_k_m \
23
+ --trials 10 \
24
+ --warmups 3
25
+
26
+ # Run without uploading
27
+ whatcanirun run --model ./model.gguf --runtime mlx --no-submit
28
+
29
+ # Upload a previously saved bundle
30
+ whatcanirun submit ./bundles/bundle-abc123.zip
31
+
32
+ # Validate a bundle
33
+ whatcanirun validate ./bundles/bundle-abc123.zip
34
+
35
+ # Inspect device, runtime, or model
36
+ whatcanirun show device
37
+ whatcanirun show runtime llama.cpp
38
+ whatcanirun show model ./models/llama-3.2-1b.gguf
39
+ ```
40
+
41
+ The short alias `wcir` is also available.
42
+
43
+ ## Supported Runtimes
44
+
45
+ | Runtime | Flag |
46
+ | ---------- | ------------ |
47
+ | llama.cpp | `llama.cpp` |
48
+ | MLX | `mlx` |
49
+ | vLLM | `vllm` |
50
+
51
+ ## Scenarios
52
+
53
+ | ID | Description |
54
+ | ---------------- | ------------------------ |
55
+ | `chat_short_v1` | Short chat completion |
56
+ | `chat_long_v1` | Long chat completion |
57
+
58
+ ## Canonical Runs
59
+
60
+ A run is considered **canonical** when all of these hold:
61
+
62
+ - `batch_size = 1`
63
+ - `temperature = 0`
64
+ - `top_p = 1`
65
+ - `trials >= 5`
66
+ - `warmups >= 2`
67
+
68
+ ## Build
69
+
70
+ ```bash
71
+ # Bundle for Bun
72
+ bun run build
73
+
74
+ # Compile to standalone binary
75
+ bun run build:bin
76
+ ```
77
+
78
+ ## Lint & Format
79
+
80
+ ```bash
81
+ bun run lint
82
+ bunx prettier --check .
83
+ bunx prettier --write .
84
+ ```
85
+
86
+ ## Development
87
+
88
+ ```bash
89
+ bun run dev # Runs src/cli.ts directly
90
+ bun test # Run tests
91
+ ```
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@whatcanirun/cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Standardized local LLM inference benchmarks",
6
+ "bin": {
7
+ "whatcanirun": "./src/cli.ts",
8
+ "wcir": "./src/cli.ts"
9
+ },
10
+ "scripts": {
11
+ "dev": "bun run src/cli.ts",
12
+ "build": "bun build src/cli.ts --outdir dist --target bun",
13
+ "build:bin": "bun build src/cli.ts --compile --outfile dist/whatcanirun",
14
+ "test": "bun test",
15
+ "lint": "eslint"
16
+ },
17
+ "dependencies": {
18
+ "@whatcanirun/shared": "workspace:*",
19
+ "citty": "^0.1.6",
20
+ "smol-toml": "^1.3.1"
21
+ },
22
+ "devDependencies": {
23
+ "@trivago/prettier-plugin-sort-imports": "^6.0.2",
24
+ "@types/bun": "^1.3.2",
25
+ "eslint": "^9",
26
+ "eslint-plugin-prettier": "^5.5.5",
27
+ "prettier": "^3",
28
+ "typescript": "^5",
29
+ "typescript-eslint": "^8"
30
+ },
31
+ "files": [
32
+ "src"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public"
36
+ }
37
+ }
@@ -0,0 +1,129 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ import { type AuthData, saveAuth } from './token';
4
+
5
+ // -----------------------------------------------------------------------------
6
+ // Constants
7
+ // -----------------------------------------------------------------------------
8
+
9
+ const API_BASE = process.env.WCIR_API_URL || 'https://whatcani.run';
10
+
11
+ // -----------------------------------------------------------------------------
12
+ // Functions
13
+ // -----------------------------------------------------------------------------
14
+
15
+ export async function loginViaBrowser(): Promise<AuthData> {
16
+ const state = randomBytes(32).toString('hex');
17
+
18
+ return new Promise((resolve, reject) => {
19
+ const server = Bun.serve({
20
+ port: 0,
21
+ fetch(req) {
22
+ const url = new URL(req.url);
23
+
24
+ if (url.pathname !== '/callback') {
25
+ return new Response('Not found', { status: 404 });
26
+ }
27
+
28
+ const code = url.searchParams.get('code');
29
+ const returnedState = url.searchParams.get('state');
30
+
31
+ if (returnedState !== state) {
32
+ return new Response(page('Authentication failed', 'State mismatch. Please try again.'), {
33
+ headers: { 'Content-Type': 'text/html' },
34
+ status: 400,
35
+ });
36
+ }
37
+
38
+ if (!code) {
39
+ return new Response(page('Authentication failed', 'Missing authorization code.'), {
40
+ headers: { 'Content-Type': 'text/html' },
41
+ status: 400,
42
+ });
43
+ }
44
+
45
+ // Exchange the code for a CLI token server-side.
46
+ exchangeCode(code)
47
+ .then((authData) => {
48
+ saveAuth(authData);
49
+ setTimeout(() => {
50
+ server.stop();
51
+ resolve(authData);
52
+ }, 100);
53
+ })
54
+ .catch((err) => {
55
+ setTimeout(() => {
56
+ server.stop();
57
+ reject(err);
58
+ }, 100);
59
+ });
60
+
61
+ return new Response(page('Authenticated', 'You can close this tab.'), {
62
+ headers: { 'Content-Type': 'text/html' },
63
+ });
64
+ },
65
+ });
66
+
67
+ const port = server.port;
68
+ const loginUrl = `${API_BASE}/cli-auth?port=${port}&state=${state}`;
69
+
70
+ // Open browser (await to prevent zombie process).
71
+ const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
72
+ const browserProc = Bun.spawn([cmd, loginUrl], { stdout: 'ignore', stderr: 'ignore' });
73
+ browserProc.exited.catch(() => {});
74
+
75
+ console.log(`If the browser didn't open, visit: ${loginUrl}`);
76
+
77
+ // Timeout after 5 minutes.
78
+ const timeout = setTimeout(() => {
79
+ server.stop();
80
+ reject(new Error('Login timed out. Please try again.'));
81
+ }, 300_000);
82
+ timeout.unref();
83
+ });
84
+ }
85
+
86
+ // -----------------------------------------------------------------------------
87
+ // Helpers
88
+ // -----------------------------------------------------------------------------
89
+
90
+ async function exchangeCode(code: string): Promise<AuthData> {
91
+ const res = await fetch(`${API_BASE}/api/v0/auth/cli-exchange`, {
92
+ method: 'POST',
93
+ headers: { 'Content-Type': 'application/json' },
94
+ body: JSON.stringify({ code }),
95
+ });
96
+
97
+ if (!res.ok) {
98
+ const body = await res.text();
99
+ throw new Error(`Code exchange failed (${res.status}): ${body}`);
100
+ }
101
+
102
+ return (await res.json()) as AuthData;
103
+ }
104
+
105
+ function escapeHtml(s: string): string {
106
+ return s
107
+ .replace(/&/g, '&amp;')
108
+ .replace(/</g, '&lt;')
109
+ .replace(/>/g, '&gt;')
110
+ .replace(/"/g, '&quot;');
111
+ }
112
+
113
+ function page(title: string, message: string): string {
114
+ const t = escapeHtml(title);
115
+ const m = escapeHtml(message);
116
+ return `<!DOCTYPE html>
117
+ <html>
118
+ <head><title>${t}</title>
119
+ <style>
120
+ body { font-family: system-ui, sans-serif; display: flex; justify-content: center;
121
+ align-items: center; min-height: 100vh; margin: 0; background: #0a0a0a; color: #fafafa; }
122
+ .card { text-align: center; }
123
+ h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
124
+ p { color: #a1a1aa; }
125
+ </style>
126
+ </head>
127
+ <body><div class="card"><h1>${t}</h1><p>${m}</p></div></body>
128
+ </html>`;
129
+ }
@@ -0,0 +1,58 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ // -----------------------------------------------------------------------------
6
+ // Types
7
+ // -----------------------------------------------------------------------------
8
+
9
+ export interface AuthUser {
10
+ id: string;
11
+ name: string;
12
+ email: string;
13
+ }
14
+
15
+ export interface AuthData {
16
+ token: string;
17
+ user: AuthUser;
18
+ }
19
+
20
+ // -----------------------------------------------------------------------------
21
+ // Constants
22
+ // -----------------------------------------------------------------------------
23
+
24
+ const AUTH_FILE = join(homedir(), '.whatcanirun', 'auth.json');
25
+
26
+ // -----------------------------------------------------------------------------
27
+ // Functions
28
+ // -----------------------------------------------------------------------------
29
+
30
+ export function getAuth(): AuthData | null {
31
+ if (!existsSync(AUTH_FILE)) return null;
32
+ try {
33
+ const data = JSON.parse(readFileSync(AUTH_FILE, 'utf-8')) as AuthData;
34
+ if (data.token && data.user?.id) return data;
35
+ return null;
36
+ } catch (e: unknown) {
37
+ console.warn(
38
+ `Warning: could not parse ${AUTH_FILE}: ${e instanceof Error ? e.message : String(e)}. ` +
39
+ 'Try deleting it and running `whatcanirun auth login` again.'
40
+ );
41
+ return null;
42
+ }
43
+ }
44
+
45
+ export function getToken(): string | null {
46
+ return getAuth()?.token ?? null;
47
+ }
48
+
49
+ export function saveAuth(data: AuthData): void {
50
+ mkdirSync(dirname(AUTH_FILE), { recursive: true });
51
+ writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
52
+ }
53
+
54
+ export function clearAuth(): void {
55
+ if (existsSync(AUTH_FILE)) {
56
+ unlinkSync(AUTH_FILE);
57
+ }
58
+ }
@@ -0,0 +1,164 @@
1
+ import {
2
+ type AggregateMetrics,
3
+ type DerivedMetrics,
4
+ type Manifest,
5
+ type Results,
6
+ type ResultTrial,
7
+ SCHEMA_VERSION,
8
+ } from '@whatcanirun/shared';
9
+ import { existsSync, mkdirSync } from 'fs';
10
+ import { join, resolve } from 'path';
11
+
12
+ import type { DeviceInfo } from '../device/detect';
13
+ import { formatSysinfo } from '../device/detect';
14
+ import type { ModelInfo } from '../model/resolve';
15
+ import type { BenchResult, RuntimeInfo } from '../runtime/types';
16
+ import { bundleFilename, generateBundleId } from '../utils/id';
17
+
18
+ // -----------------------------------------------------------------------------
19
+ // Types
20
+ // -----------------------------------------------------------------------------
21
+
22
+ export interface BundleOpts {
23
+ outputDir: string;
24
+ device: DeviceInfo;
25
+ runtimeInfo: RuntimeInfo;
26
+ model: ModelInfo;
27
+ bench: BenchResult;
28
+ metrics: DerivedMetrics;
29
+ notes?: string;
30
+ }
31
+
32
+ // -----------------------------------------------------------------------------
33
+ // Function
34
+ // -----------------------------------------------------------------------------
35
+
36
+ export async function createBundle(opts: BundleOpts): Promise<string> {
37
+ const bundleId = generateBundleId({
38
+ runtime: opts.runtimeInfo.name,
39
+ model: opts.model.display_name,
40
+ });
41
+ const now = new Date();
42
+ const filename = bundleFilename(bundleId);
43
+
44
+ if (!existsSync(opts.outputDir)) {
45
+ mkdirSync(opts.outputDir, { recursive: true });
46
+ }
47
+
48
+ const manifest: Manifest = {
49
+ schema_version: SCHEMA_VERSION,
50
+ bundle_id: bundleId,
51
+ created_at: now.toISOString(),
52
+ canonical: false,
53
+ harness: {
54
+ version: '0.1.0',
55
+ git_sha: await getGitSha(),
56
+ },
57
+ device: {
58
+ cpu: opts.device.cpu_model,
59
+ gpu: opts.device.gpu_model,
60
+ ram_gb: opts.device.ram_gb,
61
+ os_name: opts.device.os_name,
62
+ os_version: opts.device.os_version,
63
+ },
64
+ runtime: {
65
+ name: opts.runtimeInfo.name,
66
+ version: opts.runtimeInfo.version,
67
+ build_flags: opts.runtimeInfo.build_flags,
68
+ },
69
+ model: {
70
+ display_name: opts.model.display_name,
71
+ format: opts.model.format,
72
+ artifact_sha256: opts.model.artifact_sha256,
73
+ source: opts.model.source,
74
+ file_size_bytes: opts.model.file_size_bytes,
75
+ parameters: opts.model.parameters,
76
+ quant: opts.model.quant ?? undefined,
77
+ architecture: opts.model.architecture,
78
+ },
79
+ context_length: opts.bench.promptTokens + opts.bench.completionTokens,
80
+ notes: opts.notes,
81
+ };
82
+
83
+ const trials: ResultTrial[] = opts.bench.trials.map((t) => ({
84
+ input_tokens: opts.bench.promptTokens,
85
+ output_tokens: opts.bench.completionTokens,
86
+ ttft_ms: t.promptTps > 0 ? (opts.bench.promptTokens / t.promptTps) * 1000 : 0,
87
+ total_ms: 0,
88
+ decode_tps: t.generationTps,
89
+ weighted_tps:
90
+ opts.bench.promptTokens + opts.bench.completionTokens > 0
91
+ ? (opts.bench.promptTokens * t.promptTps + opts.bench.completionTokens * t.generationTps) /
92
+ (opts.bench.promptTokens + opts.bench.completionTokens)
93
+ : 0,
94
+ peak_rss_mb: Math.round(t.peakMemoryGb * 1024 * 10) / 10,
95
+ exit_status: 'ok',
96
+ }));
97
+
98
+ const aggregate: AggregateMetrics = {
99
+ ttft_p50_ms: opts.metrics.ttftP50Ms,
100
+ ttft_p95_ms: opts.metrics.ttftP95Ms,
101
+ decode_tps_mean: opts.metrics.decodeTpsMean,
102
+ weighted_tps_mean: opts.metrics.weightedTpsMean,
103
+ idle_rss_mb: 0,
104
+ peak_rss_mb: opts.metrics.peakRssMb,
105
+ trials_passed: opts.bench.trials.length,
106
+ trials_total: opts.bench.trials.length,
107
+ };
108
+
109
+ const results: Results = { trials, aggregate };
110
+
111
+ const sysinfo = formatSysinfo(opts.device);
112
+
113
+ // Create a temporary directory for bundle contents.
114
+ const tmpDir = join(opts.outputDir, `.tmp_${bundleId}`);
115
+ mkdirSync(tmpDir, { recursive: true });
116
+
117
+ // Write files with deterministic formatting.
118
+ await Bun.write(join(tmpDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
119
+ await Bun.write(join(tmpDir, 'results.json'), JSON.stringify(results, null, 2) + '\n');
120
+ await Bun.write(join(tmpDir, 'sysinfo.txt'), sysinfo + '\n');
121
+
122
+ // Create deterministic zip using system zip command.
123
+ const outputPath = resolve(opts.outputDir, filename);
124
+ const zipProc = Bun.spawn(
125
+ ['zip', '-rX', outputPath, 'manifest.json', 'results.json', 'sysinfo.txt'],
126
+ {
127
+ cwd: tmpDir,
128
+ stdout: 'ignore',
129
+ stderr: 'pipe',
130
+ }
131
+ );
132
+ const zipCode = await zipProc.exited;
133
+ if (zipCode !== 0) {
134
+ const stderr = await new Response(zipProc.stderr).text();
135
+ throw new Error(`Failed to create bundle zip: ${stderr.trim() || `exit code ${zipCode}`}`);
136
+ }
137
+
138
+ // Clean up temp dir.
139
+ const rmProc = Bun.spawn(['rm', '-rf', tmpDir], {
140
+ stdout: 'ignore',
141
+ stderr: 'ignore',
142
+ });
143
+ await rmProc.exited;
144
+
145
+ return outputPath;
146
+ }
147
+
148
+ // -----------------------------------------------------------------------------
149
+ // Helpers
150
+ // -----------------------------------------------------------------------------
151
+
152
+ async function getGitSha(): Promise<string> {
153
+ try {
154
+ const proc = Bun.spawn(['git', 'rev-parse', '--short', 'HEAD'], {
155
+ stdout: 'pipe',
156
+ stderr: 'ignore',
157
+ });
158
+ const sha = (await new Response(proc.stdout).text()).trim();
159
+ await proc.exited;
160
+ return sha || 'unknown';
161
+ } catch {
162
+ return 'unknown';
163
+ }
164
+ }
@@ -0,0 +1,82 @@
1
+ import { validateManifest, validateResults } from '@whatcanirun/shared';
2
+ import { mkdtempSync, rmSync } from 'fs';
3
+ import { tmpdir } from 'os';
4
+ import { join } from 'path';
5
+
6
+ // -----------------------------------------------------------------------------
7
+ // Types
8
+ // -----------------------------------------------------------------------------
9
+
10
+ export interface ValidationResult {
11
+ valid: boolean;
12
+ errors: string[];
13
+ }
14
+
15
+ // -----------------------------------------------------------------------------
16
+ // Function
17
+ // -----------------------------------------------------------------------------
18
+
19
+ export async function validateBundle(bundlePath: string): Promise<ValidationResult> {
20
+ const errors: string[] = [];
21
+
22
+ // Extract to temp directory.
23
+ const tmpDir = mkdtempSync(join(tmpdir(), 'whatcanirun-validate-'));
24
+
25
+ try {
26
+ const proc = Bun.spawn(['unzip', '-o', bundlePath, '-d', tmpDir], {
27
+ stdout: 'ignore',
28
+ stderr: 'pipe',
29
+ });
30
+ const code = await proc.exited;
31
+ if (code !== 0) {
32
+ const stderr = await new Response(proc.stderr).text();
33
+ errors.push(`Failed to extract bundle: ${stderr.trim()}`);
34
+ return { valid: false, errors };
35
+ }
36
+
37
+ // Check required files.
38
+ const requiredFiles = ['manifest.json', 'results.json', 'sysinfo.txt'];
39
+
40
+ for (const file of requiredFiles) {
41
+ const f = Bun.file(join(tmpDir, file));
42
+ if (!(await f.exists())) {
43
+ errors.push(`Missing required file: ${file}`);
44
+ }
45
+ }
46
+
47
+ if (errors.length > 0) {
48
+ return { valid: false, errors };
49
+ }
50
+
51
+ // Validate manifest.
52
+ let manifest: unknown;
53
+ try {
54
+ manifest = JSON.parse(await Bun.file(join(tmpDir, 'manifest.json')).text());
55
+ } catch (e: unknown) {
56
+ errors.push(`Invalid manifest.json: ${e instanceof Error ? e.message : String(e)}`);
57
+ return { valid: false, errors };
58
+ }
59
+ errors.push(...validateManifest(manifest));
60
+
61
+ // Validate results
62
+ let results: unknown;
63
+ try {
64
+ results = JSON.parse(await Bun.file(join(tmpDir, 'results.json')).text());
65
+ } catch (e: unknown) {
66
+ errors.push(`Invalid results.json: ${e instanceof Error ? e.message : String(e)}`);
67
+ return { valid: false, errors };
68
+ }
69
+ errors.push(...validateResults(results));
70
+
71
+ // Check artifact hash presence
72
+ const m = manifest as Record<string, unknown>;
73
+ const model = m.model as Record<string, unknown> | undefined;
74
+ if (!model?.artifact_sha256) {
75
+ errors.push('Missing model `artifact_sha256`.');
76
+ }
77
+
78
+ return { valid: errors.length === 0, errors };
79
+ } finally {
80
+ rmSync(tmpDir, { recursive: true, force: true });
81
+ }
82
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env bun
2
+ import { defineCommand, runMain } from 'citty';
3
+
4
+ import { auth, run, show, submit, update, validate, version } from './commands';
5
+
6
+ const main = defineCommand({
7
+ meta: {
8
+ name: 'whatcanirun',
9
+ version: '0.1.0',
10
+ description: 'Standardized local LLM inference benchmarks',
11
+ },
12
+ subCommands: {
13
+ auth,
14
+ run,
15
+ show,
16
+ submit,
17
+ update,
18
+ validate,
19
+ version,
20
+ },
21
+ });
22
+
23
+ runMain(main);
@@ -0,0 +1,75 @@
1
+ import { defineCommand } from 'citty';
2
+
3
+ import { loginViaBrowser } from '../auth/login';
4
+ import { clearAuth, getAuth } from '../auth/token';
5
+ import * as log from '../utils/log';
6
+
7
+ const login = defineCommand({
8
+ meta: {
9
+ name: 'login',
10
+ description: 'Authenticate with whatcani.run',
11
+ },
12
+ async run() {
13
+ const existing = getAuth();
14
+ if (existing) {
15
+ log.info(`Already logged in as ${existing.user.name} (${existing.user.email}).`);
16
+ log.info('Run `whatcanirun auth logout` first to switch accounts.');
17
+ return;
18
+ }
19
+
20
+ log.info('Opening browser to sign in...');
21
+ try {
22
+ const auth = await loginViaBrowser();
23
+ log.blank();
24
+ log.success(`Logged in as ${auth.user.name} (${auth.user.email}).`);
25
+ } catch (e: unknown) {
26
+ log.error(e instanceof Error ? e.message : String(e));
27
+ process.exit(1);
28
+ }
29
+ },
30
+ });
31
+
32
+ const logout = defineCommand({
33
+ meta: {
34
+ name: 'logout',
35
+ description: 'Remove stored credentials',
36
+ },
37
+ run() {
38
+ const existing = getAuth();
39
+ if (!existing) {
40
+ log.info('Not logged in.');
41
+ return;
42
+ }
43
+ clearAuth();
44
+ log.success('Logged out.');
45
+ },
46
+ });
47
+
48
+ const status = defineCommand({
49
+ meta: {
50
+ name: 'status',
51
+ description: 'Show current authentication status',
52
+ },
53
+ run() {
54
+ const auth = getAuth();
55
+ if (auth) {
56
+ log.label('Logged in as', `${auth.user.name} (${auth.user.email})`);
57
+ } else {
58
+ log.info('Not logged in. Run `whatcanirun auth login` to authenticate.');
59
+ }
60
+ },
61
+ });
62
+
63
+ const command = defineCommand({
64
+ meta: {
65
+ name: 'auth',
66
+ description: 'Manage authentication',
67
+ },
68
+ subCommands: {
69
+ login,
70
+ logout,
71
+ status,
72
+ },
73
+ });
74
+
75
+ export default command;
@@ -0,0 +1,7 @@
1
+ export { default as auth } from './auth';
2
+ export { default as run } from './run';
3
+ export { default as show } from './show';
4
+ export { default as submit } from './submit';
5
+ export { default as update } from './update';
6
+ export { default as validate } from './validate';
7
+ export { default as version } from './version';