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.
Files changed (93) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +477 -0
  4. package/bin/amicus.js +382 -0
  5. package/electron/assets/icon.png +0 -0
  6. package/electron/assets/icon.svg +5 -0
  7. package/electron/fold.js +163 -0
  8. package/electron/ipc-setup.js +176 -0
  9. package/electron/load-failsafe.js +85 -0
  10. package/electron/main.js +468 -0
  11. package/electron/preload-setup.js +38 -0
  12. package/electron/preload.js +33 -0
  13. package/electron/setup-ui-alias-script.js +218 -0
  14. package/electron/setup-ui-aliases.js +85 -0
  15. package/electron/setup-ui-keys-script.js +115 -0
  16. package/electron/setup-ui-keys.js +97 -0
  17. package/electron/setup-ui-model.js +138 -0
  18. package/electron/setup-ui-styles.js +327 -0
  19. package/electron/setup-ui.js +465 -0
  20. package/electron/summary.js +118 -0
  21. package/electron/toolbar.js +229 -0
  22. package/electron/window-position.js +35 -0
  23. package/package.json +98 -0
  24. package/scripts/postinstall.js +193 -0
  25. package/scripts/setup-hooks.js +42 -0
  26. package/skill/SKILL.md +976 -0
  27. package/skills/second-opinion/COUNCIL-DESIGN.md +227 -0
  28. package/skills/second-opinion/MODEL-NOTES.md +104 -0
  29. package/skills/second-opinion/SKILL.md +389 -0
  30. package/src/cli-handlers.js +188 -0
  31. package/src/cli.js +400 -0
  32. package/src/conflict.js +144 -0
  33. package/src/context-compression.js +102 -0
  34. package/src/context.js +199 -0
  35. package/src/drift.js +144 -0
  36. package/src/environment.js +157 -0
  37. package/src/headless.js +742 -0
  38. package/src/index.js +106 -0
  39. package/src/jsonl-parser.js +180 -0
  40. package/src/mcp-server.js +625 -0
  41. package/src/mcp-tools.js +407 -0
  42. package/src/opencode-client.js +615 -0
  43. package/src/prompt-builder.js +355 -0
  44. package/src/prompts/cowork-agent-prompt.js +118 -0
  45. package/src/session-manager.js +414 -0
  46. package/src/session.js +180 -0
  47. package/src/sidecar/context-builder.js +297 -0
  48. package/src/sidecar/continue.js +212 -0
  49. package/src/sidecar/crash-handler.js +56 -0
  50. package/src/sidecar/fanout-leg.js +107 -0
  51. package/src/sidecar/fanout-output.js +46 -0
  52. package/src/sidecar/fanout.js +236 -0
  53. package/src/sidecar/interactive.js +217 -0
  54. package/src/sidecar/models.js +135 -0
  55. package/src/sidecar/progress.js +218 -0
  56. package/src/sidecar/read.js +183 -0
  57. package/src/sidecar/resume.js +221 -0
  58. package/src/sidecar/session-utils.js +288 -0
  59. package/src/sidecar/setup-window.js +79 -0
  60. package/src/sidecar/setup.js +280 -0
  61. package/src/sidecar/start.js +251 -0
  62. package/src/utils/agent-mapping.js +138 -0
  63. package/src/utils/alias-audit.js +98 -0
  64. package/src/utils/alias-resolver.js +77 -0
  65. package/src/utils/api-key-store.js +259 -0
  66. package/src/utils/api-key-validation.js +97 -0
  67. package/src/utils/auth-json.js +109 -0
  68. package/src/utils/config.js +291 -0
  69. package/src/utils/curated-models.js +82 -0
  70. package/src/utils/env-compat.js +38 -0
  71. package/src/utils/env-loader.js +54 -0
  72. package/src/utils/idle-watchdog.js +225 -0
  73. package/src/utils/input-validators.js +127 -0
  74. package/src/utils/lifecycle.js +43 -0
  75. package/src/utils/logger.js +84 -0
  76. package/src/utils/mcp-discovery.js +194 -0
  77. package/src/utils/mcp-validators.js +78 -0
  78. package/src/utils/model-catalog.js +103 -0
  79. package/src/utils/model-fetcher.js +179 -0
  80. package/src/utils/model-validator.js +207 -0
  81. package/src/utils/path-setup.js +41 -0
  82. package/src/utils/port-pid.js +39 -0
  83. package/src/utils/prompt-source.js +53 -0
  84. package/src/utils/result-schema.js +261 -0
  85. package/src/utils/server-setup.js +93 -0
  86. package/src/utils/session-abort.js +53 -0
  87. package/src/utils/session-lock.js +95 -0
  88. package/src/utils/shared-server.js +216 -0
  89. package/src/utils/start-helpers.js +76 -0
  90. package/src/utils/thinking-validators.js +92 -0
  91. package/src/utils/update-notifier-loader.js +18 -0
  92. package/src/utils/updater.js +157 -0
  93. 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
+ };