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
|
@@ -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;
|