mod8-cli 0.2.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/CHANGELOG.md +87 -0
- package/LICENSE +21 -0
- package/README.md +239 -0
- package/bin/mod8.js +2 -0
- package/dist/cli.js +302 -0
- package/dist/commands/addProvider.js +105 -0
- package/dist/commands/all.js +158 -0
- package/dist/commands/chat.js +855 -0
- package/dist/commands/config.js +29 -0
- package/dist/commands/devAuthStatus.js +34 -0
- package/dist/commands/devHostAsk.js +51 -0
- package/dist/commands/devHostSystem.js +15 -0
- package/dist/commands/devResolve.js +54 -0
- package/dist/commands/devSimulate.js +235 -0
- package/dist/commands/devWorkAsk.js +55 -0
- package/dist/commands/intentRouting.js +280 -0
- package/dist/commands/keys.js +55 -0
- package/dist/commands/list.js +27 -0
- package/dist/commands/login.js +147 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/prompt.js +63 -0
- package/dist/commands/providers.js +30 -0
- package/dist/commands/verify.js +5 -0
- package/dist/input/compose.js +37 -0
- package/dist/input/files.js +49 -0
- package/dist/input/stdin.js +14 -0
- package/dist/providers/anthropic.js +115 -0
- package/dist/providers/displayName.js +25 -0
- package/dist/providers/errorHints.js +175 -0
- package/dist/providers/generic.js +331 -0
- package/dist/providers/genericChat.js +265 -0
- package/dist/providers/google.js +63 -0
- package/dist/providers/hostSystem.js +173 -0
- package/dist/providers/index.js +38 -0
- package/dist/providers/mock.js +87 -0
- package/dist/providers/modelResolution.js +42 -0
- package/dist/providers/openai.js +75 -0
- package/dist/providers/pricing.js +47 -0
- package/dist/providers/proxy.js +148 -0
- package/dist/providers/registry.js +196 -0
- package/dist/providers/types.js +1 -0
- package/dist/providers/workSystem.js +33 -0
- package/dist/storage/auth.js +65 -0
- package/dist/storage/config.js +35 -0
- package/dist/storage/keys.js +59 -0
- package/dist/storage/providers.js +337 -0
- package/dist/storage/sessions.js +150 -0
- package/dist/types.js +9 -0
- package/dist/util/debug.js +79 -0
- package/dist/util/errors.js +157 -0
- package/dist/util/prompt.js +111 -0
- package/dist/util/secrets.js +110 -0
- package/dist/util/text.js +53 -0
- package/dist/util/time.js +25 -0
- package/dist/verify/runner.js +437 -0
- package/package.json +69 -0
- package/specs/all-mode.yaml +44 -0
- package/specs/behavior/auto-fallback.yaml +49 -0
- package/specs/behavior/bare-name-routing.yaml +223 -0
- package/specs/behavior/bare-paste-confirm.yaml +125 -0
- package/specs/behavior/env-var-respected.yaml +108 -0
- package/specs/behavior/error-fidelity.yaml +92 -0
- package/specs/behavior/error-hints.yaml +160 -0
- package/specs/behavior/fresh-vs-resume.yaml +94 -0
- package/specs/behavior/fuzzy-match.yaml +208 -0
- package/specs/behavior/host-self-knowledge-fresh.yaml +66 -0
- package/specs/behavior/intent-no-mismatch.yaml +115 -0
- package/specs/behavior/login-logout.yaml +97 -0
- package/specs/behavior/no-model-allowlist.yaml +80 -0
- package/specs/behavior/paste-key.yaml +342 -0
- package/specs/behavior/provider-switching.yaml +186 -0
- package/specs/behavior/providers-json-respected.yaml +106 -0
- package/specs/behavior/self-knowledge.yaml +119 -0
- package/specs/behavior/stress-session.yaml +226 -0
- package/specs/behavior/switch-back-when-failing.yaml +90 -0
- package/specs/behavior/work-character.yaml +109 -0
- package/specs/chat-meta.yaml +349 -0
- package/specs/chat-startup.yaml +148 -0
- package/specs/chat.yaml +91 -0
- package/specs/config.yaml +42 -0
- package/specs/install.yaml +112 -0
- package/specs/keys.yaml +81 -0
- package/specs/one-shot.yaml +65 -0
- package/specs/pipe-and-files.yaml +40 -0
- package/specs/providers.yaml +172 -0
- package/specs/sessions.yaml +115 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to mod8 are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.2.0] — 2026-05-10
|
|
9
|
+
|
|
10
|
+
The proxy bridge. Optional — local providers.json still works exactly as it
|
|
11
|
+
did in 0.1.0.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `mod8 login` — connect this CLI to your mod8 account. Opens the browser
|
|
16
|
+
to https://mod8.ai/cli-login, prompts for the `sk-mod8-…` key you copy
|
|
17
|
+
from there, pings the proxy to confirm, and saves
|
|
18
|
+
`~/.config/mod8/auth.json` at mode 0600.
|
|
19
|
+
- `mod8 logout` — drop those credentials, fall back to local providers.json.
|
|
20
|
+
- `-d, --deepseek` flag for one-shot prompts.
|
|
21
|
+
- Startup banner on the chat REPL: "Logged in as <email> — proxy mode" or
|
|
22
|
+
"Local mode — using providers.json".
|
|
23
|
+
- `dev:auth-status` — print the resolved routing decision (auth.json
|
|
24
|
+
loaded, mode, which provider ids would proxy). Used by the new
|
|
25
|
+
`login-logout.yaml` behavioral spec.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- When `auth.json` is present, requests for the four built-in providers
|
|
30
|
+
(`anthropic`, `openai`, `google`, `deepseek`) go through the hosted
|
|
31
|
+
proxy at `https://mod8-proxy-6jnzdar4rq-uc.a.run.app/v1/chat`. Cost
|
|
32
|
+
shown in the per-turn stats line is the **charged** amount (raw provider
|
|
33
|
+
cost + 15% mod8 markup), not the raw provider cost.
|
|
34
|
+
- Custom OpenAI-compatible providers (mistral / groq / openrouter / xai /
|
|
35
|
+
custom) keep using `providers.json` even when logged in — the proxy
|
|
36
|
+
doesn't carry them yet.
|
|
37
|
+
|
|
38
|
+
### Compatibility
|
|
39
|
+
|
|
40
|
+
- `auth.json` is opt-in. Existing users see no behavior change until they
|
|
41
|
+
run `mod8 login`.
|
|
42
|
+
|
|
43
|
+
## [0.1.0] — 2026-05-07
|
|
44
|
+
|
|
45
|
+
First public release.
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- One-shot prompts: `mod8 -c "…"` (Anthropic), `mod8 -o "…"` (OpenAI),
|
|
50
|
+
`mod8 -g "…"` (Gemini), or just `mod8 "…"` for the configured default.
|
|
51
|
+
- Side-by-side comparison: `mod8 --all "…"` fans the prompt out to every
|
|
52
|
+
configured provider and renders one block per provider with its own color,
|
|
53
|
+
model name, token count, latency, and cost.
|
|
54
|
+
- Interactive chat REPL: `mod8 new` (fresh session), `mod8 list` (recent
|
|
55
|
+
sessions), `mod8 resume <id>`. Two-mode flow:
|
|
56
|
+
- **host** = mod8 / Anthropic Sonnet, the planning side.
|
|
57
|
+
- **work** = any configured provider, the doing side.
|
|
58
|
+
Switching is by natural language ("go", "let's work", "use deepseek",
|
|
59
|
+
"ask grok", "switch to mistral") or slash commands (`/use <id>`,
|
|
60
|
+
`/ask <id>`, `/mod8`, `@mod8`, `/clear`, `/exit`, `/providers`,
|
|
61
|
+
`/compare <prompt>`).
|
|
62
|
+
- Provider registry: nine built-in templates with key-prefix detection —
|
|
63
|
+
`anthropic`, `openai`, `google`, `deepseek`, `mistral`, `groq`, `openrouter`,
|
|
64
|
+
`xai`, `together`. Anything OpenAI-compatible plugs in.
|
|
65
|
+
- Custom providers: `mod8 add-provider` accepts any OpenAI-compatible API by
|
|
66
|
+
pasting a key and confirming id, name, base URL, and default model.
|
|
67
|
+
- Storage: keys live in `~/.config/mod8/providers.json` (mode 0600) with
|
|
68
|
+
automatic migration from the legacy `keys.json`. Sessions live in
|
|
69
|
+
`~/.config/mod8/sessions/` with auto-generated titles.
|
|
70
|
+
- Pricing: per-model token costs with a `<$0.001` rounded summary.
|
|
71
|
+
- Error UX: invalid key, rate limit, network, quota, model-not-found are all
|
|
72
|
+
classified into one-line friendly messages, both in single-provider runs
|
|
73
|
+
and per-block in `--all`.
|
|
74
|
+
- Pipe + `@file` inputs: `cat file.go | mod8 "review this"`,
|
|
75
|
+
`mod8 "explain @path/to/file.py"`.
|
|
76
|
+
- `mod8 verify`: built-in self-test suite — 57 tests across 8 spec files,
|
|
77
|
+
runs in ~35 seconds, sandbox-isolated, exercises mock and real-API paths.
|
|
78
|
+
Run before every ship.
|
|
79
|
+
|
|
80
|
+
### Notes
|
|
81
|
+
|
|
82
|
+
- `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY`, `GEMINI_API_KEY`
|
|
83
|
+
environment variables override the stored key for the matching provider.
|
|
84
|
+
- `--all` consent: first run pauses for explicit confirmation (only once);
|
|
85
|
+
set `MOD8_AUTO_CONFIRM=1` to skip non-interactively.
|
|
86
|
+
- The chat REPL needs a real TTY (it's an Ink-based UI). Piped invocations
|
|
87
|
+
use the one-shot path.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yonatan Zlit
|
|
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,239 @@
|
|
|
1
|
+
# mod8
|
|
2
|
+
|
|
3
|
+
**Talk to any LLM from your terminal — Claude, GPT, Gemini, DeepSeek, Mistral, Groq, anything OpenAI-compatible. BYOK.**
|
|
4
|
+
|
|
5
|
+
No SaaS. No telemetry. Your keys stay in `~/.config/mod8/providers.json` (mode 0600). One CLI, every model.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## What it feels like
|
|
10
|
+
|
|
11
|
+
Install once, then:
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
$ mod8 keys set anthropic
|
|
15
|
+
Enter API key for Anthropic (Claude): ************************************
|
|
16
|
+
✓ Saved key for Anthropic (Claude)
|
|
17
|
+
Stored at /Users/you/.config/mod8/providers.json (file is 0600, only readable by you)
|
|
18
|
+
|
|
19
|
+
$ mod8 "what's the difference between a tarball and a wheel"
|
|
20
|
+
A tarball (.tar.gz) is the generic compressed-archive format used across
|
|
21
|
+
many ecosystems — Python sdists, Ruby gems, npm packages all ship as
|
|
22
|
+
tarballs. A wheel (.whl) is Python-specific, a built distribution that
|
|
23
|
+
skips the build step on install. tl;dr: tarball = source, wheel = built.
|
|
24
|
+
|
|
25
|
+
— claude-sonnet-4-6 324 tok · 1.21s · <$0.001
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
That was a one-shot. Now go interactive:
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
$ mod8 new
|
|
32
|
+
|
|
33
|
+
✻ mod8
|
|
34
|
+
switch to claude: ask naturally — "go", "let's work", "let me talk to claude"
|
|
35
|
+
use any provider: "use deepseek", "ask grok", "switch to mistral"
|
|
36
|
+
side-by-side: "compare all" or /compare
|
|
37
|
+
list providers: /providers · back to mod8: /mod8 or @mod8
|
|
38
|
+
clear history: /clear · quit: /exit · cancel: esc
|
|
39
|
+
|
|
40
|
+
› i'm thinking about adding rate limiting to my api gateway. unsure where to put it.
|
|
41
|
+
|
|
42
|
+
✻ mod8
|
|
43
|
+
│ Are you protecting downstream services from a single misbehaving caller, or
|
|
44
|
+
│ enforcing per-user quotas across many endpoints? Those usually live in
|
|
45
|
+
│ different layers.
|
|
46
|
+
│ 246 tok · 1.40s · <$0.001
|
|
47
|
+
|
|
48
|
+
› per-user quotas across the whole api
|
|
49
|
+
|
|
50
|
+
✻ mod8
|
|
51
|
+
│ Then sticking it at the gateway makes sense — you only count once instead
|
|
52
|
+
│ of summing across services. Edge gateway with a Redis-backed token bucket
|
|
53
|
+
│ is the standard pattern. Want claude to sketch the wiring?
|
|
54
|
+
│ 312 tok · 1.62s · <$0.001
|
|
55
|
+
|
|
56
|
+
› yes go
|
|
57
|
+
|
|
58
|
+
────────────────────────────────────────────────────────────
|
|
59
|
+
◆ → switching to claude (work mode)
|
|
60
|
+
────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
› yes go
|
|
63
|
+
|
|
64
|
+
◆ claude
|
|
65
|
+
│ Here's a minimal Express middleware that does it. Redis with a sliding
|
|
66
|
+
│ window over 60s, 100 requests per user…
|
|
67
|
+
│ 1.4k tok · 4.30s · $0.012
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Add another provider, switch to it mid-conversation:
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
$ mod8 add-provider
|
|
74
|
+
paste key: gsk_***************************
|
|
75
|
+
✓ Looks like Groq (groq, openai-compat).
|
|
76
|
+
provider id [groq]:
|
|
77
|
+
display name [Groq]:
|
|
78
|
+
api type (anthropic | openai-compat | gemini) [openai-compat]:
|
|
79
|
+
base URL [https://api.groq.com/openai/v1]:
|
|
80
|
+
default model [llama-3.3-70b-versatile]:
|
|
81
|
+
✓ Saved Groq (groq) — key gsk_***********6vQp, color ●
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Back in chat:
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
› use groq, give me the same answer but shorter
|
|
88
|
+
|
|
89
|
+
────────────────────────────────────────────────────────────
|
|
90
|
+
◆ → switching to groq (groq mode)
|
|
91
|
+
────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
› give me the same answer but shorter
|
|
94
|
+
|
|
95
|
+
◆ groq
|
|
96
|
+
│ const limiter = rateLimit({ windowMs: 60_000, max: 100, keyGenerator: r => r.user.id, store: new RedisStore({ client }) }); app.use(limiter);
|
|
97
|
+
│ 184 tok · 0.42s · <$0.001
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Side-by-side, all configured providers at once:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
› compare all: write a haiku about cron jobs
|
|
104
|
+
|
|
105
|
+
◆ claude
|
|
106
|
+
│ Midnight tick repeats —
|
|
107
|
+
│ silent worker in the dark,
|
|
108
|
+
│ logs the only sound.
|
|
109
|
+
│ 28 tok · 1.10s · <$0.001
|
|
110
|
+
|
|
111
|
+
◆ groq
|
|
112
|
+
│ Cron jobs run unseen,
|
|
113
|
+
│ scheduled tasks in shadow,
|
|
114
|
+
│ servers hum at night.
|
|
115
|
+
│ 24 tok · 0.31s · <$0.001
|
|
116
|
+
|
|
117
|
+
◆ deepseek
|
|
118
|
+
│ Five stars then asterisk,
|
|
119
|
+
│ time slices marching forward,
|
|
120
|
+
│ work without applause.
|
|
121
|
+
│ 26 tok · 0.88s · <$0.001
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Out of chat, you can also do this from one shell line:
|
|
125
|
+
|
|
126
|
+
```text
|
|
127
|
+
$ mod8 --all "summarize this commit message in 5 words" < commit.txt
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
That's the whole product.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Install
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
npm install -g mod8-cli
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
(The npm package is `mod8-cli`; the terminal command is `mod8`.)
|
|
141
|
+
|
|
142
|
+
Requires Node 20+.
|
|
143
|
+
|
|
144
|
+
You have two ways to bring keys to mod8.
|
|
145
|
+
|
|
146
|
+
### Option A — `mod8 login` (recommended)
|
|
147
|
+
|
|
148
|
+
One mod8 account, one bill, every provider.
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
mod8 login # opens https://mod8.ai/cli-login,
|
|
152
|
+
# paste the sk-mod8-… key it shows you
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
After that, `mod8 -c/-o/-g/-d "…"` all route through the mod8 proxy. The
|
|
156
|
+
per-turn stats line shows what mod8 charged your balance.
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
mod8 logout # drop credentials, fall back to local providers.json
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Option B — BYOK (bring your own keys)
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
mod8 keys set anthropic # or: openai, google, deepseek, mistral,
|
|
166
|
+
# groq, openrouter, xai, together
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
For any OpenAI-compatible API mod8 doesn't already know:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
mod8 add-provider # interactive: paste key, confirm name/baseUrl/model
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Or skip on-disk storage and use an env var:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Day-to-day
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
mod8 "say hi" # one-shot to your default
|
|
185
|
+
mod8 -c "say hi" # → anthropic
|
|
186
|
+
mod8 -o "say hi" # → openai
|
|
187
|
+
mod8 -g "say hi" # → google
|
|
188
|
+
mod8 -d "say hi" # → deepseek
|
|
189
|
+
mod8 --all "say hi" # fan-out, side-by-side
|
|
190
|
+
mod8 new # start a chat session
|
|
191
|
+
mod8 list # see recent sessions
|
|
192
|
+
mod8 resume <id> # pick up where you left off
|
|
193
|
+
mod8 keys list # who's configured
|
|
194
|
+
mod8 providers # detailed provider config
|
|
195
|
+
mod8 verify # run the built-in self-test suite
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## What's in the box
|
|
201
|
+
|
|
202
|
+
| Category | Detail |
|
|
203
|
+
| --- | --- |
|
|
204
|
+
| Built-in providers | anthropic, openai, google, deepseek, mistral, groq, openrouter, xai, together |
|
|
205
|
+
| Custom providers | any OpenAI-compatible API via `mod8 add-provider` |
|
|
206
|
+
| Storage | `~/.config/mod8/providers.json` (keys, mode 0600) and `~/.config/mod8/sessions/*.json` (chat history) |
|
|
207
|
+
| Pricing | per-model token costs in every footer |
|
|
208
|
+
| Streaming | yes, all providers; cancel with `esc` mid-stream |
|
|
209
|
+
| Pipe / `@file` | `cat x | mod8 "…"` and `mod8 "review @path/to/file"` both work |
|
|
210
|
+
| Self-test | `mod8 verify` runs 50+ sandboxed tests against mocked + real API paths |
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Configuration
|
|
215
|
+
|
|
216
|
+
| File or env var | What it holds |
|
|
217
|
+
| --- | --- |
|
|
218
|
+
| `~/.config/mod8/providers.json` | API keys + per-provider config (api type, base URL, default model, color). Mode `0600`. |
|
|
219
|
+
| `~/.config/mod8/config.json` | `default` (which provider answers a bare `mod8 "..."`), `allConsent` (first-run gate). |
|
|
220
|
+
| `~/.config/mod8/sessions/*.json` | Saved chat sessions, auto-titled after the second turn. Mode `0600`. |
|
|
221
|
+
| `MOD8_CONFIG_DIR` | Override the config root entirely (used by `mod8 verify`'s sandbox). |
|
|
222
|
+
| `MOD8_HOST_MODEL`, `MOD8_WORK_MODEL` | Override the default chat models. |
|
|
223
|
+
| `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY`, `GEMINI_API_KEY` | Override the stored key for that provider. |
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Privacy
|
|
228
|
+
|
|
229
|
+
Everything is local. mod8 is a thin client that talks directly to provider APIs.
|
|
230
|
+
There is no mod8 server. No analytics, no telemetry, no key escrow.
|
|
231
|
+
|
|
232
|
+
If you want to verify, the verify suite has a test that asserts `providers.json`
|
|
233
|
+
is created with mode `0600`, and the source is short enough to read in an hour.
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## License
|
|
238
|
+
|
|
239
|
+
MIT — see [LICENSE](./LICENSE).
|
package/bin/mod8.js
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { keysSet, keysList, keysRemove } from './commands/keys.js';
|
|
4
|
+
import { loginCommand } from './commands/login.js';
|
|
5
|
+
import { logoutCommand } from './commands/logout.js';
|
|
6
|
+
import { readAuth } from './storage/auth.js';
|
|
7
|
+
import { runPrompt, resolveProvider } from './commands/prompt.js';
|
|
8
|
+
import { runAll, ensureAllConsent } from './commands/all.js';
|
|
9
|
+
import { configGet, configSet } from './commands/config.js';
|
|
10
|
+
import { runChat } from './commands/chat.js';
|
|
11
|
+
import { listCommand } from './commands/list.js';
|
|
12
|
+
import { verifyCommand } from './commands/verify.js';
|
|
13
|
+
import { getMostRecentSession } from './storage/sessions.js';
|
|
14
|
+
import { addProviderCommand } from './commands/addProvider.js';
|
|
15
|
+
import { listProvidersCommand } from './commands/providers.js';
|
|
16
|
+
import { devHostAsk } from './commands/devHostAsk.js';
|
|
17
|
+
import { devResolve } from './commands/devResolve.js';
|
|
18
|
+
import { devWorkAsk } from './commands/devWorkAsk.js';
|
|
19
|
+
import { devSimulate } from './commands/devSimulate.js';
|
|
20
|
+
import { devHostSystem } from './commands/devHostSystem.js';
|
|
21
|
+
import { readStdin } from './input/stdin.js';
|
|
22
|
+
import { composePrompt } from './input/compose.js';
|
|
23
|
+
const program = new Command();
|
|
24
|
+
program
|
|
25
|
+
.name('mod8')
|
|
26
|
+
.description('Talk to any LLM from your terminal — Claude, GPT, Gemini, DeepSeek, Mistral, Groq, anything OpenAI-compatible. BYOK.')
|
|
27
|
+
.version('0.2.0');
|
|
28
|
+
program
|
|
29
|
+
.argument('[prompt]', 'prompt to send (uses default provider unless a flag is set)')
|
|
30
|
+
.option('-c, --claude', 'use Claude (Anthropic)')
|
|
31
|
+
.option('-o, --openai', 'use OpenAI (GPT)')
|
|
32
|
+
.option('-g, --gemini', 'use Gemini (Google)')
|
|
33
|
+
.option('-d, --deepseek', 'use DeepSeek')
|
|
34
|
+
.option('--all', 'run on every configured provider in parallel and show side-by-side')
|
|
35
|
+
.action(async (prompt, opts) => {
|
|
36
|
+
if (!prompt) {
|
|
37
|
+
// Bare `mod8` (no flags, no prompt) → enter chat REPL with a FRESH
|
|
38
|
+
// session. Mirrors how chat products work elsewhere: opening = new
|
|
39
|
+
// conversation; history is one click (or one `mod8 resume`) away.
|
|
40
|
+
// With any flag set but no prompt, fall through to help.
|
|
41
|
+
if (!opts.claude && !opts.openai && !opts.gemini && !opts.deepseek && !opts.all) {
|
|
42
|
+
await printStartupBanner();
|
|
43
|
+
await runChat({ fresh: true });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
program.help();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// Order matters: consent must be gathered BEFORE stdin is consumed.
|
|
50
|
+
const stdinPiped = !process.stdin.isTTY;
|
|
51
|
+
if (opts.all) {
|
|
52
|
+
await ensureAllConsent({ stdinPiped });
|
|
53
|
+
}
|
|
54
|
+
const stdinContent = await readStdin();
|
|
55
|
+
const { finalPrompt, warnings } = await composePrompt(prompt, stdinContent);
|
|
56
|
+
for (const w of warnings) {
|
|
57
|
+
console.error(chalk.yellow(`warning: ${w}`));
|
|
58
|
+
}
|
|
59
|
+
if (opts.all) {
|
|
60
|
+
await runAll(finalPrompt);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const provider = await resolveProvider(opts);
|
|
64
|
+
await runPrompt({ provider, prompt: finalPrompt });
|
|
65
|
+
});
|
|
66
|
+
const keys = program.command('keys').description('Manage API keys (stored locally, never sent anywhere)');
|
|
67
|
+
keys
|
|
68
|
+
.command('set <provider>')
|
|
69
|
+
.description('Save an API key for a built-in provider (anthropic | openai | google | deepseek | groq | mistral | xai | openrouter | together)')
|
|
70
|
+
.action(async (provider) => {
|
|
71
|
+
await keysSet(provider);
|
|
72
|
+
});
|
|
73
|
+
keys
|
|
74
|
+
.command('list')
|
|
75
|
+
.description('List configured providers (keys masked)')
|
|
76
|
+
.action(async () => {
|
|
77
|
+
await keysList();
|
|
78
|
+
});
|
|
79
|
+
keys
|
|
80
|
+
.command('remove <provider>')
|
|
81
|
+
.description('Remove a stored API key')
|
|
82
|
+
.action(async (provider) => {
|
|
83
|
+
await keysRemove(provider);
|
|
84
|
+
});
|
|
85
|
+
program
|
|
86
|
+
.command('new')
|
|
87
|
+
.description('Start a fresh chat session')
|
|
88
|
+
.action(async () => {
|
|
89
|
+
await runChat({ fresh: true });
|
|
90
|
+
});
|
|
91
|
+
program
|
|
92
|
+
.command('list')
|
|
93
|
+
.description('Show recent chat sessions')
|
|
94
|
+
.action(async () => {
|
|
95
|
+
await listCommand();
|
|
96
|
+
});
|
|
97
|
+
program
|
|
98
|
+
.command('resume [id]')
|
|
99
|
+
.description('Resume the most recent session, or a specific session by id')
|
|
100
|
+
.action(async (id) => {
|
|
101
|
+
if (id) {
|
|
102
|
+
await runChat({ sessionId: id });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const recent = await getMostRecentSession();
|
|
106
|
+
if (!recent) {
|
|
107
|
+
console.error(chalk.red('mod8: ') +
|
|
108
|
+
'no sessions to resume yet. Try `mod8` to start fresh, or `mod8 list` to see saved sessions.');
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
await runChat({ sessionId: recent.id });
|
|
112
|
+
});
|
|
113
|
+
program
|
|
114
|
+
.command('add-provider')
|
|
115
|
+
.description('Register a provider (built-in or custom OpenAI-compatible) by pasting its key')
|
|
116
|
+
.action(async () => {
|
|
117
|
+
await addProviderCommand();
|
|
118
|
+
});
|
|
119
|
+
program
|
|
120
|
+
.command('providers')
|
|
121
|
+
.description('List configured providers (id, name, model, base URL)')
|
|
122
|
+
.action(async () => {
|
|
123
|
+
await listProvidersCommand();
|
|
124
|
+
});
|
|
125
|
+
program
|
|
126
|
+
.command('verify')
|
|
127
|
+
.description("Run mod8's self-verification spec suite (specs/*.yaml)")
|
|
128
|
+
.action(async () => {
|
|
129
|
+
await verifyCommand();
|
|
130
|
+
});
|
|
131
|
+
// Dev endpoint: print the resolved auth status + the proxy-routing decision
|
|
132
|
+
// for a few canonical provider ids. Pure (no network). Used by the login
|
|
133
|
+
// behavioral spec.
|
|
134
|
+
program
|
|
135
|
+
.command('dev:auth-status')
|
|
136
|
+
.description('print resolved auth.json + proxy routing decision (no network)')
|
|
137
|
+
.action(async () => {
|
|
138
|
+
const { devAuthStatus } = await import('./commands/devAuthStatus.js');
|
|
139
|
+
await devAuthStatus();
|
|
140
|
+
});
|
|
141
|
+
program
|
|
142
|
+
.command('login')
|
|
143
|
+
.description('Connect this CLI to your mod8 account — routes calls through the hosted proxy')
|
|
144
|
+
.action(async () => {
|
|
145
|
+
await loginCommand();
|
|
146
|
+
});
|
|
147
|
+
program
|
|
148
|
+
.command('logout')
|
|
149
|
+
.description('Drop mod8 credentials — falls back to your local providers.json')
|
|
150
|
+
.action(async () => {
|
|
151
|
+
await logoutCommand();
|
|
152
|
+
});
|
|
153
|
+
// Dev endpoint: one-shot through the host (mod8) system prompt — used by
|
|
154
|
+
// the chat-meta verify spec to confirm mod8 can answer questions about
|
|
155
|
+
// itself. Also useful from the shell for quick meta queries.
|
|
156
|
+
program
|
|
157
|
+
.command('dev:host-ask <prompt>')
|
|
158
|
+
.description('one-shot through the host (mod8) system prompt')
|
|
159
|
+
.action(async (prompt) => {
|
|
160
|
+
await devHostAsk(prompt);
|
|
161
|
+
});
|
|
162
|
+
// Dev endpoint: print how the chat REPL would route a given input string
|
|
163
|
+
// (provider switch, compare, or none) — used to test synonym handling.
|
|
164
|
+
program
|
|
165
|
+
.command('dev:resolve <input>')
|
|
166
|
+
.description('show how the chat REPL would route an input (debug only)')
|
|
167
|
+
.action(async (input) => {
|
|
168
|
+
await devResolve(input);
|
|
169
|
+
});
|
|
170
|
+
// Dev endpoint: one-shot through WORK-mode system prompt for the given
|
|
171
|
+
// provider. Used to test that work-mode models stay in character and
|
|
172
|
+
// don't impersonate the host.
|
|
173
|
+
program
|
|
174
|
+
.command('dev:work-ask <providerId> <prompt>')
|
|
175
|
+
.description('one-shot through the work-mode system prompt for a provider')
|
|
176
|
+
.action(async (providerId, prompt) => {
|
|
177
|
+
await devWorkAsk(providerId, prompt);
|
|
178
|
+
});
|
|
179
|
+
// Dev endpoint: simulate a chat session by reading inputs from stdin and
|
|
180
|
+
// applying the same routing state machine the chat REPL uses (no LLM, no
|
|
181
|
+
// Ink). Used by stress-test specs to verify long sequences of switches.
|
|
182
|
+
program
|
|
183
|
+
.command('dev:simulate')
|
|
184
|
+
.description('simulate a chat session from stdin (one input per line)')
|
|
185
|
+
.action(async () => {
|
|
186
|
+
await devSimulate();
|
|
187
|
+
});
|
|
188
|
+
// Dev endpoint: print the host system prompt as it would be assembled right
|
|
189
|
+
// now from current providers.json state. Used by behavioral specs to
|
|
190
|
+
// verify the host-self-knowledge refresh (Bug 1) — rebuilding the prompt
|
|
191
|
+
// always reflects the latest providers, not a stale startup snapshot.
|
|
192
|
+
program
|
|
193
|
+
.command('dev:host-system')
|
|
194
|
+
.description('print the host system prompt with current provider state')
|
|
195
|
+
.action(async () => {
|
|
196
|
+
await devHostSystem();
|
|
197
|
+
});
|
|
198
|
+
// Dev endpoint: test the auto-fallback decision logic for a given count of
|
|
199
|
+
// consecutive work-mode errors. Pure, no API calls.
|
|
200
|
+
program
|
|
201
|
+
.command('dev:check-fallback <count>')
|
|
202
|
+
.description('print the auto-fallback decision for N consecutive work errors')
|
|
203
|
+
.action(async (count) => {
|
|
204
|
+
const { fallbackDecision, AUTO_FALLBACK_THRESHOLD } = await import('./commands/intentRouting.js');
|
|
205
|
+
const n = Number.parseInt(count, 10);
|
|
206
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
207
|
+
console.error(`mod8: count must be a non-negative integer, got ${JSON.stringify(count)}`);
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
const decision = fallbackDecision(n);
|
|
211
|
+
console.log(`consecutive=${n} threshold=${AUTO_FALLBACK_THRESHOLD} decision=${decision}`);
|
|
212
|
+
});
|
|
213
|
+
// Dev endpoint: print which model would be sent to the provider, with the
|
|
214
|
+
// resolution source (opts > env > providers.json). No allowlist, no
|
|
215
|
+
// substitution — whatever the user wrote (or set in MOD8_<ID>_MODEL) is
|
|
216
|
+
// what the SDK will receive. Behavioral specs use this to verify
|
|
217
|
+
// passthrough without making real network calls.
|
|
218
|
+
program
|
|
219
|
+
.command('dev:resolve-model <providerId>')
|
|
220
|
+
.description('print the model + resolution source for a provider id')
|
|
221
|
+
.action(async (providerId) => {
|
|
222
|
+
const { resolveConfigured } = await import('./storage/providers.js');
|
|
223
|
+
const { resolveModel } = await import('./providers/modelResolution.js');
|
|
224
|
+
const entry = await resolveConfigured(providerId);
|
|
225
|
+
const r = resolveModel(providerId, undefined, entry?.defaultModel);
|
|
226
|
+
console.log(`providerId=${providerId} model=${JSON.stringify(r.model)} source=${r.source} envVar=${r.envVar}`);
|
|
227
|
+
});
|
|
228
|
+
// Dev endpoint: print the EXACT debug line that would be emitted on a
|
|
229
|
+
// provider call — including the URL the SDK is about to hit, the resolved
|
|
230
|
+
// model, the masked key. No network call, no SDK invocation, just the
|
|
231
|
+
// resolution logic. Behavioral specs use this to verify model-name
|
|
232
|
+
// passthrough into the provider URL without depending on real network.
|
|
233
|
+
program
|
|
234
|
+
.command('dev:debug-call <providerId>')
|
|
235
|
+
.description('print the would-be debug-call line for a provider (no network)')
|
|
236
|
+
.action(async (providerId) => {
|
|
237
|
+
const { resolveConfigured } = await import('./storage/providers.js');
|
|
238
|
+
const { resolveModel } = await import('./providers/modelResolution.js');
|
|
239
|
+
const { approximateProviderUrl } = await import('./util/debug.js');
|
|
240
|
+
const { maskApiKey } = await import('./util/secrets.js');
|
|
241
|
+
const entry = await resolveConfigured(providerId);
|
|
242
|
+
if (!entry) {
|
|
243
|
+
console.error(`mod8: ${providerId} not configured`);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
const r = resolveModel(providerId, undefined, entry.defaultModel);
|
|
247
|
+
const url = approximateProviderUrl(entry.apiType, r.model, entry.baseUrl);
|
|
248
|
+
console.log(`providerId=${providerId} apiType=${entry.apiType} model=${JSON.stringify(r.model)} modelSource=${r.source} key=${maskApiKey(entry.apiKey)} url=${JSON.stringify(url)}`);
|
|
249
|
+
});
|
|
250
|
+
// Dev endpoint: feed a synthetic error message + provider id through the
|
|
251
|
+
// per-kind explainer. Pure (no API calls). Behavioral specs use this to
|
|
252
|
+
// verify that the diagnoser extracts HTTP code, retry-after, raw message,
|
|
253
|
+
// and produces the right kind-specific short / long / suggestion text.
|
|
254
|
+
//
|
|
255
|
+
// Usage: mod8 dev:explain-error <providerId> "<error message>"
|
|
256
|
+
// e.g. mod8 dev:explain-error google "[403 Forbidden] Your project has been denied access."
|
|
257
|
+
program
|
|
258
|
+
.command('dev:explain-error <providerId> <message>')
|
|
259
|
+
.description('print the structured diagnosis for a synthetic provider error')
|
|
260
|
+
.action(async (providerId, message) => {
|
|
261
|
+
const { explainError } = await import('./providers/errorHints.js');
|
|
262
|
+
const e = explainError(new Error(message), providerId);
|
|
263
|
+
console.log(`kind=${e.kind}`);
|
|
264
|
+
console.log(`short=${e.short}`);
|
|
265
|
+
console.log('long=');
|
|
266
|
+
if (e.long)
|
|
267
|
+
console.log(e.long);
|
|
268
|
+
console.log(`suggestion=${e.suggestion}`);
|
|
269
|
+
});
|
|
270
|
+
const config = program.command('config').description('Manage configuration');
|
|
271
|
+
config
|
|
272
|
+
.command('get')
|
|
273
|
+
.description('Show current configuration')
|
|
274
|
+
.action(async () => {
|
|
275
|
+
await configGet();
|
|
276
|
+
});
|
|
277
|
+
config
|
|
278
|
+
.command('set <key> <value>')
|
|
279
|
+
.description('Set a config value (e.g. "default anthropic")')
|
|
280
|
+
.action(async (key, value) => {
|
|
281
|
+
await configSet(key, value);
|
|
282
|
+
});
|
|
283
|
+
/**
|
|
284
|
+
* Banner printed before the REPL boots — one line so it never gets in the
|
|
285
|
+
* way. Quiet on every other entry point (one-shot prompts, dev:* commands,
|
|
286
|
+
* keys/config) so the output stays predictable for scripting.
|
|
287
|
+
*/
|
|
288
|
+
async function printStartupBanner() {
|
|
289
|
+
const auth = await readAuth();
|
|
290
|
+
if (auth) {
|
|
291
|
+
const who = auth.email ? chalk.bold(auth.email) : 'mod8 account';
|
|
292
|
+
process.stderr.write(chalk.dim(`Logged in as ${who} — proxy mode (mod8 logout to switch off)\n`));
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
process.stderr.write(chalk.dim(`Local mode — using providers.json (mod8 login to use the hosted proxy)\n`));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
program.parseAsync().catch((err) => {
|
|
299
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
300
|
+
console.error(chalk.red('mod8: ') + msg);
|
|
301
|
+
process.exit(1);
|
|
302
|
+
});
|