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.
Files changed (86) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/LICENSE +21 -0
  3. package/README.md +239 -0
  4. package/bin/mod8.js +2 -0
  5. package/dist/cli.js +302 -0
  6. package/dist/commands/addProvider.js +105 -0
  7. package/dist/commands/all.js +158 -0
  8. package/dist/commands/chat.js +855 -0
  9. package/dist/commands/config.js +29 -0
  10. package/dist/commands/devAuthStatus.js +34 -0
  11. package/dist/commands/devHostAsk.js +51 -0
  12. package/dist/commands/devHostSystem.js +15 -0
  13. package/dist/commands/devResolve.js +54 -0
  14. package/dist/commands/devSimulate.js +235 -0
  15. package/dist/commands/devWorkAsk.js +55 -0
  16. package/dist/commands/intentRouting.js +280 -0
  17. package/dist/commands/keys.js +55 -0
  18. package/dist/commands/list.js +27 -0
  19. package/dist/commands/login.js +147 -0
  20. package/dist/commands/logout.js +17 -0
  21. package/dist/commands/prompt.js +63 -0
  22. package/dist/commands/providers.js +30 -0
  23. package/dist/commands/verify.js +5 -0
  24. package/dist/input/compose.js +37 -0
  25. package/dist/input/files.js +49 -0
  26. package/dist/input/stdin.js +14 -0
  27. package/dist/providers/anthropic.js +115 -0
  28. package/dist/providers/displayName.js +25 -0
  29. package/dist/providers/errorHints.js +175 -0
  30. package/dist/providers/generic.js +331 -0
  31. package/dist/providers/genericChat.js +265 -0
  32. package/dist/providers/google.js +63 -0
  33. package/dist/providers/hostSystem.js +173 -0
  34. package/dist/providers/index.js +38 -0
  35. package/dist/providers/mock.js +87 -0
  36. package/dist/providers/modelResolution.js +42 -0
  37. package/dist/providers/openai.js +75 -0
  38. package/dist/providers/pricing.js +47 -0
  39. package/dist/providers/proxy.js +148 -0
  40. package/dist/providers/registry.js +196 -0
  41. package/dist/providers/types.js +1 -0
  42. package/dist/providers/workSystem.js +33 -0
  43. package/dist/storage/auth.js +65 -0
  44. package/dist/storage/config.js +35 -0
  45. package/dist/storage/keys.js +59 -0
  46. package/dist/storage/providers.js +337 -0
  47. package/dist/storage/sessions.js +150 -0
  48. package/dist/types.js +9 -0
  49. package/dist/util/debug.js +79 -0
  50. package/dist/util/errors.js +157 -0
  51. package/dist/util/prompt.js +111 -0
  52. package/dist/util/secrets.js +110 -0
  53. package/dist/util/text.js +53 -0
  54. package/dist/util/time.js +25 -0
  55. package/dist/verify/runner.js +437 -0
  56. package/package.json +69 -0
  57. package/specs/all-mode.yaml +44 -0
  58. package/specs/behavior/auto-fallback.yaml +49 -0
  59. package/specs/behavior/bare-name-routing.yaml +223 -0
  60. package/specs/behavior/bare-paste-confirm.yaml +125 -0
  61. package/specs/behavior/env-var-respected.yaml +108 -0
  62. package/specs/behavior/error-fidelity.yaml +92 -0
  63. package/specs/behavior/error-hints.yaml +160 -0
  64. package/specs/behavior/fresh-vs-resume.yaml +94 -0
  65. package/specs/behavior/fuzzy-match.yaml +208 -0
  66. package/specs/behavior/host-self-knowledge-fresh.yaml +66 -0
  67. package/specs/behavior/intent-no-mismatch.yaml +115 -0
  68. package/specs/behavior/login-logout.yaml +97 -0
  69. package/specs/behavior/no-model-allowlist.yaml +80 -0
  70. package/specs/behavior/paste-key.yaml +342 -0
  71. package/specs/behavior/provider-switching.yaml +186 -0
  72. package/specs/behavior/providers-json-respected.yaml +106 -0
  73. package/specs/behavior/self-knowledge.yaml +119 -0
  74. package/specs/behavior/stress-session.yaml +226 -0
  75. package/specs/behavior/switch-back-when-failing.yaml +90 -0
  76. package/specs/behavior/work-character.yaml +109 -0
  77. package/specs/chat-meta.yaml +349 -0
  78. package/specs/chat-startup.yaml +148 -0
  79. package/specs/chat.yaml +91 -0
  80. package/specs/config.yaml +42 -0
  81. package/specs/install.yaml +112 -0
  82. package/specs/keys.yaml +81 -0
  83. package/specs/one-shot.yaml +65 -0
  84. package/specs/pipe-and-files.yaml +40 -0
  85. package/specs/providers.yaml +172 -0
  86. 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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../dist/cli.js';
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
+ });