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,280 @@
1
+ /**
2
+ * Intent routing helpers — parse user input from the chat REPL into structured
3
+ * actions (provider switch, compare-all, etc.) before any LLM call happens.
4
+ *
5
+ * Lives outside chat.tsx so it can be unit-tested via the dev:resolve command
6
+ * without booting the Ink UI.
7
+ */
8
+ import { looksLikeKeyNoun } from '../util/text.js';
9
+ /**
10
+ * Match a request to switch the work-mode provider, with optional remainder
11
+ * after a colon, dash, comma, or whitespace. Returns { id, rest } or null.
12
+ *
13
+ * Generous on phrasing — it must catch the way real users actually speak.
14
+ * The matched id may be a real provider id, a display name, or a synonym
15
+ * ("gpt", "claude", etc.). resolveProviderHint() does the lookup later.
16
+ *
17
+ * Phrasings handled (case-insensitive):
18
+ * /use <id> /ask <id> use <id> ask <id>
19
+ * switch to <id> switch over to <id>
20
+ * talk to <id> talk with <id> chat to <id> chat with <id>
21
+ * speak to <id> speak with <id>
22
+ * let me talk to <id> let's chat with <id> (and combos)
23
+ * i want to talk with <id> i wanna chat with <id>
24
+ * i'd like to talk to <id> i need to talk with <id>
25
+ *
26
+ * Examples:
27
+ * "use deepseek" → { id: 'deepseek' }
28
+ * "ask grok: what's the weather" → { id: 'grok', rest: "what's the weather" }
29
+ * "i want to talk with codex" → { id: 'codex' }
30
+ * "let's chat with gpt" → { id: 'gpt' }
31
+ */
32
+ const VERB_PATTERN = [
33
+ '\\/(?:use|ask)',
34
+ 'use',
35
+ 'ask',
36
+ 'switch(?:\\s+over)?\\s+to',
37
+ // Optional preamble ("i want to", "i wanna", "i'd like to", "let me",
38
+ // "let's", "lets me" — typo-tolerant) followed by a talk/chat/speak verb
39
+ // + to/with. Note: "wanna" already encodes "to", so no trailing "to".
40
+ '(?:' +
41
+ '(?:i\'d\\s+like|i\\s+(?:want|need|would\\s+like))\\s+to\\s+' +
42
+ '|i\\s+wanna\\s+' +
43
+ ')?' +
44
+ // "let'?s?" tolerates "let's", "lets", "let'", and bare "let " — followed
45
+ // by an optional "me" (matches "let's me"/"lets me"/"let me").
46
+ '(?:let\'?s?\\s+(?:me\\s+)?)?' +
47
+ '(?:talk|chat|speak)\\s+(?:to|with)',
48
+ ].join('|');
49
+ const PROVIDER_ROUTE_RE = new RegExp(`^\\s*(?:${VERB_PATTERN})\\s+([a-z][a-z0-9_.-]{0,30})\\b\\s*[:,-]?\\s*([\\s\\S]*?)\\s*$`, 'i');
50
+ export function parseProviderRoute(input) {
51
+ const m = input.match(PROVIDER_ROUTE_RE);
52
+ if (!m)
53
+ return null;
54
+ return { id: m[1].toLowerCase(), rest: (m[2] ?? '').trim() };
55
+ }
56
+ /**
57
+ * Match a request to switch BACK to host (mod8) from work mode. Runs
58
+ * BEFORE any provider call when the user is in work mode, so they're never
59
+ * stuck if the current work provider is failing — typing "mod8" is always
60
+ * an escape hatch.
61
+ *
62
+ * Returns { rest } where rest is the optional inline message after the
63
+ * trigger (e.g. "back to mod8, what's the weather?" → rest: "what's the
64
+ * weather?"). Null = no host-back intent.
65
+ *
66
+ * Phrasings handled (case-insensitive, must match the WHOLE leading intent):
67
+ * /mod8 @mod8 mod8
68
+ * back back to mod8 switch back
69
+ * switch to mod8 go back go back to mod8
70
+ * return to mod8 talk to mod8 let me talk to mod8
71
+ * change to mod8 back to host change to host
72
+ */
73
+ const HOST_BACK_PATTERNS = [
74
+ /^\s*(?:\/mod8|@mod8|mod8)\b\s*[:,-]?\s*([\s\S]*?)\s*$/i,
75
+ /^\s*back\s+to\s+(?:mod8|host)\b\s*[:,-]?\s*([\s\S]*?)\s*$/i,
76
+ /^\s*switch\s+(?:back|to)\s+(?:mod8|host)\b\s*[:,-]?\s*([\s\S]*?)\s*$/i,
77
+ /^\s*go\s+back(?:\s+to\s+(?:mod8|host))?\b\s*[:,-]?\s*([\s\S]*?)\s*$/i,
78
+ /^\s*return\s+to\s+(?:mod8|host)\b\s*[:,-]?\s*([\s\S]*?)\s*$/i,
79
+ /^\s*change\s+to\s+(?:mod8|host)\b\s*[:,-]?\s*([\s\S]*?)\s*$/i,
80
+ /^\s*(?:let(?:'s|\s+me)?\s+)?talk\s+to\s+mod8\b\s*[:,-]?\s*([\s\S]*?)\s*$/i,
81
+ /^\s*back\s*$/i, // bare "back"
82
+ /^\s*switch\s+back\s*$/i,
83
+ ];
84
+ export function parseHostBack(input) {
85
+ for (const re of HOST_BACK_PATTERNS) {
86
+ const m = input.match(re);
87
+ if (m)
88
+ return { rest: (m[1] ?? '').trim() };
89
+ }
90
+ return null;
91
+ }
92
+ export const AUTO_FALLBACK_THRESHOLD = 3;
93
+ export function fallbackDecision(consecutiveErrors) {
94
+ if (consecutiveErrors >= AUTO_FALLBACK_THRESHOLD)
95
+ return 'fallback';
96
+ if (consecutiveErrors >= 1)
97
+ return 'warn';
98
+ return 'ok';
99
+ }
100
+ const GREETINGS_RE = /^(hi|hey|hello|yo|sup|hiya|howdy)[\s,]+([a-z][a-z0-9_.-]{0,30})\b\s*[:,!?\-]?\s*([\s\S]*?)\s*$/i;
101
+ const WHOLE_NAME_RE = /^([a-z][a-z0-9_.-]{0,30})[?!,.\-]?$/i;
102
+ const FIRST_WORD_RE = /^([a-z][a-z0-9_.-]{0,30})\b\s+([\s\S]+)$/i;
103
+ export function parseBareProviderHint(input) {
104
+ const trimmed = input.trim();
105
+ if (!trimmed)
106
+ return null;
107
+ // Greeting + name (+ optional rest) — full resolution OK.
108
+ const greet = trimmed.match(GREETINGS_RE);
109
+ if (greet) {
110
+ const greeting = greet[1];
111
+ const name = greet[2].toLowerCase();
112
+ const after = (greet[3] ?? '').trim();
113
+ const rest = after ? `${greeting} ${after}` : greeting;
114
+ return { name, rest, resolution: 'full' };
115
+ }
116
+ // Whole-input single word — strict resolution only.
117
+ const whole = trimmed.match(WHOLE_NAME_RE);
118
+ if (whole) {
119
+ return { name: whole[1].toLowerCase(), rest: '', resolution: 'strict' };
120
+ }
121
+ // First word + remainder — strict resolution (so "claude alone is great"
122
+ // doesn't route; only configured-or-built-in ids/display-names do).
123
+ const firstWord = trimmed.match(FIRST_WORD_RE);
124
+ if (firstWord) {
125
+ return {
126
+ name: firstWord[1].toLowerCase(),
127
+ rest: firstWord[2].trim(),
128
+ resolution: 'strict',
129
+ };
130
+ }
131
+ return null;
132
+ }
133
+ /**
134
+ * Match a user request to add an API key inline ("add a key", "i want to
135
+ * paste a key", "let me add gemini", etc.). Returns null if no paste-key
136
+ * intent is detected; otherwise returns an object with an optional
137
+ * `providerHint` (the trailing word the user named, if any). The caller
138
+ * resolves the hint against the configured registry — if it doesn't resolve,
139
+ * the caller should treat the input as plain text rather than a paste-key
140
+ * intent (this is what protects "save my work" / "register a feature" from
141
+ * false-positiving).
142
+ *
143
+ * Phrasings handled (case-insensitive):
144
+ * add a key paste my key
145
+ * save my api key register a key
146
+ * set up a key put in a key
147
+ * i want to add a key i wanna paste a key
148
+ * i'd like to register a key let me save my key
149
+ * let's add a key lets paste a key
150
+ * add my anthropic key paste claude
151
+ * let me add gemini save my groq key
152
+ *
153
+ * Does NOT match:
154
+ * save the file let's add a feature
155
+ * set the timer put in a code-review
156
+ * (because "file" / "feature" / "timer" / "code-review" are not provider
157
+ * hints and the trailing word doesn't say "key" / "credentials" / "secret".)
158
+ */
159
+ // "use" is intentionally NOT here — "use codex" is a routing intent, not
160
+ // a paste-key intent. It IS in PASTE_PRONOUN_VERB below so "use this" /
161
+ // "use it" still work when a pendingKey is in flight.
162
+ //
163
+ // Includes change/update/replace/rotate/swap so phrasings like "lets
164
+ // change the google key" route through the inline paste flow instead of
165
+ // being passed to the host LLM (which would just lecture about
166
+ // `mod8 keys set <id>`).
167
+ const PASTE_VERB = '(?:add|paste|save|register|set(?:\\s+up)?|enter|put\\s+in|drop|store|change|update|replace|swap(?:\\s+out)?|rotate|renew|regenerate|switch)';
168
+ const PASTE_PRONOUN_VERB = '(?:add|paste|save|register|set(?:\\s+up)?|enter|put\\s+in|drop|store|use|change|update|replace|swap|rotate)';
169
+ // Modify-style verbs imply the user wants to change something — and the only
170
+ // thing mod8 manages per-provider is the key. We use this set for typo-
171
+ // tolerant matching: "change the google kew" / "update my anthropic kee"
172
+ // should all route to the inline paste flow, not the host LLM.
173
+ const PASTE_MODIFY_VERB = '(?:change|update|replace|swap(?:\\s+out)?|rotate|renew|regenerate)';
174
+ const VOLITION_PREFIX = "(?:i\\s+(?:want|wanna|need|would\\s+like)|i'?d\\s+like|let'?s?(?:\\s+me)?)\\s+(?:to\\s+)?";
175
+ // Articles + demonstratives + pronouns. "this/that/these/those" are the
176
+ // fix for "add this key!" — and "it/them" lets us match "save it" /
177
+ // "register them" when the user is referring back to a key already in view.
178
+ const ARTICLE = '(?:a|an|my|the|new|another|this|that|these|those|it|them)';
179
+ const KEY_NOUN = '(?:key|credentials?|secret)s?';
180
+ // Intent A: <volition?> <verb> [article] [api/provider] key/credentials/secret
181
+ const PASTE_KEY_RE = new RegExp(`^\\s*(?:${VOLITION_PREFIX})?${PASTE_VERB}` +
182
+ `(?:\\s+${ARTICLE})?` +
183
+ `(?:\\s+(?:api|provider))?` +
184
+ `\\s+${KEY_NOUN}\\b`, 'i');
185
+ // Intent B: <volition?> <verb> [article?] <provider-token> [key]?
186
+ // Caller validates that the provider-token is a real provider hint.
187
+ const PASTE_PROVIDER_RE = new RegExp(`^\\s*(?:${VOLITION_PREFIX})?${PASTE_VERB}` +
188
+ `(?:\\s+${ARTICLE})?\\s+` +
189
+ `([a-z][a-z0-9_.-]{1,30})` +
190
+ `(?:\\s+${KEY_NOUN})?\\s*[.,!?]?\\s*$`, 'i');
191
+ // Intent C: bare pronoun forms — "save this", "save it", "use this",
192
+ // "register it", "add this". These ONLY make sense when there is a key
193
+ // already on the screen (i.e. the user just pasted one); the chat REPL
194
+ // arms a pendingKey state in that case, which is what makes Intent C
195
+ // safe to recognize even though it has no key noun.
196
+ const PASTE_PRONOUN_RE = new RegExp(`^\\s*(?:${VOLITION_PREFIX})?${PASTE_PRONOUN_VERB}` +
197
+ `\\s+(?:this|that|it|them)` +
198
+ `(?:\\s+${KEY_NOUN})?\\s*[.,!?]?\\s*$`, 'i');
199
+ // Intent D: modify-style verb + (article)? + provider + ANY trailing word.
200
+ // Catches typos of "key": "change the google kew" / "update my anthropic
201
+ // kee" / "rotate the openai keey". Trailing word is captured so we can
202
+ // fuzzy-check it looks like "key" (otherwise we'd false-positive on
203
+ // "change google account password" etc.).
204
+ const PASTE_MODIFY_PROVIDER_TYPO_RE = new RegExp(`^\\s*(?:${VOLITION_PREFIX})?${PASTE_MODIFY_VERB}` +
205
+ `(?:\\s+${ARTICLE})?\\s+` +
206
+ `([a-z][a-z0-9_.-]{1,30})` +
207
+ `\\s+(\\S{2,16})\\s*[.,!?]?\\s*$`, 'i');
208
+ export function parsePasteKeyIntent(input) {
209
+ const trimmed = input.trim();
210
+ if (!trimmed)
211
+ return null;
212
+ if (PASTE_KEY_RE.test(trimmed))
213
+ return {};
214
+ if (PASTE_PRONOUN_RE.test(trimmed))
215
+ return { pronounRef: true };
216
+ const m = trimmed.match(PASTE_PROVIDER_RE);
217
+ if (m) {
218
+ const token = m[1].toLowerCase();
219
+ // The "token" position can also be a typo of the key noun itself
220
+ // ("change my kew" / "rotate the kee" — no provider, just a typo of
221
+ // "key"). Treat these as a generic paste-key intent without a hint.
222
+ if (looksLikeKeyNoun(token))
223
+ return {};
224
+ return { providerHint: token };
225
+ }
226
+ // Typo-tolerant fallback for modify-style verbs that name a provider
227
+ // ("change the google kew", "rotate my anthropic kee"). Accept only
228
+ // when the trailing word fuzzy-matches "key" — otherwise "change
229
+ // google account password" would false-positive.
230
+ const m2 = trimmed.match(PASTE_MODIFY_PROVIDER_TYPO_RE);
231
+ if (m2 && looksLikeKeyNoun(m2[2])) {
232
+ return { providerHint: m2[1].toLowerCase() };
233
+ }
234
+ return null;
235
+ }
236
+ /**
237
+ * Affirmative responses — "yes", "y", "sure", "go", "do it". Used for
238
+ * yes/no confirmation prompts (fuzzy match, paste-key, etc). Conservative:
239
+ * exact-shape matches only, no fuzzy reasoning.
240
+ */
241
+ const AFFIRM_RE = /^\s*(?:yes|y|yeah|yep|yup|sure|ok|okay|alright|fine|please|go|do\s+it|switch|please\s+do|confirm|correct)\s*[!?.,]?\s*$/i;
242
+ /**
243
+ * Negative responses — "no", "cancel", "skip". Lets the chat REPL ack a
244
+ * cancellation without sending it to the LLM.
245
+ */
246
+ const NEGATIVE_RE = /^\s*(?:no|nope|nah|cancel|skip|never|forget\s+it|don'?t|dont|never\s+mind|nevermind)\s*[!?.,]?\s*$/i;
247
+ export function isAffirmative(input) {
248
+ return AFFIRM_RE.test(input.trim());
249
+ }
250
+ export function isNegative(input) {
251
+ return NEGATIVE_RE.test(input.trim());
252
+ }
253
+ /**
254
+ * Composite affirmative for the paste-key confirm step — accepts plain
255
+ * "yes" along with any paste-key phrasing ("save it", "save this", "use it",
256
+ * etc.). In pendingKey context these are unambiguous: the user just pasted
257
+ * a key, mod8 asked "save this as X?", and "save it" means yes.
258
+ */
259
+ export function isPasteConfirmAffirmative(input) {
260
+ return isAffirmative(input) || parsePasteKeyIntent(input) !== null;
261
+ }
262
+ /** Bare compare command without a payload prompt. */
263
+ export function isCompareCommand(input) {
264
+ const s = input.trim().toLowerCase();
265
+ return (s === '/compare' ||
266
+ s === 'compare all' ||
267
+ s === 'ask everyone' ||
268
+ /^compare(\s+all)?\s*[:,-]?\s*$/.test(s));
269
+ }
270
+ /**
271
+ * Match a compare command WITH a payload prompt:
272
+ * "compare all: write a haiku"
273
+ * "ask everyone: write a haiku"
274
+ * "/compare write a haiku"
275
+ * Returns the payload, or null if the input isn't a compare-with-payload.
276
+ */
277
+ export function parseCompareWithPrompt(input) {
278
+ const m = input.match(/^(?:\/compare|compare all|ask everyone)\s*[:,-]?\s*([\s\S]+)$/i);
279
+ return m ? m[1].trim() : null;
280
+ }
@@ -0,0 +1,55 @@
1
+ import chalk from 'chalk';
2
+ import { setProviderKey, removeProvider, listProviders, PROVIDERS_FILE_PATH, } from '../storage/providers.js';
3
+ import { KNOWN_PROVIDERS, templateById } from '../providers/registry.js';
4
+ import { readSecret, maskKey } from '../util/prompt.js';
5
+ export async function keysSet(provider) {
6
+ const tpl = templateById(provider);
7
+ if (!tpl) {
8
+ console.error(chalk.red(`Unknown provider '${provider}'.`) +
9
+ `\n Built-in: ${KNOWN_PROVIDERS.map((p) => p.id).join(', ')}` +
10
+ `\n For other providers, use: mod8 add-provider`);
11
+ process.exit(1);
12
+ }
13
+ const key = await readSecret(`Enter API key for ${tpl.name}: `);
14
+ if (!key.trim()) {
15
+ console.error(chalk.red('No key entered. Aborted.'));
16
+ process.exit(1);
17
+ }
18
+ await setProviderKey(provider, key.trim());
19
+ console.log(chalk.green('✓') + ` Saved key for ${tpl.name}`);
20
+ console.log(chalk.dim(` Stored at ${PROVIDERS_FILE_PATH} (file is 0600, only readable by you)`));
21
+ }
22
+ export async function keysList() {
23
+ const stored = await listProviders();
24
+ console.log();
25
+ // Show every built-in provider — configured or not — plus any custom ones.
26
+ const seen = new Set();
27
+ for (const tpl of KNOWN_PROVIDERS) {
28
+ seen.add(tpl.id);
29
+ const entry = stored[tpl.id];
30
+ const value = entry ? chalk.dim(maskKey(entry.apiKey)) : chalk.dim('(not set)');
31
+ console.log(` ${tpl.id.padEnd(12)} ${value.padEnd(24)} ${chalk.dim(tpl.name)}`);
32
+ }
33
+ for (const [id, entry] of Object.entries(stored)) {
34
+ if (seen.has(id))
35
+ continue;
36
+ console.log(` ${id.padEnd(12)} ${chalk.dim(maskKey(entry.apiKey)).padEnd(24)} ${chalk.dim(entry.name)} ${chalk.dim('(custom)')}`);
37
+ }
38
+ console.log();
39
+ console.log(chalk.dim(`Stored at ${PROVIDERS_FILE_PATH}`));
40
+ }
41
+ export async function keysRemove(provider) {
42
+ const stored = await listProviders();
43
+ if (!(provider in stored) && !templateById(provider)) {
44
+ console.error(chalk.red(`Unknown provider '${provider}'.`) +
45
+ `\n Built-in: ${KNOWN_PROVIDERS.map((p) => p.id).join(', ')}`);
46
+ process.exit(1);
47
+ }
48
+ const removed = await removeProvider(provider);
49
+ if (removed) {
50
+ console.log(chalk.green('✓') + ` Removed key for ${provider}`);
51
+ }
52
+ else {
53
+ console.log(chalk.dim(`No key was set for ${provider}.`));
54
+ }
55
+ }
@@ -0,0 +1,27 @@
1
+ import chalk from 'chalk';
2
+ import { listSessions, fallbackTitle, loadSession } from '../storage/sessions.js';
3
+ import { humanTimeAgo } from '../util/time.js';
4
+ export async function listCommand() {
5
+ const summaries = await listSessions(20);
6
+ if (summaries.length === 0) {
7
+ console.log();
8
+ console.log(chalk.dim(' no sessions yet — run `mod8` to start one'));
9
+ console.log();
10
+ return;
11
+ }
12
+ console.log();
13
+ for (const s of summaries) {
14
+ let title = s.title;
15
+ if (!title) {
16
+ // Title not yet generated — fall back to first message.
17
+ const session = await loadSession(s.id);
18
+ title = session ? fallbackTitle(session) : '(no title)';
19
+ }
20
+ const ago = humanTimeAgo(s.lastActivity);
21
+ const turns = s.turnCount === 1 ? '1 turn' : `${s.turnCount} turns`;
22
+ console.log(` ${chalk.dim(s.id)} ${chalk.bold(title)} ${chalk.dim(`· ${ago} · ${turns}`)}`);
23
+ }
24
+ console.log();
25
+ console.log(chalk.dim(' resume any with: mod8 resume <id>'));
26
+ console.log();
27
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * `mod8 login` — bridge the CLI to the mod8 hosted product.
3
+ *
4
+ * 1. Open the user's browser to https://mod8-web/cli-login (or whatever
5
+ * MOD8_LOGIN_URL points at — overridable for staging).
6
+ * 2. Prompt the terminal for the sk-mod8-... key they copy from that page.
7
+ * 3. Validate by calling /v1/chat with a dry-ping… actually just smoke
8
+ * against the proxy with a 1-token request to confirm shape + balance.
9
+ * 4. Save ~/.config/mod8/auth.json with mode 0600.
10
+ *
11
+ * No callback URL handshake (yet) — paste keeps things deterministic across
12
+ * shells and avoids spawning a local listener.
13
+ */
14
+ import chalk from 'chalk';
15
+ import { createInterface } from 'readline';
16
+ import { exec } from 'child_process';
17
+ import { platform } from 'os';
18
+ import { writeAuth, readAuth, DEFAULT_PROXY_URL, AUTH_FILE_PATH, } from '../storage/auth.js';
19
+ const DEFAULT_LOGIN_URL = 'https://mod8-495901.web.app/cli-login';
20
+ export async function loginCommand() {
21
+ const loginUrl = process.env.MOD8_LOGIN_URL ?? DEFAULT_LOGIN_URL;
22
+ const proxyUrl = process.env.MOD8_PROXY_URL ?? DEFAULT_PROXY_URL;
23
+ const existing = await readAuth();
24
+ if (existing) {
25
+ process.stderr.write(chalk.yellow(`Already logged in${existing.email ? ` as ${existing.email}` : ''}. ` +
26
+ `Use \`mod8 logout\` to drop credentials first if you want to re-link.\n`));
27
+ return;
28
+ }
29
+ process.stdout.write('\n');
30
+ process.stdout.write(chalk.bold('Connect your terminal to mod8\n'));
31
+ process.stdout.write(chalk.dim(`Opening ${loginUrl} …\n\n`));
32
+ // Best-effort browser open. If it fails, the user sees the URL and can
33
+ // copy it manually — the paste-key path still works.
34
+ openBrowserBestEffort(loginUrl);
35
+ process.stdout.write(`If it didn't open, visit: ${chalk.cyan(loginUrl)}\n\n`);
36
+ process.stdout.write(`Then paste your CLI key here (starts with ${chalk.bold('sk-mod8-')}):\n`);
37
+ const key = await readLine('> ');
38
+ const trimmed = key.trim();
39
+ if (!trimmed) {
40
+ throw new Error('No key entered.');
41
+ }
42
+ if (!trimmed.startsWith('sk-mod8-')) {
43
+ throw new Error(`That doesn't look like a mod8 key (expected sk-mod8-...).`);
44
+ }
45
+ // Sanity-ping the proxy with a tiny request — confirms the key is real
46
+ // AND lets us echo the email + balance back to the user immediately.
47
+ const meta = await pingProxy(proxyUrl, trimmed);
48
+ await writeAuth({
49
+ mod8Key: trimmed,
50
+ proxyUrl,
51
+ ...(meta.email ? { email: meta.email } : {}),
52
+ });
53
+ process.stdout.write(`\n${chalk.green('✓')} Saved to ${chalk.dim(AUTH_FILE_PATH)}\n` +
54
+ `${chalk.green('✓')} Logged in${meta.email ? ` as ${chalk.bold(meta.email)}` : ''}` +
55
+ (typeof meta.availableMicros === 'number'
56
+ ? ` — ${chalk.bold(formatUsd(meta.availableMicros))} balance`
57
+ : '') +
58
+ '\n\n' +
59
+ chalk.dim('Try it: ') +
60
+ chalk.cyan('mod8 -c "hi from the proxy"') +
61
+ '\n');
62
+ }
63
+ function openBrowserBestEffort(url) {
64
+ const cmd = platform() === 'darwin'
65
+ ? `open ${shellQuote(url)}`
66
+ : platform() === 'win32'
67
+ ? `start "" ${shellQuote(url)}`
68
+ : `xdg-open ${shellQuote(url)}`;
69
+ exec(cmd, () => {
70
+ // Silently ignore — terminal-only environments will rely on the printed URL.
71
+ });
72
+ }
73
+ function shellQuote(s) {
74
+ return `"${s.replace(/"/g, '\\"')}"`;
75
+ }
76
+ async function readLine(prompt) {
77
+ return new Promise((resolve, reject) => {
78
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
79
+ rl.question(prompt, (line) => {
80
+ rl.close();
81
+ resolve(line);
82
+ });
83
+ rl.on('error', reject);
84
+ });
85
+ }
86
+ /**
87
+ * Sanity-check + metadata fetch. We don't have a "whoami" endpoint on the
88
+ * proxy itself, so we POST a maxTokens=1 anthropic call. That:
89
+ * - validates the bearer token (401 if bad)
90
+ * - confirms the master key is seeded (500 if not)
91
+ * - returns chargedMicros + balanceAfterMicros, which we use to print the
92
+ * balance hint.
93
+ *
94
+ * Cost: a fraction of a cent. Cheap enough that we eat it for the UX.
95
+ */
96
+ async function pingProxy(proxyUrl, mod8Key) {
97
+ const resp = await fetch(`${proxyUrl}/v1/chat`, {
98
+ method: 'POST',
99
+ headers: { Authorization: `Bearer ${mod8Key}`, 'Content-Type': 'application/json' },
100
+ body: JSON.stringify({
101
+ provider: 'anthropic',
102
+ model: 'claude-haiku-4-5',
103
+ maxTokens: 1,
104
+ messages: [{ role: 'user', content: 'ok' }],
105
+ }),
106
+ });
107
+ if (resp.status === 401)
108
+ throw new Error('Key not recognized by the proxy.');
109
+ if (!resp.ok) {
110
+ const detail = await resp.text().catch(() => '');
111
+ throw new Error(`Proxy ping failed: ${resp.status} ${detail.slice(0, 160)}`);
112
+ }
113
+ if (!resp.body)
114
+ return {};
115
+ const reader = resp.body.getReader();
116
+ const decoder = new TextDecoder();
117
+ let buf = '';
118
+ let availableMicros;
119
+ while (true) {
120
+ const { done, value } = await reader.read();
121
+ if (done)
122
+ break;
123
+ buf += decoder.decode(value, { stream: true });
124
+ let idx;
125
+ while ((idx = buf.indexOf('\n\n')) >= 0) {
126
+ const chunk = buf.slice(0, idx);
127
+ buf = buf.slice(idx + 2);
128
+ for (const line of chunk.split('\n')) {
129
+ if (!line.startsWith('data: '))
130
+ continue;
131
+ try {
132
+ const ev = JSON.parse(line.slice(6));
133
+ if (ev.type === 'done' && typeof ev.balanceAfterMicros === 'number') {
134
+ availableMicros = ev.balanceAfterMicros;
135
+ }
136
+ }
137
+ catch {
138
+ // ignore non-JSON
139
+ }
140
+ }
141
+ }
142
+ }
143
+ return availableMicros !== undefined ? { availableMicros } : {};
144
+ }
145
+ function formatUsd(micros) {
146
+ return `$${(micros / 1_000_000).toFixed(2)}`;
147
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `mod8 logout` — drop the saved mod8 credentials. The CLI falls back to
3
+ * local providers.json for subsequent requests.
4
+ */
5
+ import chalk from 'chalk';
6
+ import { deleteAuth, AUTH_FILE_PATH } from '../storage/auth.js';
7
+ export async function logoutCommand() {
8
+ const removed = await deleteAuth();
9
+ if (removed) {
10
+ process.stdout.write(`${chalk.green('✓')} Logged out — ${chalk.dim(AUTH_FILE_PATH)} removed.\n` +
11
+ chalk.dim('Falling back to local providers.json.\n'));
12
+ }
13
+ else {
14
+ process.stdout.write(chalk.dim('Not logged in.\n') +
15
+ chalk.dim(`No file at ${AUTH_FILE_PATH}.\n`));
16
+ }
17
+ }
@@ -0,0 +1,63 @@
1
+ import chalk from 'chalk';
2
+ import { getProviderClient } from '../providers/index.js';
3
+ import { formatCost } from '../providers/pricing.js';
4
+ import { getConfig } from '../storage/config.js';
5
+ import { classifyError } from '../util/errors.js';
6
+ export async function resolveProvider(opts) {
7
+ const flags = [opts.claude, opts.openai, opts.gemini, opts.deepseek].filter(Boolean).length;
8
+ if (flags > 1) {
9
+ throw new Error('Cannot use multiple provider flags. Pick one of -c, -o, -g, -d, or use --all.');
10
+ }
11
+ if (opts.claude)
12
+ return 'anthropic';
13
+ if (opts.openai)
14
+ return 'openai';
15
+ if (opts.gemini)
16
+ return 'google';
17
+ if (opts.deepseek)
18
+ return 'deepseek';
19
+ const config = await getConfig();
20
+ return config.default ?? 'anthropic';
21
+ }
22
+ export function formatStats(usage) {
23
+ const totalTokens = (usage.inputTokens + usage.outputTokens).toLocaleString();
24
+ const seconds = (usage.latencyMs / 1000).toFixed(2);
25
+ return `${chalk.dim('—')} ${chalk.bold(usage.model)} ${chalk.dim(`${totalTokens} tok · ${seconds}s · ${formatCost(usage.costUsd)}`)}`;
26
+ }
27
+ export async function runPrompt({ provider, prompt }) {
28
+ let client;
29
+ try {
30
+ client = await getProviderClient(provider);
31
+ }
32
+ catch (err) {
33
+ console.error(chalk.red('mod8: ') + err.message);
34
+ process.exit(1);
35
+ }
36
+ let usage;
37
+ let lastChar = '';
38
+ try {
39
+ for await (const event of client.stream(prompt)) {
40
+ if (event.type === 'text') {
41
+ process.stdout.write(event.delta);
42
+ if (event.delta.length > 0) {
43
+ lastChar = event.delta[event.delta.length - 1];
44
+ }
45
+ }
46
+ else if (event.type === 'done') {
47
+ usage = event.usage;
48
+ }
49
+ }
50
+ }
51
+ catch (err) {
52
+ if (lastChar !== '\n' && lastChar !== '')
53
+ process.stdout.write('\n');
54
+ console.error(chalk.red('mod8: ') + chalk.dim(`${provider}: `) + classifyError(err, provider));
55
+ process.exit(1);
56
+ }
57
+ if (lastChar !== '\n')
58
+ process.stdout.write('\n');
59
+ if (usage) {
60
+ console.log();
61
+ console.log(formatStats(usage));
62
+ }
63
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `mod8 providers` — list configured providers (id, name, color, model, base URL).
3
+ *
4
+ * Different from `mod8 keys list`:
5
+ * - `keys list` shows every built-in slot + key state (set / not set)
6
+ * - `providers` shows only what the user has actually configured, with the
7
+ * details mod8 will use at call time (model, base URL, color)
8
+ */
9
+ import chalk from 'chalk';
10
+ import { listProviders, PROVIDERS_FILE_PATH } from '../storage/providers.js';
11
+ export async function listProvidersCommand() {
12
+ const stored = await listProviders();
13
+ const entries = Object.entries(stored);
14
+ console.log();
15
+ if (entries.length === 0) {
16
+ console.log(chalk.dim(' no providers configured yet.'));
17
+ console.log(chalk.dim(' add one with: mod8 add-provider'));
18
+ console.log(chalk.dim(' or for a built-in: mod8 keys set <provider>'));
19
+ console.log();
20
+ return;
21
+ }
22
+ for (const [id, e] of entries) {
23
+ const dot = chalk.hex(e.color)('●');
24
+ const tag = e.custom ? chalk.dim(' (custom)') : '';
25
+ const base = e.baseUrl ? chalk.dim(` ${e.baseUrl}`) : '';
26
+ console.log(` ${dot} ${chalk.bold(id.padEnd(12))} ${chalk.dim(e.name)} · ${chalk.dim(e.apiType)} · ${chalk.dim(e.defaultModel)}${tag}${base}`);
27
+ }
28
+ console.log();
29
+ console.log(chalk.dim(` ${entries.length} provider${entries.length === 1 ? '' : 's'} · stored at ${PROVIDERS_FILE_PATH}`));
30
+ }
@@ -0,0 +1,5 @@
1
+ import { runVerify } from '../verify/runner.js';
2
+ export async function verifyCommand() {
3
+ const summary = await runVerify();
4
+ process.exit(summary.fail > 0 ? 1 : 0);
5
+ }
@@ -0,0 +1,37 @@
1
+ import { resolveFileRefs } from './files.js';
2
+ /**
3
+ * Build the final prompt sent to providers.
4
+ *
5
+ * Layout:
6
+ * <user prompt>
7
+ *
8
+ * [file: path1]
9
+ * <content of path1>
10
+ *
11
+ * [file: path2]
12
+ * <content of path2>
13
+ *
14
+ * <piped stdin content>
15
+ *
16
+ * @file refs that fail to load become warnings (and are left in-place in the prompt).
17
+ */
18
+ export async function composePrompt(prompt, stdin) {
19
+ const warnings = [];
20
+ const sections = [prompt.trimEnd()];
21
+ const refs = await resolveFileRefs(prompt);
22
+ for (const ref of refs) {
23
+ if (ref.content !== undefined) {
24
+ sections.push('');
25
+ sections.push(`[file: ${ref.path}]`);
26
+ sections.push(ref.content.trimEnd());
27
+ }
28
+ else {
29
+ warnings.push(`@${ref.path}: ${ref.error}`);
30
+ }
31
+ }
32
+ if (stdin && stdin.trim()) {
33
+ sections.push('');
34
+ sections.push(stdin.trimEnd());
35
+ }
36
+ return { finalPrompt: sections.join('\n'), warnings };
37
+ }