lobstack 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lobstack
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,188 @@
1
+ # lobstack
2
+
3
+ One key, every model, and what each call cost.
4
+
5
+ ```bash
6
+ npx lobstack init
7
+ npx lobstack # the UI
8
+ ```
9
+
10
+ ```
11
+ lobstack auto 2 calls $0.002200 saved $0.008800
12
+ ───────────────────────────────────────────────────────────────────────────────
13
+ you explain a b-tree
14
+
15
+ lob A B-tree keeps sorted data in a shallow, wide tree, so a lookup touches
16
+ very few nodes even when the table is enormous. Each node holds many keys
17
+ and many child pointers, which is what keeps the height down to three or
18
+ four levels for tables with billions of rows.
19
+ ───────────────────────────────────────────────────────────────────────────────
20
+ model claude-haiku-4-5 asked claude-opus-5
21
+ tokens 400/140 cost $0.001100 saved $0.004400 in 123ms
22
+ against claude-opus-5, the model you named
23
+ ›
24
+ ^C quit Tab model PgUp scroll ^L redraw /help for the rest
25
+ ```
26
+
27
+ The bottom two panes are the reason this exists. A chat window on its own is
28
+ worth nothing — every tool has one. What you cannot get anywhere else is the
29
+ price of the call you just made, pinned under the conversation, and a running
30
+ total in the corner that moves while you work.
31
+
32
+ ## The UI
33
+
34
+ `lobstack` with no arguments opens it, when — and only when — there is a
35
+ terminal on both ends and a key already available. In a pipeline, in CI, or on a
36
+ first run before `init`, bare `lobstack` prints the same help it always printed,
37
+ which is also the screen that tells you to run `init`. `lobstack tui` asks for it
38
+ by name and is the stable spelling for a script or a shortcut.
39
+
40
+ ### Keys
41
+
42
+ | | |
43
+ |---|---|
44
+ | `Enter` | send |
45
+ | `\` at end of line | keep typing on a new line. `Alt+Enter` too, where the terminal sends it |
46
+ | `Ctrl+C` | cancel a streaming answer; quit when nothing is streaming |
47
+ | `Ctrl+D` | quit on an empty line |
48
+ | `Tab` | complete a slash command, or open the model picker on an empty line |
49
+ | `Up` / `Down` | walk back through what you sent |
50
+ | `PgUp` / `PgDn` | scroll the transcript. `Esc` returns to the live tail |
51
+ | `Ctrl+L` | repaint, for when something else wrote over the screen |
52
+ | `Ctrl+A` `Ctrl+E` `Ctrl+U` `Ctrl+K` `Ctrl+W` | the readline edits you already know |
53
+
54
+ Shift+Enter is not bound: most terminals send nothing a program can tell apart
55
+ from a plain Enter, and promising a key that silently does the wrong thing is
56
+ worse than not having it.
57
+
58
+ ### Commands
59
+
60
+ | | |
61
+ |---|---|
62
+ | `/model [name]` | set the model, or open the picker |
63
+ | `/models` | what the gateway will serve, with prices |
64
+ | `/spend [days]` | what you have actually spent, from the usage API |
65
+ | `/receipt` | every field of the last receipt, verbatim, plus the session tally |
66
+ | `/proxy [port]` | serve the OpenAI-compatible endpoint from this process |
67
+ | `/new` | forget the conversation, keep the session totals |
68
+ | `/clear` | clear the screen, keep the conversation |
69
+ | `/help` `/quit` | |
70
+
71
+ `/proxy` is the one worth knowing about. Point Cursor or Aider at the port it
72
+ prints and every call those tools make appears in this transcript with its
73
+ price, in the same running total as what you type by hand.
74
+
75
+ ## Every terminal, and every way out of one
76
+
77
+ The UI is the interesting case; the boring ones are where a TUI usually breaks.
78
+
79
+ | | |
80
+ |---|---|
81
+ | Piped or redirected | Never draws. `echo "hi" \| lobstack > out.txt` treats stdin as the prompt and puts the answer, and only the answer, in the file. |
82
+ | Keyboard in, file out | `lobstack tui > log.txt` runs a plain prompt loop: answers on stdout, prompts and receipts on stderr, so the file stays clean. |
83
+ | `TERM=dumb` | A dumb terminal has no cursor addressing, so a full-screen frame is not a degraded experience, it is garbage. Same plain loop, and it says why. |
84
+ | `NO_COLOR` | No escapes at all. The colour depth is a number — 0, 16, 256, or truecolour — and the frame is assembled the same way at every one of them. |
85
+ | No UTF-8 locale | Box drawing falls back to `-` and `>`. Force it with `LOBSTACK_ASCII=1`. |
86
+ | Resize | `SIGWINCH` drops the diff baseline and repaints whole, because every row's content depends on the width. |
87
+ | Narrow | 40 columns is the width it aims at. Below that the layout stacks instead of tabulating, and a figure is never truncated — `$0.004400` clipped to `$0.004` is not a shorter number, it is a wrong one, so labels and then whole fields drop first. |
88
+ | Short | Rows go to the receipt before the transcript. You can scroll back for history; you cannot scroll back for a price you never saw. |
89
+ | Ctrl+C, SIGTERM, SIGHUP, a crash | The terminal comes back. Every exit path — including `process.exit` from anywhere and an uncaught throw — runs the same restore, and a crash still prints its stack. |
90
+
91
+ `--force` draws the UI where `isTTY` says there is no terminal but a person is
92
+ watching anyway: a wrapper, `docker run` without `-t`, an unusual runner.
93
+
94
+ Mouse reporting is never switched on. It breaks click-to-select in JetBrains and
95
+ in some tmux configurations, and a process that dies before disabling it leaves
96
+ your shell reading mouse packets as keystrokes. Nothing here needs a mouse.
97
+
98
+ ## Commands outside the UI
99
+
100
+ | | |
101
+ |---|---|
102
+ | `lobstack init` | Save a key to `~/.lobstack/config.json`, mode 0600. Verifies it before writing. |
103
+ | `lobstack chat "<prompt>"` | One call, streamed. Answer to stdout, receipt to stderr, so `> out.txt` gives you the answer alone. |
104
+ | `lobstack models` | What the gateway will serve, with prices. |
105
+ | `lobstack spend [--days 7]` | What you have spent. Needs a key with the `usage:read` scope. |
106
+ | `lobstack proxy [--port 8787]` | A local OpenAI-compatible endpoint. |
107
+
108
+ Flags: `--model` (default `auto`), `--key`, `--base`, `--json`, `--force`.
109
+ `LOBSTACK_API_KEY` and `LOBSTACK_BASE_URL` win over the saved config.
110
+
111
+ ## The proxy
112
+
113
+ ```bash
114
+ lobstack proxy
115
+ ```
116
+
117
+ ```
118
+ Listening on http://127.0.0.1:8787/v1 -> https://www.lobstack.ai
119
+
120
+ Point any OpenAI-compatible tool at it:
121
+ OPENAI_BASE_URL=http://127.0.0.1:8787/v1
122
+ OPENAI_API_KEY=anything
123
+ ```
124
+
125
+ Change one base URL in Cursor, Aider, Continue, or anything else with an
126
+ OpenAI-compatible setting, and every call it makes goes through the router and
127
+ lands in your Console — with a line per request telling you what it cost. The
128
+ tool never sees your Lobstack key; this process holds it.
129
+
130
+ It binds loopback only. This is a process that holds a credential and answers
131
+ unauthenticated requests, so anything that can reach the port can spend your
132
+ money.
133
+
134
+ ## Notes on three things that look like details
135
+
136
+ **Zero dependencies, including the UI.** `fetch`, `node:http` and
137
+ `node:readline` are all in the runtime, so `npx lobstack` starts immediately
138
+ rather than resolving a tree first — and there is no supply chain between your
139
+ key and us. That did not change to add a full-screen interface: `node:readline`
140
+ already has the escape-sequence decoder, and the rest is nine escape sequences
141
+ written out by hand in `src/tty.mjs`, with a comment on each about why the
142
+ conservative one was chosen over the clever one. A process holding an
143
+ `lsk_live_` credential does not get to pull a dependency tree to draw a box.
144
+
145
+ **The bare domain is corrected, out loud.** `lobstack.ai` redirects to
146
+ `www.lobstack.ai`, and [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#name-redirection-3xx)
147
+ requires every HTTP client to drop `Authorization` when a redirect changes host.
148
+ Point `--base` at the apex and you would get "missing credentials" while holding
149
+ a perfectly good key. So the CLI rewrites it and prints a line saying it did,
150
+ because a silent fix teaches you nothing about why your own code will fail the
151
+ same way.
152
+
153
+ **Text from a model is sanitised before it is drawn.** A completion is
154
+ attacker-influenced text about to be pasted into a terminal. Left alone, an
155
+ `ESC[2J` inside an answer wipes the frame and an OSC sequence rewrites your
156
+ window title. Escapes are removed, not rendered.
157
+
158
+ ## Cost is read, never computed
159
+
160
+ The gateway puts the price on the final SSE frame under `x_lobstack`, because on
161
+ a streamed response the headers are written before the provider has counted a
162
+ token. This CLI reads that number. It does not multiply token counts by a
163
+ bundled rate card — our own desktop client did exactly that, and printed
164
+ `$0.00` for three months next to a correct invoice, because its copy of the
165
+ rate card knew six models and the gateway serves far more.
166
+
167
+ That is also why the UI shows no running dollar figure *during* a stream. Until
168
+ the last frame lands there is no price to show, so it shows elapsed time and how
169
+ much text arrived, and says the price is still coming.
170
+
171
+ Three rules follow, and they hold on every screen:
172
+
173
+ - `cost_usd` is `null`, never `0`, when the gateway could not price a call. That
174
+ prints `unpriced`. A zero renders as "free", and writing off a real charge is
175
+ the most expensive way to be wrong about money. A session whose calls were all
176
+ unpriced shows `unpriced` as its total, not `$0.000000`; a session with some of
177
+ each shows the priced total and counts the rest out loud — `+1 unpriced`.
178
+ - `saved` means the router beat a model **you named**. `vs ceiling` means you
179
+ sent `auto` and the gateway measured against the priciest model your plan
180
+ allows. `baseline_reason` says which, the receipt says which, and the two are
181
+ separate running totals that are never added together.
182
+ - No figure is ever truncated to fit.
183
+
184
+ ## Get a key
185
+
186
+ <https://www.lobstack.ai/start> — free, no card, about a minute.
187
+
188
+ MIT.
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "lobstack",
3
+ "version": "0.1.1",
4
+ "description": "The Lobstack Gateway from your terminal: one key, every model, and what each call cost.",
5
+ "type": "module",
6
+ "bin": {
7
+ "lobstack": "./src/index.mjs"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "keywords": [
17
+ "llm",
18
+ "gateway",
19
+ "openai",
20
+ "anthropic",
21
+ "proxy",
22
+ "cli",
23
+ "lobstack"
24
+ ],
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/Lobstack-ai/lobstack-cli.git"
29
+ },
30
+ "homepage": "https://www.lobstack.ai/docs/cli",
31
+ "author": "Lobstack",
32
+ "bugs": {
33
+ "url": "https://github.com/Lobstack-ai/lobstack-cli/issues"
34
+ },
35
+ "scripts": {
36
+ "test": "node --test test/*.test.mjs",
37
+ "prepublishOnly": "node --test test/*.test.mjs"
38
+ }
39
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Where the key lives, and why it lives there.
3
+ *
4
+ * `~/.lobstack/config.json`, mode 0600, created with mode 0700 on the
5
+ * directory. Not an environment variable in a dotfile the user has to remember
6
+ * to gitignore, and not a keychain — a keychain would be better and it would
7
+ * also mean a native dependency, which would mean `npx lobstack` stops being
8
+ * instant. LOBSTACK_API_KEY still wins when set, because CI has no home
9
+ * directory worth writing to.
10
+ */
11
+ import { mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { join } from 'node:path';
14
+
15
+ export const CONFIG_DIR = join(homedir(), '.lobstack');
16
+ export const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
17
+
18
+ /** The host that answers without a redirect. See the note in `resolveBase`. */
19
+ export const DEFAULT_BASE = 'https://www.lobstack.ai';
20
+
21
+ export function readConfig() {
22
+ try {
23
+ return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
24
+ } catch {
25
+ return {};
26
+ }
27
+ }
28
+
29
+ export function writeConfig(next) {
30
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
31
+ writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
32
+ // mkdir's mode is masked by umask, and writeFile's mode is ignored when the
33
+ // file already exists. Both are silent, and both leave a credential
34
+ // world-readable, so set them again explicitly.
35
+ try {
36
+ chmodSync(CONFIG_DIR, 0o700);
37
+ chmodSync(CONFIG_PATH, 0o600);
38
+ } catch {
39
+ /* Windows has no POSIX modes; the ACL default is per-user already */
40
+ }
41
+ }
42
+
43
+ export function resolveKey() {
44
+ return process.env.LOBSTACK_API_KEY || readConfig().key || null;
45
+ }
46
+
47
+ /**
48
+ * Normalise a base URL, and refuse the one that silently breaks auth.
49
+ *
50
+ * `lobstack.ai` 307s to `www.lobstack.ai`, and RFC 9110 requires a client to
51
+ * drop `Authorization` across a host change. A user who types the bare apex
52
+ * here gets "missing credentials" while holding a perfectly good key — which is
53
+ * exactly the failure that made the Gateway look broken for three months. So it
54
+ * is corrected, out loud, rather than honoured.
55
+ */
56
+ export function resolveBase(explicit) {
57
+ const raw = explicit || process.env.LOBSTACK_BASE_URL || readConfig().baseUrl || DEFAULT_BASE;
58
+ const url = new URL(raw);
59
+ if (url.hostname === 'lobstack.ai') {
60
+ url.hostname = 'www.lobstack.ai';
61
+ return { base: url.origin, corrected: true };
62
+ }
63
+ return { base: url.origin.replace(/\/+$/, ''), corrected: false };
64
+ }
65
+
66
+ export const gatewayUrl = (base, path) => `${base}/api/gateway/v1${path}`;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Talking to the Gateway, in one place.
3
+ *
4
+ * These four calls used to live inline in `index.mjs`, which was fine while
5
+ * there was one caller per command. The TUI needs the same requests, the same
6
+ * refusal to follow a redirect and the same error text, and two copies of
7
+ * "never follow a redirect with a credential attached" is one copy too many.
8
+ *
9
+ * Nothing here calls `process.exit`. A failure throws a `GatewayError` that
10
+ * carries the hint alongside the message, so the one-shot commands can print
11
+ * it and die exactly as they did before while the TUI puts the same words in
12
+ * the transcript and stays up.
13
+ */
14
+
15
+ import { gatewayUrl } from './config.mjs';
16
+ import { consume } from './stream.mjs';
17
+
18
+ export class GatewayError extends Error {
19
+ constructor(message, hint) {
20
+ super(message);
21
+ this.name = 'GatewayError';
22
+ this.hint = hint;
23
+ }
24
+ }
25
+
26
+ export async function gwFetch(url, key, init = {}) {
27
+ const { client, headers, ...rest } = init;
28
+ const res = await fetch(url, {
29
+ ...rest,
30
+ redirect: 'manual',
31
+ headers: {
32
+ Authorization: `Bearer ${key}`,
33
+ 'Content-Type': 'application/json',
34
+ 'x-lobstack-client': client || 'lobstack-cli',
35
+ ...(headers || {}),
36
+ },
37
+ });
38
+ if (res.status >= 300 && res.status < 400) {
39
+ // Not followed, and not quietly. A redirect that changes host makes every
40
+ // HTTP client drop Authorization, so the gateway would answer a perfectly
41
+ // good key with "missing credentials" - the failure that made this look
42
+ // broken for three months.
43
+ throw new GatewayError(
44
+ `the gateway redirected to ${res.headers.get('location') || 'somewhere else'}.`,
45
+ 'A redirect strips your key. Point --base at the host that answers directly.',
46
+ );
47
+ }
48
+ return res;
49
+ }
50
+
51
+ export async function errorText(res) {
52
+ const id = res.headers.get('x-lobstack-request-id');
53
+ let msg = `${res.status}`;
54
+ try {
55
+ const body = await res.json();
56
+ msg = body?.error?.message || body?.error || msg;
57
+ } catch {
58
+ /* not JSON */
59
+ }
60
+ return id ? `${msg} (request ${id})` : msg;
61
+ }
62
+
63
+ /** `/models`, as the array the gateway sends. */
64
+ export async function fetchModels(base, key, init) {
65
+ const res = await gwFetch(gatewayUrl(base, '/models'), key, init);
66
+ if (!res.ok) throw new GatewayError(await errorText(res));
67
+ return (await res.json()).data ?? [];
68
+ }
69
+
70
+ /** `/api/v1/usage`, grouped by model. */
71
+ export async function fetchUsage(base, key, days, init) {
72
+ const res = await gwFetch(`${base}/api/v1/usage?range=${days}d&group_by=model`, key, init);
73
+ if (!res.ok) {
74
+ throw new GatewayError(
75
+ await errorText(res),
76
+ res.status === 403
77
+ ? 'This key needs the "usage:read" scope. Mint one in Console > API keys.'
78
+ : undefined,
79
+ );
80
+ }
81
+ return res.json();
82
+ }
83
+
84
+ /**
85
+ * One streamed completion, and the receipt off its last frame.
86
+ *
87
+ * `include_usage` is not optional: without it the gateway has no frame to
88
+ * attach `x_lobstack` to, and the price never arrives.
89
+ */
90
+ export async function streamCompletion(base, key, { model, messages, signal, onText, client }) {
91
+ const res = await gwFetch(gatewayUrl(base, '/chat/completions'), key, {
92
+ method: 'POST',
93
+ signal,
94
+ client,
95
+ body: JSON.stringify({
96
+ model,
97
+ messages,
98
+ stream: true,
99
+ stream_options: { include_usage: true },
100
+ }),
101
+ });
102
+ if (!res.ok || !res.body) throw new GatewayError(await errorText(res));
103
+ return consume(res.body, onText);
104
+ }