amicus 1.0.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 +46 -0
- package/LICENSE +21 -0
- package/README.md +477 -0
- package/bin/amicus.js +382 -0
- package/electron/assets/icon.png +0 -0
- package/electron/assets/icon.svg +5 -0
- package/electron/fold.js +163 -0
- package/electron/ipc-setup.js +176 -0
- package/electron/load-failsafe.js +85 -0
- package/electron/main.js +468 -0
- package/electron/preload-setup.js +38 -0
- package/electron/preload.js +33 -0
- package/electron/setup-ui-alias-script.js +218 -0
- package/electron/setup-ui-aliases.js +85 -0
- package/electron/setup-ui-keys-script.js +115 -0
- package/electron/setup-ui-keys.js +97 -0
- package/electron/setup-ui-model.js +138 -0
- package/electron/setup-ui-styles.js +327 -0
- package/electron/setup-ui.js +465 -0
- package/electron/summary.js +118 -0
- package/electron/toolbar.js +229 -0
- package/electron/window-position.js +35 -0
- package/package.json +98 -0
- package/scripts/postinstall.js +193 -0
- package/scripts/setup-hooks.js +42 -0
- package/skill/SKILL.md +976 -0
- package/skills/second-opinion/COUNCIL-DESIGN.md +227 -0
- package/skills/second-opinion/MODEL-NOTES.md +104 -0
- package/skills/second-opinion/SKILL.md +389 -0
- package/src/cli-handlers.js +188 -0
- package/src/cli.js +400 -0
- package/src/conflict.js +144 -0
- package/src/context-compression.js +102 -0
- package/src/context.js +199 -0
- package/src/drift.js +144 -0
- package/src/environment.js +157 -0
- package/src/headless.js +742 -0
- package/src/index.js +106 -0
- package/src/jsonl-parser.js +180 -0
- package/src/mcp-server.js +625 -0
- package/src/mcp-tools.js +407 -0
- package/src/opencode-client.js +615 -0
- package/src/prompt-builder.js +355 -0
- package/src/prompts/cowork-agent-prompt.js +118 -0
- package/src/session-manager.js +414 -0
- package/src/session.js +180 -0
- package/src/sidecar/context-builder.js +297 -0
- package/src/sidecar/continue.js +212 -0
- package/src/sidecar/crash-handler.js +56 -0
- package/src/sidecar/fanout-leg.js +107 -0
- package/src/sidecar/fanout-output.js +46 -0
- package/src/sidecar/fanout.js +236 -0
- package/src/sidecar/interactive.js +217 -0
- package/src/sidecar/models.js +135 -0
- package/src/sidecar/progress.js +218 -0
- package/src/sidecar/read.js +183 -0
- package/src/sidecar/resume.js +221 -0
- package/src/sidecar/session-utils.js +288 -0
- package/src/sidecar/setup-window.js +79 -0
- package/src/sidecar/setup.js +280 -0
- package/src/sidecar/start.js +251 -0
- package/src/utils/agent-mapping.js +138 -0
- package/src/utils/alias-audit.js +98 -0
- package/src/utils/alias-resolver.js +77 -0
- package/src/utils/api-key-store.js +259 -0
- package/src/utils/api-key-validation.js +97 -0
- package/src/utils/auth-json.js +109 -0
- package/src/utils/config.js +291 -0
- package/src/utils/curated-models.js +82 -0
- package/src/utils/env-compat.js +38 -0
- package/src/utils/env-loader.js +54 -0
- package/src/utils/idle-watchdog.js +225 -0
- package/src/utils/input-validators.js +127 -0
- package/src/utils/lifecycle.js +43 -0
- package/src/utils/logger.js +84 -0
- package/src/utils/mcp-discovery.js +194 -0
- package/src/utils/mcp-validators.js +78 -0
- package/src/utils/model-catalog.js +103 -0
- package/src/utils/model-fetcher.js +179 -0
- package/src/utils/model-validator.js +207 -0
- package/src/utils/path-setup.js +41 -0
- package/src/utils/port-pid.js +39 -0
- package/src/utils/prompt-source.js +53 -0
- package/src/utils/result-schema.js +261 -0
- package/src/utils/server-setup.js +93 -0
- package/src/utils/session-abort.js +53 -0
- package/src/utils/session-lock.js +95 -0
- package/src/utils/shared-server.js +216 -0
- package/src/utils/start-helpers.js +76 -0
- package/src/utils/thinking-validators.js +92 -0
- package/src/utils/update-notifier-loader.js +18 -0
- package/src/utils/updater.js +157 -0
- package/src/utils/validators.js +300 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Key Store — reading, saving, and validating API keys.
|
|
3
|
+
* Keys stored in ~/.config/amicus/.env with 0o600 permissions.
|
|
4
|
+
*/
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { validateApiKey, validateOpenRouterKey, VALIDATION_ENDPOINTS } = require('./api-key-validation');
|
|
8
|
+
const { getCompatEnv } = require('./env-compat');
|
|
9
|
+
|
|
10
|
+
/** Maps provider IDs to environment variable names */
|
|
11
|
+
const PROVIDER_ENV_MAP = {
|
|
12
|
+
openrouter: 'OPENROUTER_API_KEY',
|
|
13
|
+
google: 'GOOGLE_GENERATIVE_AI_API_KEY',
|
|
14
|
+
openai: 'OPENAI_API_KEY',
|
|
15
|
+
anthropic: 'ANTHROPIC_API_KEY',
|
|
16
|
+
deepseek: 'DEEPSEEK_API_KEY'
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Legacy key names that have been renamed (old -> new) */
|
|
20
|
+
const LEGACY_KEY_NAMES = {
|
|
21
|
+
'GEMINI_API_KEY': 'GOOGLE_GENERATIVE_AI_API_KEY'
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Get the path to the .env file */
|
|
25
|
+
function getEnvPath() {
|
|
26
|
+
const envDir = getCompatEnv('ENV_DIR');
|
|
27
|
+
if (envDir) {
|
|
28
|
+
const resolved = path.resolve(envDir);
|
|
29
|
+
if (resolved.includes('\0')) {
|
|
30
|
+
throw new Error('Invalid ENV_DIR: null bytes not allowed');
|
|
31
|
+
}
|
|
32
|
+
return path.join(resolved, '.env');
|
|
33
|
+
}
|
|
34
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE;
|
|
35
|
+
const amicusEnvPath = path.join(homeDir, '.config', 'amicus', '.env');
|
|
36
|
+
// DEPRECATED(amicus-shim): fall back to the legacy ~/.config/sidecar/.env if it
|
|
37
|
+
// exists and the new one does not, so pre-rebrand installs keep reading/writing
|
|
38
|
+
// their existing keys. Remove in a future revision — see docs/SHIMS.md.
|
|
39
|
+
if (!fs.existsSync(amicusEnvPath)) {
|
|
40
|
+
const legacyEnvPath = path.join(homeDir, '.config', 'sidecar', '.env');
|
|
41
|
+
if (fs.existsSync(legacyEnvPath)) {
|
|
42
|
+
return legacyEnvPath;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return amicusEnvPath;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Parse a .env file into a key-value map (comments/blanks excluded) */
|
|
49
|
+
function parseEnvContent(content) {
|
|
50
|
+
const entries = new Map();
|
|
51
|
+
const lines = content.split('\n');
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
const trimmed = line.trim();
|
|
54
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const eqIndex = trimmed.indexOf('=');
|
|
58
|
+
if (eqIndex === -1) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const key = trimmed.slice(0, eqIndex);
|
|
62
|
+
const value = trimmed.slice(eqIndex + 1);
|
|
63
|
+
entries.set(key, value);
|
|
64
|
+
}
|
|
65
|
+
return entries;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Migrate a legacy key name in a .env file (best-effort, one-time) */
|
|
69
|
+
function migrateEnvFileKey(envPath, oldName, newName) {
|
|
70
|
+
try {
|
|
71
|
+
const content = fs.readFileSync(envPath, 'utf-8');
|
|
72
|
+
const re = new RegExp(`^${oldName}=`, 'm');
|
|
73
|
+
const updated = content.replace(re, `${newName}=`);
|
|
74
|
+
if (updated !== content) {
|
|
75
|
+
fs.writeFileSync(envPath, updated, { mode: 0o600 });
|
|
76
|
+
}
|
|
77
|
+
} catch (_err) {
|
|
78
|
+
// Best effort
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Load .env file entries (auto-migrates legacy key names) */
|
|
83
|
+
function loadEnvEntries() {
|
|
84
|
+
const envPath = getEnvPath();
|
|
85
|
+
let fileEntries = new Map();
|
|
86
|
+
try {
|
|
87
|
+
if (fs.existsSync(envPath)) {
|
|
88
|
+
const content = fs.readFileSync(envPath, 'utf-8');
|
|
89
|
+
fileEntries = parseEnvContent(content);
|
|
90
|
+
// Auto-migrate legacy key names
|
|
91
|
+
for (const [oldName, newName] of Object.entries(LEGACY_KEY_NAMES)) {
|
|
92
|
+
if (fileEntries.has(oldName) && !fileEntries.has(newName)) {
|
|
93
|
+
fileEntries.set(newName, fileEntries.get(oldName));
|
|
94
|
+
fileEntries.delete(oldName);
|
|
95
|
+
migrateEnvFileKey(envPath, oldName, newName);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch (_err) {
|
|
100
|
+
// Ignore read errors
|
|
101
|
+
}
|
|
102
|
+
return fileEntries;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Resolve key value: file entry takes precedence over process.env */
|
|
106
|
+
function resolveKeyValue(fileEntries, envVar) {
|
|
107
|
+
const fromFile = fileEntries.get(envVar);
|
|
108
|
+
if (fromFile && fromFile.length > 0) { return fromFile; }
|
|
109
|
+
const fromEnv = process.env[envVar];
|
|
110
|
+
if (fromEnv && fromEnv.length > 0) { return fromEnv; }
|
|
111
|
+
return '';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Read API key availability from .env file and process.env
|
|
116
|
+
* @returns {{openrouter: boolean, google: boolean, openai: boolean, anthropic: boolean, deepseek: boolean}}
|
|
117
|
+
*/
|
|
118
|
+
function readApiKeys() {
|
|
119
|
+
const result = { openrouter: false, google: false, openai: false, anthropic: false, deepseek: false };
|
|
120
|
+
const entries = loadEnvEntries();
|
|
121
|
+
for (const [provider, envVar] of Object.entries(PROVIDER_ENV_MAP)) {
|
|
122
|
+
if (resolveKeyValue(entries, envVar)) { result[provider] = true; }
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Read API key hints (masked prefixes) for UI display
|
|
129
|
+
* @returns {{openrouter: string|false, google: string|false, openai: string|false, anthropic: string|false, deepseek: string|false}}
|
|
130
|
+
*/
|
|
131
|
+
function readApiKeyHints() {
|
|
132
|
+
const result = { openrouter: false, google: false, openai: false, anthropic: false, deepseek: false };
|
|
133
|
+
const entries = loadEnvEntries();
|
|
134
|
+
for (const [provider, envVar] of Object.entries(PROVIDER_ENV_MAP)) {
|
|
135
|
+
const key = resolveKeyValue(entries, envVar);
|
|
136
|
+
if (key) {
|
|
137
|
+
const visible = key.slice(0, 8);
|
|
138
|
+
result[provider] = visible + '\u2022'.repeat(Math.max(0, Math.min(key.length - 8, 12)));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Read actual API key strings for configured providers.
|
|
146
|
+
* Used by model-fetcher to authenticate against provider APIs.
|
|
147
|
+
* @returns {Object<string, string>} Map of provider → key string (only set providers)
|
|
148
|
+
*/
|
|
149
|
+
function readApiKeyValues() {
|
|
150
|
+
const result = {};
|
|
151
|
+
const entries = loadEnvEntries();
|
|
152
|
+
for (const [provider, envVar] of Object.entries(PROVIDER_ENV_MAP)) {
|
|
153
|
+
const value = resolveKeyValue(entries, envVar);
|
|
154
|
+
if (value) { result[provider] = value; }
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Save an API key for a provider to the .env file */
|
|
160
|
+
function saveApiKey(provider, key) {
|
|
161
|
+
const envVar = PROVIDER_ENV_MAP[provider];
|
|
162
|
+
if (!envVar) {
|
|
163
|
+
return { success: false, error: `Unknown provider: ${provider}` };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const envPath = getEnvPath();
|
|
167
|
+
const envDir = path.dirname(envPath);
|
|
168
|
+
fs.mkdirSync(envDir, { recursive: true });
|
|
169
|
+
|
|
170
|
+
// Read existing .env content, preserving comments and other lines
|
|
171
|
+
let lines = [];
|
|
172
|
+
try {
|
|
173
|
+
if (fs.existsSync(envPath)) {
|
|
174
|
+
const content = fs.readFileSync(envPath, 'utf-8');
|
|
175
|
+
lines = content.split('\n');
|
|
176
|
+
}
|
|
177
|
+
} catch (_err) {
|
|
178
|
+
// Start fresh
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Find and replace the line for this env var, or append
|
|
182
|
+
let found = false;
|
|
183
|
+
for (let i = 0; i < lines.length; i++) {
|
|
184
|
+
const trimmed = lines[i].trim();
|
|
185
|
+
if (trimmed.startsWith(envVar + '=')) {
|
|
186
|
+
lines[i] = `${envVar}=${key}`;
|
|
187
|
+
found = true;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (!found) {
|
|
192
|
+
// Remove trailing empty lines before appending
|
|
193
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
|
|
194
|
+
lines.pop();
|
|
195
|
+
}
|
|
196
|
+
lines.push(`${envVar}=${key}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Write with trailing newline
|
|
200
|
+
const output = lines.join('\n') + '\n';
|
|
201
|
+
fs.writeFileSync(envPath, output, { mode: 0o600 });
|
|
202
|
+
|
|
203
|
+
// Also set process.env so the key is immediately available
|
|
204
|
+
process.env[envVar] = key;
|
|
205
|
+
|
|
206
|
+
return { success: true };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Remove an API key for a provider from the .env file */
|
|
210
|
+
function removeApiKey(provider) {
|
|
211
|
+
const envVar = PROVIDER_ENV_MAP[provider];
|
|
212
|
+
if (!envVar) {
|
|
213
|
+
return { success: false, error: `Unknown provider: ${provider}` };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const envPath = getEnvPath();
|
|
217
|
+
try {
|
|
218
|
+
if (!fs.existsSync(envPath)) {
|
|
219
|
+
const { checkAuthJson } = require('./auth-json');
|
|
220
|
+
delete process.env[envVar];
|
|
221
|
+
return { success: true, alsoInAuthJson: checkAuthJson(provider) };
|
|
222
|
+
}
|
|
223
|
+
const content = fs.readFileSync(envPath, 'utf-8');
|
|
224
|
+
const lines = content.split('\n').filter(line => {
|
|
225
|
+
return !line.trim().startsWith(envVar + '=');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Remove trailing empty lines
|
|
229
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
|
|
230
|
+
lines.pop();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const output = lines.length > 0 ? lines.join('\n') + '\n' : '';
|
|
234
|
+
fs.writeFileSync(envPath, output, { mode: 0o600 });
|
|
235
|
+
} catch (_err) {
|
|
236
|
+
// Ignore
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
delete process.env[envVar];
|
|
240
|
+
|
|
241
|
+
// Check if key also exists in auth.json (caller decides whether to clean)
|
|
242
|
+
const { checkAuthJson } = require('./auth-json');
|
|
243
|
+
return { success: true, alsoInAuthJson: checkAuthJson(provider) };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
module.exports = {
|
|
247
|
+
getEnvPath,
|
|
248
|
+
loadEnvEntries,
|
|
249
|
+
readApiKeys,
|
|
250
|
+
readApiKeyHints,
|
|
251
|
+
readApiKeyValues,
|
|
252
|
+
saveApiKey,
|
|
253
|
+
removeApiKey,
|
|
254
|
+
validateApiKey,
|
|
255
|
+
validateOpenRouterKey,
|
|
256
|
+
PROVIDER_ENV_MAP,
|
|
257
|
+
LEGACY_KEY_NAMES,
|
|
258
|
+
VALIDATION_ENDPOINTS
|
|
259
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Key Validation — test API keys against provider endpoints.
|
|
3
|
+
* Extracted from api-key-store.js to keep modules under 300 lines.
|
|
4
|
+
*/
|
|
5
|
+
const https = require('https');
|
|
6
|
+
|
|
7
|
+
/** Validation endpoints per provider */
|
|
8
|
+
const VALIDATION_ENDPOINTS = {
|
|
9
|
+
openrouter: {
|
|
10
|
+
url: 'https://openrouter.ai/api/v1/models',
|
|
11
|
+
authHeader: (key) => ({ 'Authorization': `Bearer ${key}` })
|
|
12
|
+
},
|
|
13
|
+
openai: {
|
|
14
|
+
url: 'https://api.openai.com/v1/models',
|
|
15
|
+
authHeader: (key) => ({ 'Authorization': `Bearer ${key}` })
|
|
16
|
+
},
|
|
17
|
+
anthropic: {
|
|
18
|
+
url: 'https://api.anthropic.com/v1/messages',
|
|
19
|
+
authHeader: (key) => ({
|
|
20
|
+
'x-api-key': key,
|
|
21
|
+
'anthropic-version': '2023-06-01'
|
|
22
|
+
})
|
|
23
|
+
},
|
|
24
|
+
google: {
|
|
25
|
+
url: 'https://generativelanguage.googleapis.com/v1beta/models',
|
|
26
|
+
authHeader: () => ({})
|
|
27
|
+
},
|
|
28
|
+
deepseek: {
|
|
29
|
+
url: 'https://api.deepseek.com/models',
|
|
30
|
+
authHeader: (key) => ({ 'Authorization': `Bearer ${key}` })
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Validate an API key by making a test request to the provider's API */
|
|
35
|
+
function validateApiKey(provider, key) {
|
|
36
|
+
if (!key || key.trim().length === 0) {
|
|
37
|
+
return Promise.resolve({ valid: false, error: 'API key is required' });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const endpoint = VALIDATION_ENDPOINTS[provider];
|
|
41
|
+
if (!endpoint) {
|
|
42
|
+
return Promise.resolve({ valid: false, error: `Unknown provider: ${provider}` });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const trimmedKey = key.trim();
|
|
46
|
+
|
|
47
|
+
// Google uses query param auth, not header
|
|
48
|
+
let url = endpoint.url;
|
|
49
|
+
if (provider === 'google') {
|
|
50
|
+
url = `${endpoint.url}?key=${trimmedKey}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const headers = endpoint.authHeader(trimmedKey);
|
|
54
|
+
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
const req = https.get(url, { headers }, (res) => {
|
|
57
|
+
res.on('data', () => {});
|
|
58
|
+
res.on('end', () => {
|
|
59
|
+
// Anthropic returns 401 for invalid key, 400/405 for valid key with no body
|
|
60
|
+
if (provider === 'anthropic') {
|
|
61
|
+
if (res.statusCode === 401) {
|
|
62
|
+
resolve({ valid: false, error: 'Invalid API key (401)' });
|
|
63
|
+
} else if (res.statusCode >= 500 || res.statusCode === 429) {
|
|
64
|
+
resolve({ valid: false, error: `Server error (${res.statusCode})` });
|
|
65
|
+
} else {
|
|
66
|
+
resolve({ valid: true });
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (res.statusCode === 200) {
|
|
72
|
+
resolve({ valid: true });
|
|
73
|
+
} else if (res.statusCode === 401 || res.statusCode === 403) {
|
|
74
|
+
resolve({ valid: false, error: `Invalid API key (${res.statusCode})` });
|
|
75
|
+
} else {
|
|
76
|
+
resolve({ valid: false, error: `Unexpected response (${res.statusCode})` });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
req.setTimeout(10000, () => {
|
|
81
|
+
req.destroy();
|
|
82
|
+
resolve({ valid: false, error: 'Request timed out' });
|
|
83
|
+
});
|
|
84
|
+
req.on('error', (err) => {
|
|
85
|
+
resolve({ valid: false, error: err.message });
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Backwards compat alias
|
|
91
|
+
const validateOpenRouterKey = validateApiKey;
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
validateApiKey,
|
|
95
|
+
validateOpenRouterKey,
|
|
96
|
+
VALIDATION_ENDPOINTS
|
|
97
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth JSON Reader
|
|
3
|
+
*
|
|
4
|
+
* Read-only interface to OpenCode's auth.json (~/.local/share/opencode/auth.json).
|
|
5
|
+
* Used for one-time key import into sidecar's .env and optional cleanup on delete.
|
|
6
|
+
* Sidecar never writes keys TO auth.json -- only reads and optionally removes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { logger } = require('./logger');
|
|
13
|
+
|
|
14
|
+
const AUTH_JSON_PATH = path.join(os.homedir(), '.local', 'share', 'opencode', 'auth.json');
|
|
15
|
+
|
|
16
|
+
/** Known provider IDs that map to sidecar's PROVIDER_ENV_MAP */
|
|
17
|
+
const KNOWN_PROVIDERS = ['openrouter', 'google', 'openai', 'anthropic', 'deepseek'];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extract key value from an auth.json provider entry.
|
|
21
|
+
* Checks both .key and .apiKey fields (OpenCode uses both formats).
|
|
22
|
+
* @param {object} entry - Provider entry from auth.json
|
|
23
|
+
* @returns {string|undefined} Key string, or undefined if not found
|
|
24
|
+
*/
|
|
25
|
+
function extractKey(entry) {
|
|
26
|
+
if (!entry || typeof entry !== 'object') { return undefined; }
|
|
27
|
+
const fromKey = entry.key;
|
|
28
|
+
if (typeof fromKey === 'string' && fromKey.length > 0) { return fromKey; }
|
|
29
|
+
const fromApiKey = entry.apiKey;
|
|
30
|
+
if (typeof fromApiKey === 'string' && fromApiKey.length > 0) { return fromApiKey; }
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read and parse auth.json into a normalized provider-key map.
|
|
36
|
+
* Only returns keys for known providers (openrouter, google, openai, anthropic, deepseek).
|
|
37
|
+
* @returns {Object<string, string>} Map of provider -> key string (only providers with keys)
|
|
38
|
+
*/
|
|
39
|
+
function readAuthJsonKeys() {
|
|
40
|
+
if (!fs.existsSync(AUTH_JSON_PATH)) { return {}; }
|
|
41
|
+
let parsed;
|
|
42
|
+
try {
|
|
43
|
+
parsed = JSON.parse(fs.readFileSync(AUTH_JSON_PATH, 'utf-8'));
|
|
44
|
+
} catch (_err) {
|
|
45
|
+
logger.debug('auth.json is malformed, skipping import');
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
if (!parsed || typeof parsed !== 'object') { return {}; }
|
|
49
|
+
|
|
50
|
+
const result = {};
|
|
51
|
+
for (const provider of KNOWN_PROVIDERS) {
|
|
52
|
+
const key = extractKey(parsed[provider]);
|
|
53
|
+
if (key) { result[provider] = key; }
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Find keys in auth.json that are not already in sidecar's .env.
|
|
60
|
+
* @param {Object<string, boolean>} existingKeys - Which providers already have keys in sidecar
|
|
61
|
+
* @returns {{ imported: Array<{provider: string, key: string}> }}
|
|
62
|
+
*/
|
|
63
|
+
function importFromAuthJson(existingKeys) {
|
|
64
|
+
const authKeys = readAuthJsonKeys();
|
|
65
|
+
const imported = [];
|
|
66
|
+
for (const [provider, key] of Object.entries(authKeys)) {
|
|
67
|
+
if (!existingKeys[provider]) {
|
|
68
|
+
imported.push({ provider, key });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { imported };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check if a provider has a key in auth.json.
|
|
76
|
+
* @param {string} provider - Provider ID
|
|
77
|
+
* @returns {boolean}
|
|
78
|
+
*/
|
|
79
|
+
function checkAuthJson(provider) {
|
|
80
|
+
if (!KNOWN_PROVIDERS.includes(provider)) { return false; }
|
|
81
|
+
const keys = readAuthJsonKeys();
|
|
82
|
+
return !!keys[provider];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Remove a provider entry from auth.json.
|
|
87
|
+
* Best-effort: does not throw on errors.
|
|
88
|
+
* @param {string} provider - Provider ID to remove
|
|
89
|
+
*/
|
|
90
|
+
function removeFromAuthJson(provider) {
|
|
91
|
+
try {
|
|
92
|
+
if (!fs.existsSync(AUTH_JSON_PATH)) { return; }
|
|
93
|
+
const parsed = JSON.parse(fs.readFileSync(AUTH_JSON_PATH, 'utf-8'));
|
|
94
|
+
if (!parsed[provider]) { return; }
|
|
95
|
+
delete parsed[provider];
|
|
96
|
+
fs.writeFileSync(AUTH_JSON_PATH, JSON.stringify(parsed, null, 2), 'utf-8');
|
|
97
|
+
} catch (_err) {
|
|
98
|
+
logger.debug('Failed to remove provider from auth.json', { provider });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
readAuthJsonKeys,
|
|
104
|
+
importFromAuthJson,
|
|
105
|
+
checkAuthJson,
|
|
106
|
+
removeFromAuthJson,
|
|
107
|
+
AUTH_JSON_PATH,
|
|
108
|
+
KNOWN_PROVIDERS
|
|
109
|
+
};
|