@abliteration/cli 1.0.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,96 @@
1
+ # abliteration CLI
2
+
3
+ Command-line client for the [abliteration.org](https://abliteration.org) catalog API. The same catalog you browse on the web, served as JSON from your terminal — models, datasets, methods, indices, and the curated field timeline.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @abliteration/cli
9
+ ```
10
+
11
+ Or use the installer that also works on machines without Node.js installed:
12
+
13
+ ```bash
14
+ curl -sSf https://abliteration.org/install | bash
15
+ ```
16
+
17
+ Both give you two commands: `abliteration` (the full name) and `abl` (the short one). Everywhere you can type one, you can type the other.
18
+
19
+ ## Getting started
20
+
21
+ ```bash
22
+ $ abl login
23
+ # Opens https://abliteration.org/shop/account#api-keys in your browser.
24
+ # Generate a key on that page, paste it here.
25
+
26
+ $ abl models --method M4 --limit 5
27
+ ID Method Family License Downloads
28
+ ----------------------------------------------------- ------ ------- ----------- ---------
29
+ mlabonne/Qwen3-30B-A3B-abliterated M4 qwen apache-2.0 249,452
30
+ mlabonne/Meta-Llama-3.1-8B-Instruct-abliterated-GGUF M4 llama-3 llama-3 30,768
31
+ mlabonne/NeuralDaredevil-8B-abliterated M4 llama-3 llama-3 14,765
32
+ mlabonne/gemma-2-9b-it-abliterated M4 gemma-2 gemma 12,890
33
+ mlabonne/Daredevil-8B-abliterated M4 llama-3 llama-3 11,432
34
+ cost: 5 cr · balance: 495 · rate: 59/60
35
+ ```
36
+
37
+ ## Commands
38
+
39
+ | Command | What it does | Cost |
40
+ |---|---|---|
41
+ | `abl login` | Sign in, save key locally | free (once) |
42
+ | `abl logout` | Remove local key | free |
43
+ | `abl whoami` | Show key prefix + balance | 5 cr |
44
+ | `abl credits` | Show credit balance | 5 cr |
45
+ | `abl models` | List catalog models | 5-300+ cr |
46
+ | `abl models <id>` | Fetch one model | 10 cr |
47
+ | `abl authors` | List authors | 5-15 cr |
48
+ | `abl authors <id>` | Fetch one author | 1 cr |
49
+ | `abl methods` | List M-code taxonomy | 5-15 cr |
50
+ | `abl methods <id>` | Fetch one method | 1 cr |
51
+ | `abl datasets` | List alignment datasets | 5-15 cr |
52
+ | `abl datasets <id>` | Fetch one dataset | 1 cr |
53
+ | `abl indices` | All four flagship indices | 20 cr |
54
+ | `abl indices <id>` | One index with history | 10 cr |
55
+ | `abl timeline` | Curated field events | 50 cr |
56
+
57
+ Every command has `--help` for full flag documentation.
58
+
59
+ ## Pricing shape
60
+
61
+ Model routes carry an anti-scrape price shape: the cost of `abl models` grows steeply as you page deeper into the catalog. A filtered lookup up to 200 rows always costs 5 credits. A full 1000-row page on the first page costs 300; on page 10, 5100. Filter cheap, dump expensive — see [/api#pricing](https://abliteration.org/api#pricing) for the full formula.
62
+
63
+ Every response prints the actual cost and your remaining balance in the footer.
64
+
65
+ ## Machine-readable output
66
+
67
+ Add `--json` to any command to get the full envelope as JSON on stdout:
68
+
69
+ ```bash
70
+ $ abl models --method M4 --limit 3 --json
71
+ {
72
+ "data": [ ... ],
73
+ "cost": 5,
74
+ "credits_remaining": 495,
75
+ "rate_limit_limit": 60,
76
+ "rate_limit_remaining": 59,
77
+ "metering": "billed",
78
+ "request_id": "..."
79
+ }
80
+ ```
81
+
82
+ ## Environment
83
+
84
+ - `ABLITERATION_API_KEY` — overrides `~/.abliteration/config.json`
85
+ - `ABLITERATION_API_BASE` — overrides `https://abliteration.org`
86
+ - `ABL_DEBUG=1` — print full stack traces on errors
87
+
88
+ ## Docs
89
+
90
+ - Full API reference: <https://abliteration.org/api>
91
+ - Machine-readable spec: <https://abliteration.org/api/v1/openapi.json>
92
+ - Source: <https://github.com/neymark/abliteration/tree/main/cli>
93
+
94
+ ## License
95
+
96
+ MIT
package/bin/cli.mjs ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @abliteration/cli entry point.
4
+ *
5
+ * The shebang and the file extension together let the same file work as both
6
+ * `abliteration` and `abl` bin entries declared in package.json. Nothing runs
7
+ * here beyond delegating to src/main.mjs — the wrapper stays small so a stack
8
+ * trace never blames a line in the shebang file.
9
+ */
10
+ import { main } from '../src/main.mjs';
11
+
12
+ main(process.argv.slice(2)).catch((err) => {
13
+ // Errors bubbled all the way up are the ones we could not classify; print
14
+ // a plain message rather than a full stack unless the caller asked for
15
+ // debug output. Node exits with the code we set.
16
+ const debug = process.env.ABL_DEBUG === '1';
17
+ if (debug) {
18
+ console.error(err);
19
+ } else {
20
+ console.error('abl:', err?.message ?? err);
21
+ }
22
+ process.exit(err?.exitCode ?? 1);
23
+ });
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@abliteration/cli",
3
+ "version": "1.0.0",
4
+ "description": "Command-line client for the abliteration.org catalog API",
5
+ "type": "module",
6
+ "bin": {
7
+ "abliteration": "./bin/cli.mjs",
8
+ "abl": "./bin/cli.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "scripts": {
19
+ "start": "node bin/cli.mjs"
20
+ },
21
+ "keywords": [
22
+ "abliteration",
23
+ "cli",
24
+ "llm",
25
+ "catalog",
26
+ "api"
27
+ ],
28
+ "author": "Catyonic OÜ",
29
+ "license": "MIT",
30
+ "homepage": "https://abliteration.org/api",
31
+ "bugs": {
32
+ "url": "https://github.com/neymark/abliteration/issues"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/neymark/abliteration.git",
37
+ "directory": "cli"
38
+ }
39
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Authentication and account commands.
3
+ *
4
+ * `abl login` — paste-a-key flow. Opens the ~/shop/account#api-keys page in
5
+ * the user's default browser, prompts them to paste a freshly generated
6
+ * key, verifies it with a live /whoami-style call to the API, then writes
7
+ * ~/.abliteration/config.json. Deliberately not an OAuth device-code flow:
8
+ * the account page already knows how to mint a key, and a copy-paste is
9
+ * what a user does anyway when they generate one — the CLI just meets
10
+ * them where they are.
11
+ *
12
+ * `abl logout` — deletes the config file. Does NOT revoke the key server-
13
+ * side because a shared key on a shared laptop might be in use elsewhere;
14
+ * the server call to revoke lives at `abl keys revoke <id>` (future PR).
15
+ *
16
+ * `abl whoami` — shows the local state (key prefix, config path) plus the
17
+ * server's answer to `GET /api/v1/authors/{whatever}` — actually we call
18
+ * a cheap route (methods/list is 5cr flat) and read the metering fields.
19
+ * Cost is real and shown as such.
20
+ *
21
+ * `abl credits` — the cheapest possible way to check balance. Calls
22
+ * /methods (5 credits) which is a small fixed cost the server has to
23
+ * compute anyway. No dedicated /whoami endpoint exists yet, so this
24
+ * piggybacks on an existing route; the trade-off (5 credits per check)
25
+ * is documented in the help text.
26
+ */
27
+
28
+ import { readFile } from 'node:fs/promises';
29
+ import { createInterface } from 'node:readline/promises';
30
+ import { spawn } from 'node:child_process';
31
+ import { platform } from 'node:os';
32
+ import { get, CliError } from '../lib/client.mjs';
33
+ import { readConfig, writeConfig, deleteConfig, configPath, configExists } from '../lib/config.mjs';
34
+ import { renderFooter, fmtNum } from '../lib/output.mjs';
35
+
36
+ const ACCOUNT_URL = 'https://abliteration.org/shop/account#api-keys';
37
+
38
+ /**
39
+ * Open a URL in the user's default browser. Cross-platform without a
40
+ * dependency: macOS gets `open`, Windows gets `start`, Linux gets `xdg-open`.
41
+ * On WSL we fall through to xdg-open which usually routes to the Windows
42
+ * shell anyway.
43
+ *
44
+ * Silently returns on failure — the CLI prints the URL as a fallback, so a
45
+ * headless machine without a browser still knows where to go.
46
+ */
47
+ async function openUrl(url) {
48
+ const p = platform();
49
+ const cmd = p === 'darwin' ? 'open'
50
+ : p === 'win32' ? 'start'
51
+ : 'xdg-open';
52
+ try {
53
+ const child = spawn(cmd, [url], {
54
+ detached: true,
55
+ stdio: 'ignore',
56
+ shell: p === 'win32', // `start` is a shell builtin, not a binary
57
+ });
58
+ child.unref();
59
+ } catch {
60
+ /* ignore — the caller printed the URL already */
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Simple TTY prompt with echo. We do NOT hide the key on read because a
66
+ * masked prompt would fight the user's clipboard-based paste (browser →
67
+ * terminal) which is the exact flow we designed for. A revoked key is
68
+ * cheap; a paste that fails silently is not.
69
+ */
70
+ async function prompt(question) {
71
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
72
+ try {
73
+ const answer = await rl.question(question);
74
+ return answer.trim();
75
+ } finally {
76
+ rl.close();
77
+ }
78
+ }
79
+
80
+ export async function loginCmd(args) {
81
+ if (args.help) {
82
+ console.log(
83
+ [
84
+ 'Usage: abl login',
85
+ '',
86
+ 'Opens https://abliteration.org/shop/account#api-keys in your browser,',
87
+ 'prompts for the key you generate there, and saves it to',
88
+ ` ${configPath()}`,
89
+ '',
90
+ 'After login, every subsequent `abl` command uses this key until you',
91
+ 'run `abl logout` or overwrite it with another `abl login`.',
92
+ ].join('\n'),
93
+ );
94
+ return 0;
95
+ }
96
+
97
+ console.log('Opening your account page:');
98
+ console.log(` ${ACCOUNT_URL}`);
99
+ console.log('');
100
+ console.log('If your browser did not open, copy the URL above.');
101
+ console.log('Generate a new key on that page - the key is shown once, then hashed.');
102
+ console.log('');
103
+
104
+ await openUrl(ACCOUNT_URL);
105
+
106
+ const key = await prompt('Paste your API key here: ');
107
+ if (!key) {
108
+ throw new CliError('no key entered — aborting.', { code: 'no_key', exitCode: 2 });
109
+ }
110
+ if (!key.startsWith('abl_live_') && !key.startsWith('abl_test_')) {
111
+ throw new CliError(
112
+ 'that does not look like an abliteration key. Keys start with abl_live_ or abl_test_.',
113
+ { code: 'bad_key', exitCode: 4 },
114
+ );
115
+ }
116
+
117
+ // Verify against the API by making one cheap authenticated call. If it
118
+ // works, the key is real, the account is not revoked, and the paywall
119
+ // recognises this user — all in one 5-credit round trip.
120
+ //
121
+ // We temporarily inject the key via env so client.mjs picks it up without
122
+ // us having to duplicate the fetch logic. Restore afterwards regardless.
123
+ const savedEnv = process.env.ABLITERATION_API_KEY;
124
+ process.env.ABLITERATION_API_KEY = key;
125
+ try {
126
+ const { meta } = await get('/api/v1/methods');
127
+ await writeConfig({ key, saved_at: new Date().toISOString() });
128
+ console.log('');
129
+ console.log(`Signed in. Key: ${key.slice(0, 12)}...${key.slice(-6)}`);
130
+ if (meta.creditsRemaining !== null) {
131
+ console.log(`Balance: ${fmtNum(meta.creditsRemaining)} credits`);
132
+ }
133
+ console.log(`Config: ${configPath()}`);
134
+ console.log('');
135
+ console.log('Try: abl models --method M4 --limit 5');
136
+ } finally {
137
+ if (savedEnv === undefined) delete process.env.ABLITERATION_API_KEY;
138
+ else process.env.ABLITERATION_API_KEY = savedEnv;
139
+ }
140
+ return 0;
141
+ }
142
+
143
+ export async function logoutCmd(args) {
144
+ if (args.help) {
145
+ console.log(
146
+ [
147
+ 'Usage: abl logout',
148
+ '',
149
+ 'Removes your local key from the config file. Does NOT revoke the key',
150
+ 'server-side — visit ' + ACCOUNT_URL + ' if you want to revoke it.',
151
+ ].join('\n'),
152
+ );
153
+ return 0;
154
+ }
155
+ const existed = await configExists();
156
+ await deleteConfig();
157
+ if (existed) {
158
+ console.log('Signed out. Local config removed.');
159
+ } else {
160
+ console.log('No local config to remove.');
161
+ }
162
+ return 0;
163
+ }
164
+
165
+ export async function whoamiCmd(args) {
166
+ if (args.help) {
167
+ console.log(
168
+ [
169
+ 'Usage: abl whoami',
170
+ '',
171
+ 'Prints the key prefix currently in use, where it came from (env or',
172
+ 'config), and the balance the server reports for it. Costs 5 credits',
173
+ '(one /methods call) to check because the API has no dedicated whoami.',
174
+ ].join('\n'),
175
+ );
176
+ return 0;
177
+ }
178
+
179
+ // Prefer env; fall back to config. Do NOT print the full key — only the
180
+ // prefix. Anyone shoulder-surfing the terminal already sees the prefix
181
+ // on the CF /shop/account page, so this leaks nothing new.
182
+ const envKey = process.env.ABLITERATION_API_KEY;
183
+ let source, prefix;
184
+ if (envKey) {
185
+ source = 'ABLITERATION_API_KEY';
186
+ prefix = envKey.slice(0, 12);
187
+ } else {
188
+ const cfg = await readConfig();
189
+ if (!cfg?.key) {
190
+ console.log('Not signed in. Run `abl login`.');
191
+ return 2;
192
+ }
193
+ source = configPath();
194
+ prefix = cfg.key.slice(0, 12);
195
+ }
196
+
197
+ const { meta } = await get('/api/v1/methods');
198
+ console.log(`Key source: ${source}`);
199
+ console.log(`Key prefix: ${prefix}...`);
200
+ if (meta.creditsRemaining !== null) {
201
+ console.log(`Balance: ${fmtNum(meta.creditsRemaining)} credits`);
202
+ }
203
+ console.log('');
204
+ process.stdout.write(renderFooter(meta));
205
+ return 0;
206
+ }
207
+
208
+ export async function creditsCmd(args) {
209
+ if (args.help) {
210
+ console.log(
211
+ [
212
+ 'Usage: abl credits',
213
+ '',
214
+ 'Prints your current credit balance. Costs 5 credits because it uses',
215
+ 'the /methods list route as a probe (no dedicated balance endpoint).',
216
+ 'Use `abl whoami` for the same info plus the key prefix.',
217
+ ].join('\n'),
218
+ );
219
+ return 0;
220
+ }
221
+ const { meta } = await get('/api/v1/methods');
222
+ if (meta.creditsRemaining === null) {
223
+ console.log('Balance unavailable (metering is in preview).');
224
+ } else {
225
+ console.log(`${fmtNum(meta.creditsRemaining)} credits`);
226
+ }
227
+ return 0;
228
+ }
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Catalog commands.
3
+ *
4
+ * One command per API resource. Each command has two shapes:
5
+ * - `abl <resource>` — list route, prints a table (or JSON)
6
+ * - `abl <resource> <id>` — single-entity route, prints a key/value block
7
+ *
8
+ * List commands share the same set of paging and format flags: --limit,
9
+ * --offset, --sort, --json. Filter flags vary by resource and are declared
10
+ * in the `filters` map below.
11
+ *
12
+ * The output layer is always the same: for --json we JSON.stringify the raw
13
+ * server payload including cost and credits_remaining, so a script consuming
14
+ * the CLI sees the same envelope it would see from curl. For the human
15
+ * output we render a table or entity block plus the standard footer.
16
+ */
17
+
18
+ import { get } from '../lib/client.mjs';
19
+ import { renderTable, renderEntity, renderFooter, fmtNum } from '../lib/output.mjs';
20
+
21
+ /**
22
+ * Emit either JSON or the human-readable rendering. Return the exit code.
23
+ *
24
+ * Kept in one place so every list command follows the same rule: when
25
+ * --json is on, stdout is machine-readable and stderr carries any warnings;
26
+ * when --json is off, stdout is human-readable and the footer prints there.
27
+ */
28
+ function emit(payload, options) {
29
+ if (options.json) {
30
+ // Reconstruct the full envelope shape rather than dumping `data` alone,
31
+ // so a piped consumer can read cost + credits_remaining without a second
32
+ // API call.
33
+ process.stdout.write(
34
+ JSON.stringify(
35
+ {
36
+ data: payload.data,
37
+ cost: payload.meta.cost,
38
+ credits_remaining: payload.meta.creditsRemaining,
39
+ rate_limit_limit: payload.meta.rateLimit.limit,
40
+ rate_limit_remaining: payload.meta.rateLimit.remaining,
41
+ rate_limit_reset: payload.meta.rateLimit.reset,
42
+ metering: payload.meta.metering,
43
+ request_id: payload.meta.requestId,
44
+ },
45
+ null,
46
+ 2,
47
+ ) + '\n',
48
+ );
49
+ } else {
50
+ if (Array.isArray(payload.data)) {
51
+ process.stdout.write(renderTable(payload.data, options.columns));
52
+ } else if (payload.data && typeof payload.data === 'object') {
53
+ process.stdout.write(renderEntity(payload.data));
54
+ } else {
55
+ process.stdout.write(String(payload.data) + '\n');
56
+ }
57
+ process.stdout.write(renderFooter(payload.meta));
58
+ }
59
+ return 0;
60
+ }
61
+
62
+ /* ---------- models ---------- */
63
+
64
+ const MODEL_COLUMNS = [
65
+ { header: 'ID', key: 'id', align: 'left' },
66
+ { header: 'Method', key: 'method', align: 'left' },
67
+ { header: 'Family', key: 'family', align: 'left' },
68
+ { header: 'License', key: 'license', align: 'left' },
69
+ { header: 'Downloads', key: 'downloads', align: 'right' },
70
+ ];
71
+
72
+ export async function modelsCmd(args) {
73
+ if (args.help) return printModelsHelp();
74
+ if (args._[0]) return getModel(args._[0], args);
75
+
76
+ const payload = await get('/api/v1/models', {
77
+ method: args.method,
78
+ family: args.family,
79
+ author: args.author,
80
+ license: args.license,
81
+ downloads_gte: args['downloads-gte'],
82
+ downloads_lte: args['downloads-lte'],
83
+ created_after: args['created-after'],
84
+ created_before: args['created-before'],
85
+ sort: args.sort,
86
+ limit: args.limit,
87
+ offset: args.offset,
88
+ });
89
+ return emit(payload, { json: args.json, columns: MODEL_COLUMNS });
90
+ }
91
+
92
+ async function getModel(id, args) {
93
+ const payload = await get(`/api/v1/models/${encodeURIComponent(id)}`);
94
+ return emit(payload, { json: args.json });
95
+ }
96
+
97
+ function printModelsHelp() {
98
+ console.log(
99
+ [
100
+ 'Usage: abl models [flags]',
101
+ ' abl models <id>',
102
+ '',
103
+ 'List catalog models or fetch one by id (author/name).',
104
+ '',
105
+ 'Filter flags:',
106
+ ' --method <code> M-code, e.g. M4',
107
+ ' --family <str> base family, e.g. qwen, llama',
108
+ ' --author <handle> publisher handle',
109
+ ' --license <str> SPDX-ish license',
110
+ ' --downloads-gte <n> minimum downloads',
111
+ ' --downloads-lte <n> maximum downloads',
112
+ ' --created-after <date> ISO date, inclusive',
113
+ ' --created-before <date> ISO date, inclusive',
114
+ '',
115
+ 'Sort and paging:',
116
+ ' --sort <key> -downloads (default), created_at, -created_at, ...',
117
+ ' --limit <n> rows to return, 1-1000',
118
+ ' --offset <n> rows to skip',
119
+ '',
120
+ 'Output:',
121
+ ' --json machine-readable envelope (data + cost + ...)',
122
+ '',
123
+ 'Cost:',
124
+ ' - one model by id: 10 credits (models are priced above authors/methods',
125
+ ' because iterating over ids is functionally a slow dump)',
126
+ ' - list: 5-300+ credits, stepped by returned rows and multiplied by',
127
+ ' (1 + (offset/2500)²). Filter cheap, dump expensive — see',
128
+ ' https://abliteration.org/api#pricing for the full table.',
129
+ ].join('\n'),
130
+ );
131
+ return 0;
132
+ }
133
+
134
+ /* ---------- authors ---------- */
135
+
136
+ const AUTHOR_COLUMNS = [
137
+ { header: 'Author', key: 'author', align: 'left' },
138
+ { header: 'Models', key: 'total_models', align: 'right' },
139
+ { header: 'Downloads', key: 'total_downloads', align: 'right' },
140
+ { header: 'Likes', key: 'total_likes', align: 'right' },
141
+ ];
142
+
143
+ export async function authorsCmd(args) {
144
+ if (args.help) return printResourceHelp('authors', ['-total_downloads', '-total_models']);
145
+ if (args._[0]) return emit(
146
+ await get(`/api/v1/authors/${encodeURIComponent(args._[0])}`),
147
+ { json: args.json },
148
+ );
149
+ const payload = await get('/api/v1/authors', {
150
+ sort: args.sort,
151
+ limit: args.limit,
152
+ offset: args.offset,
153
+ });
154
+ return emit(payload, { json: args.json, columns: AUTHOR_COLUMNS });
155
+ }
156
+
157
+ /* ---------- methods ---------- */
158
+
159
+ const METHOD_COLUMNS = [
160
+ { header: 'Code', key: 'id', align: 'left' },
161
+ { header: 'Label', key: 'label', align: 'left' },
162
+ { header: 'Category', key: 'category', align: 'left' },
163
+ { header: 'Models', key: 'model_count', align: 'right' },
164
+ { header: 'Downloads', key: 'total_downloads', align: 'right' },
165
+ ];
166
+
167
+ export async function methodsCmd(args) {
168
+ if (args.help) return printResourceHelp('methods', []);
169
+ if (args._[0]) return emit(
170
+ await get(`/api/v1/methods/${encodeURIComponent(args._[0])}`),
171
+ { json: args.json },
172
+ );
173
+ return emit(await get('/api/v1/methods'), { json: args.json, columns: METHOD_COLUMNS });
174
+ }
175
+
176
+ /* ---------- datasets ---------- */
177
+
178
+ const DATASET_COLUMNS = [
179
+ { header: 'ID', key: 'id', align: 'left' },
180
+ { header: 'Category', key: 'category', align: 'left' },
181
+ { header: 'License', key: 'license', align: 'left' },
182
+ { header: 'Downloads', key: 'downloads_all', align: 'right' },
183
+ { header: 'Likes', key: 'likes', align: 'right' },
184
+ ];
185
+
186
+ export async function datasetsCmd(args) {
187
+ if (args.help) return printResourceHelp('datasets', ['-downloads', 'created_at']);
188
+ if (args._[0]) return emit(
189
+ await get(`/api/v1/datasets/${encodeURIComponent(args._[0])}`),
190
+ { json: args.json },
191
+ );
192
+ const payload = await get('/api/v1/datasets', {
193
+ category: args.category,
194
+ author: args.author,
195
+ license: args.license,
196
+ sort: args.sort,
197
+ limit: args.limit,
198
+ offset: args.offset,
199
+ });
200
+ return emit(payload, { json: args.json, columns: DATASET_COLUMNS });
201
+ }
202
+
203
+ /* ---------- indices ---------- */
204
+
205
+ const INDEX_COLUMNS = [
206
+ { header: 'ID', key: 'id', align: 'left' },
207
+ { header: 'Name', key: 'name', align: 'left' },
208
+ { header: 'Value', key: 'value', align: 'right', format: (v, r) => v === null ? '-' : `${fmtNum(v)}${r.unit === 'percent' ? '%' : r.unit === 'days' ? 'd' : ''}` },
209
+ { header: 'Source', key: 'source', align: 'left' },
210
+ { header: 'Measured', key: 'measured', align: 'left', format: (v) => v ? 'yes' : 'no' },
211
+ ];
212
+
213
+ export async function indicesCmd(args) {
214
+ if (args.help) {
215
+ console.log(
216
+ [
217
+ 'Usage: abl indices',
218
+ ' abl indices <id>',
219
+ '',
220
+ 'Fetch the four flagship indices at once (Freedom Velocity, Weaponization,',
221
+ 'F2W Latency, DAI) or one by id with its full history.',
222
+ '',
223
+ 'Cost:',
224
+ ' - all four: 20 credits (heavy: Freedom Velocity is a full scan)',
225
+ ' - one by id: 10 credits (full time-series, not a row lookup)',
226
+ ].join('\n'),
227
+ );
228
+ return 0;
229
+ }
230
+ if (args._[0]) return emit(
231
+ await get(`/api/v1/indices/${encodeURIComponent(args._[0])}`),
232
+ { json: args.json },
233
+ );
234
+ return emit(await get('/api/v1/indices'), { json: args.json, columns: INDEX_COLUMNS });
235
+ }
236
+
237
+ /* ---------- timeline ---------- */
238
+
239
+ const TIMELINE_COLUMNS = [
240
+ { header: 'Date', key: 'date', align: 'left' },
241
+ { header: 'Type', key: 'type', align: 'left' },
242
+ { header: 'Title', key: 'title', align: 'left' },
243
+ ];
244
+
245
+ export async function timelineCmd(args) {
246
+ if (args.help) {
247
+ console.log(
248
+ [
249
+ 'Usage: abl timeline [flags]',
250
+ '',
251
+ 'Fetch curated field events - delistings, takedowns, notable releases.',
252
+ '',
253
+ 'Filter flags:',
254
+ ' --from <date> ISO date, inclusive lower bound',
255
+ ' --to <date> ISO date, inclusive upper bound',
256
+ ' --event-type <str> filter by event type',
257
+ ' --limit <n> rows to return, default 100',
258
+ '',
259
+ 'Output:',
260
+ ' --json machine-readable envelope',
261
+ '',
262
+ 'Cost: 50 credits, flat. Timeline is the largest single response the API',
263
+ 'serves, hence the higher cost.',
264
+ ].join('\n'),
265
+ );
266
+ return 0;
267
+ }
268
+ const payload = await get('/api/v1/timeline', {
269
+ from: args.from,
270
+ to: args.to,
271
+ event_type: args['event-type'],
272
+ limit: args.limit,
273
+ });
274
+ return emit(payload, { json: args.json, columns: TIMELINE_COLUMNS });
275
+ }
276
+
277
+ /* ---------- shared help printer ---------- */
278
+
279
+ function printResourceHelp(name, sortHints) {
280
+ const filterHints = {
281
+ authors: [],
282
+ methods: [],
283
+ datasets: [
284
+ ' --category <str> workflow stage, e.g. heal',
285
+ ' --author <handle> publisher handle',
286
+ ' --license <str> SPDX-ish license',
287
+ ],
288
+ }[name] || [];
289
+ console.log(
290
+ [
291
+ `Usage: abl ${name} [flags]`,
292
+ ` abl ${name} <id>`,
293
+ '',
294
+ `List ${name} or fetch one by id.`,
295
+ '',
296
+ ...(filterHints.length ? ['Filter flags:', ...filterHints, ''] : []),
297
+ 'Sort and paging:',
298
+ ` --sort <key> ${sortHints.join(', ') || 'default sort'}`,
299
+ ' --limit <n> rows to return, 1-1000',
300
+ ' --offset <n> rows to skip',
301
+ '',
302
+ 'Output:',
303
+ ' --json machine-readable envelope',
304
+ '',
305
+ 'Cost:',
306
+ ` - single entity: 1 credit`,
307
+ ` - list: 5-15 credits, by rows returned (5 for the first 100 rows,`,
308
+ ` +1 per further 100, capped at 15). Empty result still costs 5.`,
309
+ ].join('\n'),
310
+ );
311
+ return 0;
312
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * HTTP client for the abliteration.org API.
3
+ *
4
+ * One place that talks to the network so every command benefits from the
5
+ * same error mapping. Callers get either the parsed `data` payload (unwrapped
6
+ * from the envelope) plus meta on success, or a typed CliError on failure —
7
+ * they never touch fetch, headers, or status codes themselves.
8
+ *
9
+ * The envelope shape mirrors src/lib/api/v1/response.ts on the server:
10
+ * { data, cost, credits_remaining, rate_limit_*, metering, request_id }
11
+ * Only `data` gets returned to callers; everything else is bundled into
12
+ * `meta` for commands that want to print it (all of them do, for the
13
+ * "cost: 5 · balance: 495" footer).
14
+ */
15
+
16
+ import { readConfig } from './config.mjs';
17
+
18
+ /** Default API base for the abliteration.org production deployment. */
19
+ const DEFAULT_BASE = 'https://abliteration.org';
20
+
21
+ /**
22
+ * A CliError carries an exit code so the top-level catch can hand it to
23
+ * process.exit. Kept small: `message` is what the user sees, `code` is the
24
+ * machine-readable slug returned by the server or one we minted locally,
25
+ * `exitCode` is the shell exit status. Nothing else — a stack trace on a
26
+ * 402 would just be noise.
27
+ */
28
+ export class CliError extends Error {
29
+ constructor(message, { code = 'error', exitCode = 1 } = {}) {
30
+ super(message);
31
+ this.name = 'CliError';
32
+ this.code = code;
33
+ this.exitCode = exitCode;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Resolve the API base URL. Env var wins so a developer testing against a
39
+ * staging deployment can point every command at it in one place; otherwise
40
+ * we use the production host. The trailing slash is stripped so callers can
41
+ * always append `/api/v1/...` without a double slash.
42
+ */
43
+ export function apiBase() {
44
+ const raw = process.env.ABLITERATION_API_BASE ?? DEFAULT_BASE;
45
+ return raw.replace(/\/+$/, '');
46
+ }
47
+
48
+ /**
49
+ * Resolve the API key. Env variable ABLITERATION_API_KEY wins so a CI job can
50
+ * override without editing the on-disk config; otherwise we read the config
51
+ * file written by `abl login`. Missing key is a CliError, not undefined,
52
+ * because every non-help command needs one.
53
+ */
54
+ export async function apiKey() {
55
+ const env = process.env.ABLITERATION_API_KEY;
56
+ if (env && env.trim()) return env.trim();
57
+ const cfg = await readConfig();
58
+ if (cfg?.key) return cfg.key;
59
+ throw new CliError(
60
+ 'no API key found. run `abl login`, or export ABLITERATION_API_KEY=abl_live_...',
61
+ { code: 'no_key', exitCode: 2 },
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Perform an authenticated GET against /api/v1/<path>. Returns
67
+ * `{ data, meta }` on success. Errors are mapped to CliError with the
68
+ * server's own slug when we can extract one.
69
+ *
70
+ * `params` accepts strings, numbers, and booleans. Undefined and null are
71
+ * dropped so a call site can pass every flag it received without filtering
72
+ * first. Arrays are joined with commas because the current API takes CSV
73
+ * for list-valued filters (methods, families).
74
+ */
75
+ export async function get(path, params = {}) {
76
+ const key = await apiKey();
77
+ const url = new URL(apiBase() + path);
78
+ for (const [k, v] of Object.entries(params)) {
79
+ if (v === undefined || v === null || v === '') continue;
80
+ if (Array.isArray(v)) {
81
+ if (v.length === 0) continue;
82
+ url.searchParams.set(k, v.join(','));
83
+ } else {
84
+ url.searchParams.set(k, String(v));
85
+ }
86
+ }
87
+
88
+ let res;
89
+ try {
90
+ res = await fetch(url.toString(), {
91
+ headers: {
92
+ authorization: `Bearer ${key}`,
93
+ 'user-agent': `abliteration-cli/1.0.0 node/${process.versions.node}`,
94
+ accept: 'application/json',
95
+ },
96
+ });
97
+ } catch (err) {
98
+ // Network-layer failure — DNS, TLS, connection refused. The server side
99
+ // never saw the request, so nothing was charged. Callers get a
100
+ // network_error slug so a wrapper script can retry with backoff.
101
+ throw new CliError(`network error: ${err?.message ?? err}`, {
102
+ code: 'network_error',
103
+ exitCode: 3,
104
+ });
105
+ }
106
+
107
+ // A response with no body at all (204, or a proxy blip) is not something
108
+ // the API produces on this route. Bail before parsing so json() does not
109
+ // throw an opaque SyntaxError.
110
+ const text = await res.text();
111
+ let body;
112
+ try {
113
+ body = text ? JSON.parse(text) : {};
114
+ } catch {
115
+ throw new CliError(
116
+ `unexpected non-JSON response from ${url.pathname} (status ${res.status})`,
117
+ { code: 'bad_response', exitCode: 4 },
118
+ );
119
+ }
120
+
121
+ if (!res.ok) {
122
+ // Server-returned errors carry an `error` slug and a `message` from
123
+ // response.ts. Prefer the server's message when present because it names
124
+ // the specific route and the specific balance for 402s.
125
+ const code = body?.error ?? `http_${res.status}`;
126
+ const msg = body?.message ?? `HTTP ${res.status}`;
127
+ const cliCode =
128
+ res.status === 401 ? 'unauthorized' :
129
+ res.status === 402 ? code :
130
+ res.status === 404 ? 'not_found' :
131
+ res.status === 429 ? 'rate_limit' :
132
+ code;
133
+ throw new CliError(msg, { code: cliCode, exitCode: exitFor(res.status) });
134
+ }
135
+
136
+ return {
137
+ data: body?.data,
138
+ meta: {
139
+ cost: body?.cost ?? 0,
140
+ creditsRemaining: body?.credits_remaining ?? null,
141
+ rateLimit: {
142
+ limit: body?.rate_limit_limit ?? null,
143
+ remaining: body?.rate_limit_remaining ?? null,
144
+ reset: body?.rate_limit_reset ?? null,
145
+ },
146
+ metering: body?.metering ?? null,
147
+ requestId: body?.request_id ?? null,
148
+ },
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Map HTTP status to a shell exit code. Not every status gets a distinct
154
+ * number — a wrapper script only cares about the coarse category:
155
+ * 2 – configuration / auth (401)
156
+ * 3 – transient (429, 5xx)
157
+ * 4 – permanent input problem (400, 404, 422)
158
+ * 5 – payment (402)
159
+ * Anything else is a plain 1.
160
+ */
161
+ function exitFor(status) {
162
+ if (status === 401) return 2;
163
+ if (status === 402) return 5;
164
+ if (status === 429 || status >= 500) return 3;
165
+ if (status === 400 || status === 404 || status === 422) return 4;
166
+ return 1;
167
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Read and write ~/.abliteration/config.json.
3
+ *
4
+ * Storing an API key on disk is a permission-and-portability problem more
5
+ * than a cryptographic one — anyone who can read the file can already read
6
+ * the process env of a script that uses it. What we buy with a real config
7
+ * file is:
8
+ * 1. One place the CLI reads from, so a login persists across shells.
9
+ * 2. File-mode 0600 so a shared laptop's other users cannot cat it.
10
+ * 3. A JSON shape we can extend (per-profile keys, region overrides)
11
+ * without rewriting the loader.
12
+ *
13
+ * Env variables still take precedence in client.mjs — that path never
14
+ * touches this file. The config is the "no ceremony" default for the
15
+ * interactive terminal user.
16
+ */
17
+
18
+ import { readFile, writeFile, mkdir, chmod, unlink, stat } from 'node:fs/promises';
19
+ import { homedir } from 'node:os';
20
+ import { dirname, join } from 'node:path';
21
+
22
+ /**
23
+ * Location of the config directory and file. Resolved at call time (not at
24
+ * import) so tests can set HOME to a temp dir and get isolated files without
25
+ * a module reload.
26
+ */
27
+ export function configPath() {
28
+ return join(homedir(), '.abliteration', 'config.json');
29
+ }
30
+
31
+ /**
32
+ * Read the config file if it exists. Returns null when the file is missing —
33
+ * that is the normal state before `abl login`. A file that exists but is
34
+ * unreadable, malformed, or contains something other than an object counts
35
+ * as "no config"; the CLI then falls through to whatever env var the caller
36
+ * set, or prompts for `abl login`. Silent recovery matches the "the CLI just
37
+ * works" expectation better than a syntax-error trace.
38
+ */
39
+ export async function readConfig() {
40
+ const path = configPath();
41
+ let raw;
42
+ try {
43
+ raw = await readFile(path, 'utf8');
44
+ } catch (err) {
45
+ if (err?.code === 'ENOENT') return null;
46
+ // Anything else — permission denied, disk error — is worth surfacing
47
+ // rather than silently ignoring, because the user might have chmod'd
48
+ // themselves out of their own config.
49
+ throw err;
50
+ }
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(raw);
54
+ } catch {
55
+ return null;
56
+ }
57
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
58
+ return parsed;
59
+ }
60
+
61
+ /**
62
+ * Write a fresh config file. Always overwrites — the CLI has one key at a
63
+ * time and login supersedes whatever was there. Creates the directory tree
64
+ * if needed and clamps permissions to 0600 (owner read/write only) after
65
+ * the write, because writeFile alone honours the process umask which may
66
+ * be 022 (world-readable). Directory perms follow the same logic at 0700.
67
+ *
68
+ * We write to a temp file and rename so a partial write during a crash
69
+ * leaves the previous config intact instead of a truncated JSON that
70
+ * subsequent reads would treat as no config at all.
71
+ */
72
+ export async function writeConfig(cfg) {
73
+ const path = configPath();
74
+ const dir = dirname(path);
75
+ await mkdir(dir, { recursive: true, mode: 0o700 });
76
+ // mkdir + recursive does not chmod an existing directory, so set it
77
+ // explicitly. Failures here are not fatal — the file itself will still
78
+ // be 0600, which is what actually protects the secret.
79
+ try { await chmod(dir, 0o700); } catch { /* best effort */ }
80
+
81
+ const tmp = `${path}.tmp.${process.pid}`;
82
+ await writeFile(tmp, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
83
+ await chmod(tmp, 0o600);
84
+ // Atomic rename replaces the old file in one step, so any concurrent
85
+ // reader sees either the old file or the new one, never a truncation.
86
+ const { rename } = await import('node:fs/promises');
87
+ await rename(tmp, path);
88
+ }
89
+
90
+ /**
91
+ * Remove the config file. Used by `abl logout`. Missing file is not an
92
+ * error — the caller wanted "no config" and got it.
93
+ */
94
+ export async function deleteConfig() {
95
+ const path = configPath();
96
+ try {
97
+ await unlink(path);
98
+ } catch (err) {
99
+ if (err?.code === 'ENOENT') return;
100
+ throw err;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Whether the config file exists at all. Cheaper than reading it and used
106
+ * by `abl whoami` to decide between "signed in as X" and "not signed in".
107
+ */
108
+ export async function configExists() {
109
+ try {
110
+ await stat(configPath());
111
+ return true;
112
+ } catch (err) {
113
+ if (err?.code === 'ENOENT') return false;
114
+ throw err;
115
+ }
116
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Human-readable table output.
3
+ *
4
+ * The CLI has two output modes: default (a formatted table into a TTY) and
5
+ * --json (a machine-readable dump into anything). Everything here lives in
6
+ * default mode; --json bypasses this file entirely by JSON.stringifying the
7
+ * server payload as-is.
8
+ *
9
+ * The table is width-aware but not smart. Every column measures the widest
10
+ * string in itself, pads with spaces to that width, and joins with two
11
+ * spaces. That produces the plain aligned columns the tizer on the home
12
+ * page promises — no borders, no boxes, no colour. Boxes and colour are
13
+ * the two most common ways a CLI becomes unreadable on the wrong terminal
14
+ * (paged into less -R, dumped into a file, viewed on a light theme with a
15
+ * dark palette). Plain columns render everywhere.
16
+ */
17
+
18
+ /**
19
+ * Return the visible width of a string. process.stdout carries the terminal
20
+ * columns when stdout is a TTY; when it is piped we default to 80 so a
21
+ * `abl models | less` still produces a sensible table rather than a single
22
+ * wrapped line.
23
+ */
24
+ export function termWidth() {
25
+ return process.stdout.columns || 80;
26
+ }
27
+
28
+ /**
29
+ * Format a number with thousand separators, matching the download counts on
30
+ * the /models pages. `null` and `undefined` render as `-` so a row with a
31
+ * missing column does not collapse the whole table.
32
+ */
33
+ export function fmtNum(n) {
34
+ if (n === null || n === undefined) return '-';
35
+ const int = Math.trunc(n);
36
+ return int.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
37
+ }
38
+
39
+ /**
40
+ * Render a list of objects as an aligned table.
41
+ *
42
+ * `columns` is an array of { header, key, align?, format? }. `align` is
43
+ * 'left' by default; 'right' is honoured on numeric columns (downloads,
44
+ * likes, params_b). `format(value, row)` returns a string; when omitted we
45
+ * use `fmtNum` on numbers and `String()` on everything else.
46
+ *
47
+ * Rows are truncated per-column if the total width would exceed the
48
+ * terminal width. The truncation adds a trailing `…` so the reader knows
49
+ * the string is cut. Header line uses `-` separators one line down, styled
50
+ * like `git log --pretty=short` output.
51
+ */
52
+ export function renderTable(rows, columns) {
53
+ if (!rows.length) return '(no rows)\n';
54
+
55
+ // Compute each cell's formatted string once so we can measure widths and
56
+ // print without recomputing. Store parallel to `rows` in the same order.
57
+ const cells = rows.map((row) =>
58
+ columns.map((col) => {
59
+ const raw = row[col.key];
60
+ if (col.format) return col.format(raw, row);
61
+ if (typeof raw === 'number') return fmtNum(raw);
62
+ if (raw === null || raw === undefined) return '-';
63
+ return String(raw);
64
+ }),
65
+ );
66
+
67
+ // Column widths: max of header and every cell in that column.
68
+ const widths = columns.map((col, i) => {
69
+ const cellMax = cells.reduce((max, r) => Math.max(max, r[i].length), 0);
70
+ return Math.max(col.header.length, cellMax);
71
+ });
72
+
73
+ // If the sum of widths + separators is wider than the terminal, shrink
74
+ // the longest text column (the leftmost 'left' column by default). This
75
+ // keeps the numeric columns readable and truncates the id/name column.
76
+ const sep = 2;
77
+ const target = termWidth();
78
+ let total = widths.reduce((a, b) => a + b, 0) + sep * (widths.length - 1);
79
+ if (total > target) {
80
+ // Find the widest left-aligned column and shrink it by the overflow.
81
+ const overflow = total - target;
82
+ let idx = -1;
83
+ let widest = -1;
84
+ for (let i = 0; i < columns.length; i++) {
85
+ const align = columns[i].align ?? 'left';
86
+ if (align === 'left' && widths[i] > widest) {
87
+ widest = widths[i];
88
+ idx = i;
89
+ }
90
+ }
91
+ if (idx >= 0 && widths[idx] - overflow > 4) {
92
+ widths[idx] -= overflow;
93
+ }
94
+ }
95
+
96
+ const pad = (str, width, align) => {
97
+ if (str.length > width) {
98
+ // Truncate with an ellipsis so the reader sees the row was cut.
99
+ return str.slice(0, Math.max(1, width - 1)) + '…';
100
+ }
101
+ return align === 'right' ? str.padStart(width, ' ') : str.padEnd(width, ' ');
102
+ };
103
+
104
+ const line = (values) => values.map((v, i) => pad(v, widths[i], columns[i].align ?? 'left')).join(' ');
105
+
106
+ const out = [];
107
+ out.push(line(columns.map((c) => c.header)));
108
+ out.push(line(widths.map((w) => '-'.repeat(Math.min(w, 32)))));
109
+ for (const c of cells) out.push(line(c));
110
+ return out.join('\n') + '\n';
111
+ }
112
+
113
+ /**
114
+ * Render a single-entity payload as an aligned "key: value" block. Used by
115
+ * `abl models <id>`, `abl authors <id>`, etc. — every field on its own line
116
+ * so grep and awk work naturally.
117
+ */
118
+ export function renderEntity(obj) {
119
+ const entries = Object.entries(obj).filter(([, v]) => v !== undefined);
120
+ const keyWidth = Math.max(...entries.map(([k]) => k.length));
121
+ return (
122
+ entries
123
+ .map(([k, v]) => {
124
+ const key = k.padEnd(keyWidth);
125
+ const value = v === null
126
+ ? '-'
127
+ : typeof v === 'object'
128
+ ? JSON.stringify(v)
129
+ : typeof v === 'number'
130
+ ? fmtNum(v)
131
+ : String(v);
132
+ return `${key} ${value}`;
133
+ })
134
+ .join('\n') + '\n'
135
+ );
136
+ }
137
+
138
+ /**
139
+ * Footer line printed after every successful command:
140
+ * cost: 5 credits · balance: 495 · request_id: abc-…
141
+ *
142
+ * When metering is off (preview mode on the server) we replace the balance
143
+ * with the word "preview" so a scripted caller does not read a stale number
144
+ * as if it were the truth.
145
+ */
146
+ export function renderFooter(meta) {
147
+ const parts = [`cost: ${meta.cost} cr`];
148
+ if (meta.metering === 'preview') {
149
+ parts.push('preview (not billed)');
150
+ } else if (meta.creditsRemaining !== null) {
151
+ parts.push(`balance: ${fmtNum(meta.creditsRemaining)}`);
152
+ }
153
+ if (meta.rateLimit?.remaining !== null && meta.rateLimit?.limit !== null) {
154
+ parts.push(`rate: ${meta.rateLimit.remaining}/${meta.rateLimit.limit}`);
155
+ }
156
+ return parts.join(' · ') + '\n';
157
+ }
package/src/main.mjs ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Argument parser and command dispatcher.
3
+ *
4
+ * The parser is deliberately small: no external dependency, no plugin
5
+ * system, just enough to route `abl models --method M4 --limit 5` and
6
+ * `abl models mlabonne/Qwen3-30B-A3B-abliterated` to the right handler.
7
+ *
8
+ * Rules:
9
+ * - The first non-flag argument is the command (`models`, `login`, …).
10
+ * - Anything after that until the next flag is a positional (the id).
11
+ * - `--flag value` and `--flag=value` are equivalent.
12
+ * - `--flag` with no value is a boolean true.
13
+ * - `--` stops flag parsing so an id containing `--` still passes through.
14
+ * - Unknown commands print the top-level help and exit 2.
15
+ *
16
+ * We do not implement short flags (`-m` for `--method`). Every abbreviation
17
+ * is another rule the reader has to remember and the CLI already lives at
18
+ * the terminal, where tab completion covers the "short" case.
19
+ */
20
+
21
+ import { loginCmd, logoutCmd, whoamiCmd, creditsCmd } from './commands/auth.mjs';
22
+ import {
23
+ modelsCmd, authorsCmd, methodsCmd, datasetsCmd, indicesCmd, timelineCmd,
24
+ } from './commands/catalog.mjs';
25
+
26
+ const COMMANDS = {
27
+ login: loginCmd,
28
+ logout: logoutCmd,
29
+ whoami: whoamiCmd,
30
+ credits: creditsCmd,
31
+ models: modelsCmd,
32
+ authors: authorsCmd,
33
+ methods: methodsCmd,
34
+ datasets: datasetsCmd,
35
+ indices: indicesCmd,
36
+ timeline: timelineCmd,
37
+ };
38
+
39
+ const COMMAND_HELP = {
40
+ login: 'sign in and save a key to ~/.abliteration/config.json',
41
+ logout: 'remove the local key',
42
+ whoami: 'show the current key prefix and balance',
43
+ credits: 'show credit balance only',
44
+ models: 'list catalog models or fetch one by id',
45
+ authors: 'list authors or fetch one by id',
46
+ methods: 'list the M-code taxonomy or fetch one by id',
47
+ datasets: 'list alignment datasets or fetch one by id',
48
+ indices: 'fetch the four flagship indices or one by id',
49
+ timeline: 'fetch curated field events',
50
+ };
51
+
52
+ /**
53
+ * Parse process.argv-style tokens into `{ _: [positional], flag: value, … }`.
54
+ * Values that look like numbers become numbers. `--json` alone is `true`.
55
+ */
56
+ export function parseArgs(argv) {
57
+ const out = { _: [] };
58
+ let stopFlags = false;
59
+ for (let i = 0; i < argv.length; i++) {
60
+ const tok = argv[i];
61
+ if (stopFlags) {
62
+ out._.push(tok);
63
+ continue;
64
+ }
65
+ if (tok === '--') {
66
+ stopFlags = true;
67
+ continue;
68
+ }
69
+ if (tok.startsWith('--')) {
70
+ const eq = tok.indexOf('=');
71
+ let name, value;
72
+ if (eq >= 0) {
73
+ name = tok.slice(2, eq);
74
+ value = tok.slice(eq + 1);
75
+ } else {
76
+ name = tok.slice(2);
77
+ // A flag consumes the next token as its value unless that token
78
+ // starts with `--` (another flag), in which case the current flag
79
+ // is a boolean.
80
+ const next = argv[i + 1];
81
+ if (next !== undefined && !next.startsWith('--')) {
82
+ value = next;
83
+ i++;
84
+ } else {
85
+ value = true;
86
+ }
87
+ }
88
+ if (typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value)) {
89
+ value = Number(value);
90
+ }
91
+ out[name] = value;
92
+ continue;
93
+ }
94
+ out._.push(tok);
95
+ }
96
+ return out;
97
+ }
98
+
99
+ function printTopHelp() {
100
+ console.log(
101
+ [
102
+ 'abliteration CLI - abliteration.org catalog client',
103
+ '',
104
+ 'Usage: abl <command> [args] [flags]',
105
+ '',
106
+ 'Auth:',
107
+ ' abl login sign in and save a key',
108
+ ' abl logout remove the local key',
109
+ ' abl whoami show key prefix and balance (5 cr)',
110
+ ' abl credits show credit balance (5 cr)',
111
+ '',
112
+ 'Catalog:',
113
+ ' abl models list models (5-300+ cr, offset-multiplied)',
114
+ ' abl models <id> fetch one model (10 cr)',
115
+ ' abl authors list authors (5-15 cr)',
116
+ ' abl authors <id> fetch one author (1 cr)',
117
+ ' abl methods list the M-code taxonomy (5-15 cr)',
118
+ ' abl methods <id> fetch one method (1 cr)',
119
+ ' abl datasets list alignment datasets (5-15 cr)',
120
+ ' abl datasets <id> fetch one dataset (1 cr)',
121
+ ' abl indices all four flagship indices (20 cr)',
122
+ ' abl indices <id> one index with history (10 cr)',
123
+ ' abl timeline curated field events (50 cr)',
124
+ '',
125
+ 'Global flags:',
126
+ ' --json machine-readable envelope on stdout',
127
+ ' --help command-specific help',
128
+ '',
129
+ 'Environment:',
130
+ ' ABLITERATION_API_KEY overrides the config file',
131
+ ' ABLITERATION_API_BASE overrides the default server (staging)',
132
+ ' ABL_DEBUG=1 print full stack traces on errors',
133
+ '',
134
+ 'Full documentation: https://abliteration.org/api',
135
+ 'Machine spec: https://abliteration.org/api/v1/openapi.json',
136
+ ].join('\n'),
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Dispatch a parsed argv into a command. Returns the command's exit code,
142
+ * which the shell script wrapper hands to process.exit.
143
+ */
144
+ export async function main(argv) {
145
+ const args = parseArgs(argv);
146
+
147
+ // `abl` with no args, `abl --help`, and `abl help` all print the top help.
148
+ if (args._.length === 0 || args.help && args._.length === 0) {
149
+ printTopHelp();
150
+ return 0;
151
+ }
152
+
153
+ const [command, ...rest] = args._;
154
+
155
+ // `abl help models` is the same as `abl models --help`.
156
+ if (command === 'help' && rest[0]) {
157
+ const target = rest[0];
158
+ const fn = COMMANDS[target];
159
+ if (!fn) {
160
+ console.error(`abl: unknown command "${target}"`);
161
+ printTopHelp();
162
+ return 2;
163
+ }
164
+ return fn({ ...args, _: rest.slice(1), help: true });
165
+ }
166
+
167
+ const fn = COMMANDS[command];
168
+ if (!fn) {
169
+ console.error(`abl: unknown command "${command}"`);
170
+ console.error('');
171
+ printTopHelp();
172
+ return 2;
173
+ }
174
+
175
+ // Re-parse so the command sees positionals that were AFTER its name and
176
+ // not the command name itself. --help is preserved.
177
+ return fn({ ...args, _: rest });
178
+ }