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
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Provider registry — built-in catalog of known providers.
3
+ *
4
+ * mod8 supports any provider that speaks one of three API styles:
5
+ * - "anthropic" — native Anthropic Messages API
6
+ * - "gemini" — native Google generative-ai SDK
7
+ * - "openai-compat" — OpenAI Chat Completions schema (covers OpenAI itself,
8
+ * DeepSeek, Mistral, Groq, OpenRouter, xAI, Together, …)
9
+ *
10
+ * Each entry is a *template*. A provider only becomes "configured" once the
11
+ * user supplies a key (via `mod8 add-provider` or `mod8 keys set <id>`).
12
+ *
13
+ * Unknown key prefixes fall through to a manual prompt in `add-provider`.
14
+ */
15
+ /**
16
+ * Known providers. Order matters for ambiguity resolution: when two prefixes
17
+ * could match (e.g. "sk-" matches both OpenAI and Mistral), the first match
18
+ * wins, so list more-specific prefixes before more-generic ones.
19
+ */
20
+ export const KNOWN_PROVIDERS = [
21
+ {
22
+ id: 'anthropic',
23
+ name: 'Anthropic (Claude)',
24
+ apiType: 'anthropic',
25
+ defaultModel: 'claude-sonnet-4-6',
26
+ color: '#A78BFA', // purple (matches chat work-mode brand)
27
+ keyPrefix: 'sk-ant-',
28
+ },
29
+ {
30
+ id: 'openrouter',
31
+ name: 'OpenRouter',
32
+ apiType: 'openai-compat',
33
+ baseUrl: 'https://openrouter.ai/api/v1',
34
+ defaultModel: 'openai/gpt-4o-mini',
35
+ color: '#EC4899', // pink
36
+ keyPrefix: 'sk-or-',
37
+ },
38
+ {
39
+ id: 'openai',
40
+ name: 'OpenAI (GPT)',
41
+ apiType: 'openai-compat',
42
+ baseUrl: 'https://api.openai.com/v1',
43
+ defaultModel: 'gpt-4o',
44
+ color: '#10B981', // emerald
45
+ keyPrefix: 'sk-proj-', // newer OpenAI keys; legacy "sk-" also matched via fallback
46
+ },
47
+ {
48
+ id: 'deepseek',
49
+ name: 'DeepSeek',
50
+ apiType: 'openai-compat',
51
+ baseUrl: 'https://api.deepseek.com',
52
+ defaultModel: 'deepseek-chat',
53
+ color: '#3B82F6', // blue
54
+ keyPrefix: 'sk-', // generic — only used as last resort below
55
+ },
56
+ {
57
+ id: 'groq',
58
+ name: 'Groq',
59
+ apiType: 'openai-compat',
60
+ baseUrl: 'https://api.groq.com/openai/v1',
61
+ defaultModel: 'llama-3.3-70b-versatile',
62
+ color: '#F59E0B', // amber-500
63
+ keyPrefix: 'gsk_',
64
+ },
65
+ {
66
+ id: 'xai',
67
+ name: 'xAI (Grok)',
68
+ apiType: 'openai-compat',
69
+ baseUrl: 'https://api.x.ai/v1',
70
+ defaultModel: 'grok-2-latest',
71
+ color: '#6B7280', // gray
72
+ keyPrefix: 'xai-',
73
+ },
74
+ {
75
+ id: 'mistral',
76
+ name: 'Mistral',
77
+ apiType: 'openai-compat',
78
+ baseUrl: 'https://api.mistral.ai/v1',
79
+ defaultModel: 'mistral-large-latest',
80
+ color: '#EF4444', // red
81
+ // No public stable prefix; fall back to manual confirmation.
82
+ },
83
+ {
84
+ id: 'together',
85
+ name: 'Together AI',
86
+ apiType: 'openai-compat',
87
+ baseUrl: 'https://api.together.xyz/v1',
88
+ defaultModel: 'meta-llama/Llama-3.3-70B-Instruct-Turbo',
89
+ color: '#8B5CF6', // violet
90
+ // No stable prefix.
91
+ },
92
+ {
93
+ id: 'google',
94
+ name: 'Google (Gemini)',
95
+ apiType: 'gemini',
96
+ // gemini-2.5-flash is the current free-tier flagship as of 2026.
97
+ // gemini-2.0-flash is being deprecated for new users — Google returns
98
+ // "no longer available to new users" on a fresh key.
99
+ defaultModel: 'gemini-2.5-flash',
100
+ color: '#06B6D4', // cyan
101
+ // Google API keys start with AIza but format is shared with all Google APIs.
102
+ keyPrefix: 'AIza',
103
+ },
104
+ ];
105
+ /**
106
+ * Common nicknames → built-in provider id. When the user says "let me talk
107
+ * to gpt" or "use claude", we want to land on the actual configured provider
108
+ * (which might be stored under "openai" with a custom display name like
109
+ * "codex"), not bail with "unknown provider 'gpt'."
110
+ *
111
+ * The resolver in storage/providers.ts uses this AFTER trying an exact id
112
+ * match and an exact display-name match — synonyms are the last fallback.
113
+ */
114
+ export const PROVIDER_SYNONYMS = {
115
+ // Anthropic family
116
+ claude: 'anthropic',
117
+ sonnet: 'anthropic',
118
+ opus: 'anthropic',
119
+ haiku: 'anthropic',
120
+ // OpenAI family
121
+ gpt: 'openai',
122
+ chatgpt: 'openai',
123
+ 'gpt-4': 'openai',
124
+ 'gpt-4o': 'openai',
125
+ // Google family
126
+ gemini: 'google',
127
+ bard: 'google',
128
+ // xAI
129
+ grok: 'xai',
130
+ // Meta / Groq (shorthand most users type)
131
+ llama: 'groq',
132
+ };
133
+ /**
134
+ * High-confidence brand aliases — unambiguous brand names that don't collide
135
+ * with common English words. Allowed in STRICT resolution (bare-name and
136
+ * first-word matching) so that `claude`, `gpt`, `grok`, etc. typed alone or
137
+ * with a short instruction route directly without going through the LLM.
138
+ *
139
+ * Excluded on purpose: `sonnet`, `opus`, `haiku`, `bard` — those are poetry/
140
+ * literature words common enough in chat to false-positive. Also excluded:
141
+ * `llama` (animal/Linux distro) for the same reason. Those still resolve
142
+ * via the verb-based path (`use sonnet`, `talk to llama`) which is explicit.
143
+ */
144
+ export const HIGH_CONFIDENCE_BRAND_ALIASES = {
145
+ claude: 'anthropic',
146
+ gpt: 'openai',
147
+ chatgpt: 'openai',
148
+ gemini: 'google',
149
+ grok: 'xai',
150
+ };
151
+ /**
152
+ * Auto-assignable color palette for user-provided custom providers.
153
+ * Keep distinct from the known-provider colors above.
154
+ */
155
+ export const COLOR_PALETTE = [
156
+ '#6EE7B7', // mint
157
+ '#A78BFA', // purple
158
+ '#FBBF24', // yellow
159
+ '#F472B6', // pink
160
+ '#60A5FA', // sky
161
+ '#FB923C', // orange
162
+ '#34D399', // green
163
+ '#E879F9', // fuchsia
164
+ ];
165
+ export function templateById(id) {
166
+ return KNOWN_PROVIDERS.find((p) => p.id === id);
167
+ }
168
+ /**
169
+ * Detect a provider template from a key prefix. Returns the most specific
170
+ * match, falling back to OpenAI for legacy "sk-" if no other match.
171
+ */
172
+ export function detectFromKey(key) {
173
+ const trimmed = key.trim();
174
+ // First pass: any provider with an exact prefix match. KNOWN_PROVIDERS is
175
+ // ordered most-specific-first, so the first hit wins.
176
+ for (const p of KNOWN_PROVIDERS) {
177
+ if (p.keyPrefix && trimmed.startsWith(p.keyPrefix))
178
+ return p;
179
+ }
180
+ // Legacy fallback: bare "sk-" (no -ant-, -or-, -proj-) → OpenAI.
181
+ if (trimmed.startsWith('sk-'))
182
+ return templateById('openai');
183
+ return undefined;
184
+ }
185
+ /** Pick a color for a custom provider given how many already exist. */
186
+ export function pickPaletteColor(usedColors) {
187
+ for (const c of COLOR_PALETTE) {
188
+ if (!usedColors.includes(c))
189
+ return c;
190
+ }
191
+ // Wrap around if user has > palette length custom providers.
192
+ return COLOR_PALETTE[usedColors.length % COLOR_PALETTE.length];
193
+ }
194
+ export function isValidProviderId(id) {
195
+ return /^[a-z][a-z0-9_-]{0,30}$/.test(id);
196
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Work-mode system prompt builder.
3
+ *
4
+ * Whoever is in work mode (claude by default, but could be any configured
5
+ * provider — codex, grok, deepseek, etc.) gets THIS prompt. The job is to
6
+ * keep the worker model in character: do the work, don't impersonate mod8
7
+ * host, and bounce meta questions about the CLI back to the host.
8
+ */
9
+ export function buildWorkSystem(workerName) {
10
+ return `You are ${workerName}, an LLM helping the user complete a task. The user is reaching you through mod8 — a CLI that routes messages between providers — but you are NOT mod8 itself. mod8 is a separate model (the "host" / planning side) that handed off to you.
11
+
12
+ # Stay in your lane
13
+
14
+ - You are the WORKER. Just do the work the user asked for — code, write, generate, analyze, explain. Direct and thorough.
15
+ - You are NOT mod8. You do not know mod8's configuration, command surface, or which other providers are connected. Don't pretend to.
16
+ - If the user asks a META question about mod8 itself — "what providers are configured?", "what's mod8?", "how do I switch?", "how do I add a new provider?", "what commands are there?" — DO NOT answer it from your own assumptions. Hand back to the host with <SWITCH_TO_HOST> and a brief note: "that's a mod8 question — handing back to host."
17
+ - DO NOT give advice about provider configuration, naming, the right CLI flag, etc. That's the host's job. If the user is confused about mod8's plumbing, get them back to the host.
18
+ - DO NOT claim to be mod8. If asked who you are, you are ${workerName}.
19
+
20
+ # How to hand off back to host
21
+
22
+ Respond normally, then end your message with the literal token <SWITCH_TO_HOST>. Don't explain the token. Just append it on a new line. The CLI strips it and switches modes for the next turn.
23
+
24
+ When to hand off:
25
+ - Explicit: "@mod8", "/mod8", "back to mod8", "talk to mod8".
26
+ - Pausing or reconsidering: "stop", "wait", "let me think", "this isn't right", "actually no".
27
+ - Meta questions about mod8 itself (you should NOT answer these yourself — defer).
28
+ - Stepping back to planning: "go back to planning", "let's rethink".
29
+
30
+ If the user wants more work done — fixes, follow-ups, related tasks — DO NOT emit the token. Keep working.
31
+
32
+ Never refuse a hand-off.`;
33
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * mod8 auth.json — credentials for the mod8-hosted proxy.
3
+ *
4
+ * ~/.config/mod8/auth.json:
5
+ * {
6
+ * "mod8Key": "sk-mod8-...", // bearer token for proxy
7
+ * "proxyUrl": "https://...", // base URL (override w/ MOD8_PROXY_URL)
8
+ * "email": "you@example.com" // shown in startup banner
9
+ * }
10
+ *
11
+ * When this file exists, the CLI routes every request through the proxy
12
+ * instead of the user's local providers.json. When it's absent, the CLI
13
+ * uses the BYOK local providers (current behavior).
14
+ *
15
+ * File mode 0600 — same as providers.json + config.json.
16
+ */
17
+ import { promises as fs } from 'fs';
18
+ import { homedir } from 'os';
19
+ import { join } from 'path';
20
+ export const DEFAULT_PROXY_URL = 'https://mod8-proxy-6jnzdar4rq-uc.a.run.app';
21
+ const CONFIG_DIR = process.env.MOD8_CONFIG_DIR ?? join(homedir(), '.config', 'mod8');
22
+ const AUTH_FILE = join(CONFIG_DIR, 'auth.json');
23
+ export async function readAuth() {
24
+ try {
25
+ const raw = await fs.readFile(AUTH_FILE, 'utf8');
26
+ const parsed = JSON.parse(raw);
27
+ if (!parsed || typeof parsed.mod8Key !== 'string')
28
+ return null;
29
+ return {
30
+ mod8Key: parsed.mod8Key,
31
+ proxyUrl: typeof parsed.proxyUrl === 'string' && parsed.proxyUrl
32
+ ? parsed.proxyUrl
33
+ : DEFAULT_PROXY_URL,
34
+ ...(typeof parsed.email === 'string' ? { email: parsed.email } : {}),
35
+ };
36
+ }
37
+ catch (err) {
38
+ if (err.code === 'ENOENT')
39
+ return null;
40
+ throw err;
41
+ }
42
+ }
43
+ export async function writeAuth(data) {
44
+ await fs.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
45
+ await fs.writeFile(AUTH_FILE, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
46
+ await fs.chmod(AUTH_FILE, 0o600);
47
+ }
48
+ export async function deleteAuth() {
49
+ try {
50
+ await fs.unlink(AUTH_FILE);
51
+ return true;
52
+ }
53
+ catch (err) {
54
+ if (err.code === 'ENOENT')
55
+ return false;
56
+ throw err;
57
+ }
58
+ }
59
+ /** Resolve the proxy URL: env override > auth.json > default. */
60
+ export function effectiveProxyUrl(auth) {
61
+ if (process.env.MOD8_PROXY_URL)
62
+ return process.env.MOD8_PROXY_URL;
63
+ return auth?.proxyUrl ?? DEFAULT_PROXY_URL;
64
+ }
65
+ export const AUTH_FILE_PATH = AUTH_FILE;
@@ -0,0 +1,35 @@
1
+ import { promises as fs } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+ const CONFIG_DIR = process.env.MOD8_CONFIG_DIR ?? join(homedir(), '.config', 'mod8');
5
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
6
+ async function readConfigFile() {
7
+ try {
8
+ const data = await fs.readFile(CONFIG_FILE, 'utf8');
9
+ const parsed = JSON.parse(data);
10
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
11
+ return parsed;
12
+ }
13
+ return {};
14
+ }
15
+ catch (err) {
16
+ if (err.code === 'ENOENT')
17
+ return {};
18
+ throw err;
19
+ }
20
+ }
21
+ async function writeConfigFile(config) {
22
+ await fs.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
23
+ await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
24
+ await fs.chmod(CONFIG_FILE, 0o600);
25
+ }
26
+ export async function getConfig() {
27
+ return readConfigFile();
28
+ }
29
+ export async function updateConfig(patch) {
30
+ const current = await readConfigFile();
31
+ const next = { ...current, ...patch };
32
+ await writeConfigFile(next);
33
+ return next;
34
+ }
35
+ export const CONFIG_FILE_PATH = CONFIG_FILE;
@@ -0,0 +1,59 @@
1
+ import { promises as fs } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+ import { PROVIDERS } from '../types.js';
5
+ const CONFIG_DIR = process.env.MOD8_CONFIG_DIR ?? join(homedir(), '.config', 'mod8');
6
+ const KEYS_FILE = join(CONFIG_DIR, 'keys.json');
7
+ async function readKeysFile() {
8
+ try {
9
+ const data = await fs.readFile(KEYS_FILE, 'utf8');
10
+ const parsed = JSON.parse(data);
11
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
12
+ return parsed;
13
+ }
14
+ return {};
15
+ }
16
+ catch (err) {
17
+ if (err.code === 'ENOENT')
18
+ return {};
19
+ throw err;
20
+ }
21
+ }
22
+ async function writeKeysFile(keys) {
23
+ await fs.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
24
+ const data = JSON.stringify(keys, null, 2) + '\n';
25
+ await fs.writeFile(KEYS_FILE, data, { mode: 0o600 });
26
+ // chmod explicitly in case the file already existed with looser perms
27
+ await fs.chmod(KEYS_FILE, 0o600);
28
+ }
29
+ export async function setKey(provider, key) {
30
+ const keys = await readKeysFile();
31
+ keys[provider] = key;
32
+ await writeKeysFile(keys);
33
+ }
34
+ export async function getKey(provider) {
35
+ const keys = await readKeysFile();
36
+ return keys[provider];
37
+ }
38
+ export async function getAllKeys() {
39
+ const keys = await readKeysFile();
40
+ const out = {};
41
+ for (const p of PROVIDERS)
42
+ out[p] = keys[p];
43
+ return out;
44
+ }
45
+ export async function removeKey(provider) {
46
+ const keys = await readKeysFile();
47
+ if (!(provider in keys))
48
+ return false;
49
+ delete keys[provider];
50
+ await writeKeysFile(keys);
51
+ return true;
52
+ }
53
+ export function maskKey(key) {
54
+ if (key.length <= 8)
55
+ return '*'.repeat(Math.max(key.length, 4));
56
+ return `${key.slice(0, 4)}…${key.slice(-4)}`;
57
+ }
58
+ export const KEYS_FILE_PATH = KEYS_FILE;
59
+ export const CONFIG_DIR_PATH = CONFIG_DIR;