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,127 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module input-validators
5
+ * MCP input validation with structured error responses.
6
+ * Composes validators from validators.js and adds model resolution.
7
+ */
8
+
9
+ // Lazy require to avoid circular dependency (validators.js re-exports from here)
10
+ let _validators;
11
+ function getValidators() {
12
+ if (!_validators) { _validators = require('./validators'); }
13
+ return _validators;
14
+ }
15
+
16
+ /**
17
+ * Find candidates that start with the input or vice versa.
18
+ * @param {string} input
19
+ * @param {string[]} candidates
20
+ * @returns {string[]} Up to 3 matching candidates
21
+ */
22
+ function findSimilar(input, candidates) {
23
+ if (!input) { return []; }
24
+ const lower = input.toLowerCase();
25
+ return candidates.filter(c => {
26
+ const cl = c.toLowerCase();
27
+ return cl.startsWith(lower) || lower.startsWith(cl);
28
+ }).slice(0, 3);
29
+ }
30
+
31
+ /**
32
+ * Validate sidecar_start inputs before session creation.
33
+ * Composes existing validators and adds model resolution.
34
+ * @param {Object} input - Raw MCP tool input
35
+ * @returns {{ valid: true, resolvedModel: string } | { valid: false, error: Object }}
36
+ */
37
+ function validateStartInputs(input) {
38
+ // 1. Prompt
39
+ const { validatePromptContent, validateHeadlessAgent } = getValidators();
40
+ const promptResult = validatePromptContent(input.prompt);
41
+ if (!promptResult.valid) {
42
+ return {
43
+ valid: false,
44
+ error: {
45
+ type: 'validation_error',
46
+ field: 'prompt',
47
+ message: promptResult.error,
48
+ },
49
+ };
50
+ }
51
+
52
+ // 2. Model: resolve alias to full provider/model string
53
+ const { tryResolveModel, getEffectiveAliases } = require('./config');
54
+ const { model: resolved, error: modelError } = tryResolveModel(input.model);
55
+ if (modelError) {
56
+ const aliases = Object.keys(getEffectiveAliases());
57
+ const suggestions = findSimilar(input.model, aliases);
58
+ return {
59
+ valid: false,
60
+ error: {
61
+ type: 'validation_error',
62
+ field: 'model',
63
+ message: input.model
64
+ ? `Model '${input.model}' not found. ${modelError}`
65
+ : `No model specified and no default configured. ${modelError}`,
66
+ suggestions,
67
+ available: aliases,
68
+ },
69
+ };
70
+ }
71
+
72
+ // 3. Timeout: positive number, max 60 minutes
73
+ if (input.timeout !== undefined) {
74
+ const t = Number(input.timeout);
75
+ if (isNaN(t) || t <= 0) {
76
+ return {
77
+ valid: false,
78
+ error: {
79
+ type: 'validation_error',
80
+ field: 'timeout',
81
+ message: `Timeout must be a positive number (minutes). Got: ${input.timeout}`,
82
+ },
83
+ };
84
+ }
85
+ if (t > 60) {
86
+ return {
87
+ valid: false,
88
+ error: {
89
+ type: 'validation_error',
90
+ field: 'timeout',
91
+ message: `Timeout cannot exceed 60 minutes. Got: ${t}`,
92
+ },
93
+ };
94
+ }
95
+ }
96
+
97
+ // 4. Agent + headless compatibility
98
+ // Note: MCP Zod schema defaults agent to 'Chat'. The handler auto-converts
99
+ // Chat to Build for headless mode (line ~92 in mcp-server.js). Only reject
100
+ // if the user explicitly set agent to Chat with noUi (not the Zod default).
101
+ // We detect explicit by checking if agent was in the original input vs Zod default.
102
+ // Since we can't distinguish here, skip validation for Chat+noUi -
103
+ // the handler will convert it to Build before use.
104
+ if (input.noUi && input.agent) {
105
+ const lower = input.agent.toLowerCase();
106
+ // Only reject chat if it's NOT the auto-convertible case
107
+ // The handler converts chat -> build for headless, so we allow it
108
+ if (lower !== 'chat') {
109
+ const agentResult = validateHeadlessAgent(input.agent);
110
+ if (!agentResult.valid) {
111
+ return {
112
+ valid: false,
113
+ error: {
114
+ type: 'validation_error',
115
+ field: 'agent',
116
+ message: agentResult.error,
117
+ suggestions: ['Build', 'Plan'],
118
+ },
119
+ };
120
+ }
121
+ }
122
+ }
123
+
124
+ return { valid: true, resolvedModel: resolved };
125
+ }
126
+
127
+ module.exports = { validateStartInputs, findSimilar };
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Process-lifecycle helpers for the one-shot CLI commands (F3 #15).
5
+ *
6
+ * One-shot commands must return control to the shell when their work is done.
7
+ * If a stray handle (e.g. the OpenCode Go server) keeps Node's event loop
8
+ * alive, the force-exit watchdog guarantees the process still exits.
9
+ */
10
+
11
+ // One-shot commands spin up the OpenCode server and must return to the shell
12
+ // when done (F3 #15). Deliberately EXCLUDED: `mcp` (long-lived server), and
13
+ // `setup`/`update` (no OpenCode server to leak, and `setup` can be a long-lived
14
+ // interactive Electron flow that must never be force-exited).
15
+ const ONE_SHOT_COMMANDS = new Set(['start', 'continue', 'resume', 'list', 'read', 'abort', 'fanout', 'models']);
16
+
17
+ /** @param {string} command @returns {boolean} */
18
+ function isOneShotCommand(command) {
19
+ return ONE_SHOT_COMMANDS.has(command);
20
+ }
21
+
22
+ /**
23
+ * Arm an unref'd timer that force-exits if the loop has not drained by `ms`.
24
+ * Natural drain still wins: an unref'd timer never holds the process open, so
25
+ * a clean exit happens before this fires. Injectable exit/log for tests.
26
+ *
27
+ * @param {number} [code=0]
28
+ * @param {number} [ms=1500]
29
+ * @param {{exit?: Function, log?: Function}} [deps]
30
+ * @returns {NodeJS.Timeout}
31
+ */
32
+ function armExitWatchdog(code = 0, ms = 1500, deps = {}) {
33
+ const exit = deps.exit || process.exit;
34
+ const log = deps.log;
35
+ const t = setTimeout(() => {
36
+ if (log) { log('force-exit watchdog fired — a handle kept the event loop alive', { code, ms }); }
37
+ exit(code);
38
+ }, ms);
39
+ if (t.unref) { t.unref(); }
40
+ return t;
41
+ }
42
+
43
+ module.exports = { isOneShotCommand, armExitWatchdog, ONE_SHOT_COMMANDS };
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Structured Logger Module
3
+ *
4
+ * Spec Reference: CLAUDE.md - Structured Logging Guidelines
5
+ * Outputs JSON-formatted logs to stderr (stdout reserved for summary output).
6
+ */
7
+
8
+ const LOG_LEVELS = { error: 0, warn: 1, info: 2, debug: 3 };
9
+
10
+ /**
11
+ * Get current log level from environment
12
+ * @returns {number} Numeric log level
13
+ */
14
+ function getCurrentLevel() {
15
+ const levelName = process.env.LOG_LEVEL || 'error';
16
+ return LOG_LEVELS[levelName] ?? LOG_LEVELS.info;
17
+ }
18
+
19
+ /**
20
+ * Create a log entry and write to stderr
21
+ * @param {string} level - Log level name
22
+ * @param {string} msg - Log message
23
+ * @param {object} ctx - Additional context fields
24
+ */
25
+ function log(level, msg, ctx = {}) {
26
+ const currentLevel = getCurrentLevel();
27
+ const levelNum = LOG_LEVELS[level];
28
+
29
+ if (levelNum > currentLevel) {
30
+ return;
31
+ }
32
+
33
+ const entry = {
34
+ level,
35
+ msg,
36
+ ...ctx,
37
+ ts: new Date().toISOString()
38
+ };
39
+
40
+ try {
41
+ console.error(JSON.stringify(entry));
42
+ } catch (err) {
43
+ // Ignore EPIPE errors when pipe is closed (e.g., during shutdown)
44
+ if (err.code !== 'EPIPE') {
45
+ // Can't log here - would be recursive
46
+ }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Structured logger with level-based filtering
52
+ * @type {{error: Function, warn: Function, info: Function, debug: Function}}
53
+ */
54
+ const logger = {
55
+ /**
56
+ * Log an error message
57
+ * @param {string} msg - Error message
58
+ * @param {object} [ctx] - Additional context
59
+ */
60
+ error: (msg, ctx = {}) => log('error', msg, ctx),
61
+
62
+ /**
63
+ * Log a warning message
64
+ * @param {string} msg - Warning message
65
+ * @param {object} [ctx] - Additional context
66
+ */
67
+ warn: (msg, ctx = {}) => log('warn', msg, ctx),
68
+
69
+ /**
70
+ * Log an info message
71
+ * @param {string} msg - Info message
72
+ * @param {object} [ctx] - Additional context
73
+ */
74
+ info: (msg, ctx = {}) => log('info', msg, ctx),
75
+
76
+ /**
77
+ * Log a debug message
78
+ * @param {string} msg - Debug message
79
+ * @param {object} [ctx] - Additional context
80
+ */
81
+ debug: (msg, ctx = {}) => log('debug', msg, ctx)
82
+ };
83
+
84
+ module.exports = { logger, LOG_LEVELS };
@@ -0,0 +1,194 @@
1
+ /**
2
+ * MCP Discovery - Discovers MCP servers from parent LLM configuration
3
+ *
4
+ * Supports discovering MCP servers from:
5
+ * - Claude Code (reads plugin chain from ~/.claude/)
6
+ * - Cowork / Claude Desktop (reads claude_desktop_config.json)
7
+ *
8
+ * @module utils/mcp-discovery
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const os = require('os');
14
+ const { logger } = require('./logger');
15
+
16
+ /**
17
+ * Normalize .mcp.json to a flat { name: config } map.
18
+ * Handles both Format A (wrapped) and Format B (flat).
19
+ *
20
+ * @param {object|null|undefined} raw - Raw parsed JSON from .mcp.json
21
+ * @returns {object} Normalized server configs
22
+ */
23
+ function normalizeMcpJson(raw) {
24
+ if (!raw || typeof raw !== 'object') {
25
+ return {};
26
+ }
27
+ // Format A: { mcpServers: { name: config } }
28
+ if (raw.mcpServers && typeof raw.mcpServers === 'object') {
29
+ return raw.mcpServers;
30
+ }
31
+ // Format B: { name: config } (flat)
32
+ return raw;
33
+ }
34
+
35
+ /**
36
+ * Discover MCP servers from Claude Code's plugin chain AND ~/.claude.json.
37
+ *
38
+ * Discovery sources (merged, in priority order):
39
+ * 1. ~/.claude.json → mcpServers (servers added via `claude mcp add`)
40
+ * 2. Enabled plugins → .mcp.json entries
41
+ *
42
+ * @param {string} [claudeDir] - Path to ~/.claude directory (for testing)
43
+ * @param {string} [claudeJsonPath] - Path to ~/.claude.json (for testing)
44
+ * @returns {object|null} Merged MCP server configs, or null if none found
45
+ */
46
+ function discoverClaudeCodeMcps(claudeDir, claudeJsonPath) {
47
+ const baseDir = claudeDir || path.join(os.homedir(), '.claude');
48
+ const jsonPath = claudeJsonPath || path.join(os.homedir(), '.claude.json');
49
+
50
+ // Source 1: ~/.claude.json → mcpServers (servers added via `claude mcp add`)
51
+ let claudeJsonServers = {};
52
+ try {
53
+ if (fs.existsSync(jsonPath)) {
54
+ const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
55
+ if (raw.mcpServers && typeof raw.mcpServers === 'object') {
56
+ claudeJsonServers = raw.mcpServers;
57
+ logger.debug('Read MCP servers from ~/.claude.json', {
58
+ serverCount: Object.keys(claudeJsonServers).length
59
+ });
60
+ }
61
+ }
62
+ } catch (err) {
63
+ logger.debug('Failed to read ~/.claude.json', { error: err.message });
64
+ }
65
+
66
+ // Source 2: Plugin chain (settings.json → installed_plugins.json → .mcp.json)
67
+ const pluginServers = {};
68
+
69
+ try {
70
+ const settingsPath = path.join(baseDir, 'settings.json');
71
+ if (!fs.existsSync(settingsPath)) {
72
+ // No settings.json — skip plugin discovery, may still have claude.json servers
73
+ const merged = { ...claudeJsonServers };
74
+ delete merged.sidecar;
75
+ return Object.keys(merged).length > 0 ? merged : null;
76
+ }
77
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
78
+ const enabledPlugins = settings.enabledPlugins;
79
+ if (!enabledPlugins || typeof enabledPlugins !== 'object') {
80
+ const merged = { ...claudeJsonServers };
81
+ delete merged.sidecar;
82
+ return Object.keys(merged).length > 0 ? merged : null;
83
+ }
84
+
85
+ let installedPlugins = {};
86
+ try {
87
+ const pluginsDir = path.join(baseDir, 'plugins');
88
+ const installedPath = path.join(pluginsDir, 'installed_plugins.json');
89
+ if (fs.existsSync(installedPath)) {
90
+ const installed = JSON.parse(fs.readFileSync(installedPath, 'utf-8'));
91
+ installedPlugins = installed.plugins || {};
92
+ }
93
+ } catch (err) {
94
+ logger.debug('Failed to read installed plugins', { error: err.message });
95
+ }
96
+
97
+ // Read blocklist
98
+ let blocklist = [];
99
+ try {
100
+ const blocklistPath = path.join(baseDir, 'plugins', 'blocklist.json');
101
+ if (fs.existsSync(blocklistPath)) {
102
+ blocklist = JSON.parse(fs.readFileSync(blocklistPath, 'utf-8'));
103
+ if (!Array.isArray(blocklist)) { blocklist = []; }
104
+ }
105
+ } catch {
106
+ // Ignore blocklist read errors
107
+ }
108
+
109
+ for (const [pluginName, isEnabled] of Object.entries(enabledPlugins)) {
110
+ if (!isEnabled) { continue; }
111
+ if (blocklist.includes(pluginName)) {
112
+ logger.debug('Skipping blocklisted plugin', { pluginName });
113
+ continue;
114
+ }
115
+
116
+ const pluginInfo = installedPlugins[pluginName];
117
+ if (!pluginInfo || !pluginInfo.installPath) { continue; }
118
+
119
+ try {
120
+ const mcpPath = path.join(pluginInfo.installPath, '.mcp.json');
121
+ if (!fs.existsSync(mcpPath)) { continue; }
122
+ const raw = JSON.parse(fs.readFileSync(mcpPath, 'utf-8'));
123
+ const servers = normalizeMcpJson(raw);
124
+
125
+ for (const [name, config] of Object.entries(servers)) {
126
+ pluginServers[name] = config;
127
+ }
128
+ } catch (err) {
129
+ logger.debug('Failed to read plugin MCP config', { pluginName, error: err.message });
130
+ }
131
+ }
132
+ } catch (err) {
133
+ logger.debug('Failed to read Claude Code settings', { error: err.message });
134
+ }
135
+
136
+ // Merge: plugin servers first, then claude.json overwrites (higher priority)
137
+ const merged = { ...pluginServers, ...claudeJsonServers };
138
+
139
+ // Always exclude sidecar itself to prevent recursive spawning
140
+ delete merged.sidecar;
141
+
142
+ return Object.keys(merged).length > 0 ? merged : null;
143
+ }
144
+
145
+ /**
146
+ * Discover MCP servers from Cowork / Claude Desktop config.
147
+ *
148
+ * @param {string} [configDir] - Path to config directory (for testing)
149
+ * @returns {object|null} MCP server configs, or null if none found
150
+ */
151
+ function discoverCoworkMcps(configDir) {
152
+ const baseDir = configDir || (
153
+ process.platform === 'darwin'
154
+ ? path.join(os.homedir(), 'Library', 'Application Support', 'Claude')
155
+ : path.join(os.homedir(), '.config', 'Claude')
156
+ );
157
+
158
+ try {
159
+ const configPath = path.join(baseDir, 'claude_desktop_config.json');
160
+ if (!fs.existsSync(configPath)) { return null; }
161
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
162
+ if (!config.mcpServers || Object.keys(config.mcpServers).length === 0) {
163
+ return null;
164
+ }
165
+ return config.mcpServers;
166
+ } catch (err) {
167
+ logger.debug('Failed to read Cowork config', { error: err.message });
168
+ return null;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Discover MCP servers from the parent LLM's configuration.
174
+ *
175
+ * @param {string} [clientType] - Client type: 'code-local', 'code-web', 'cowork'
176
+ * @returns {object|null} Discovered MCP server configs, or null
177
+ */
178
+ function discoverParentMcps(clientType) {
179
+ if (clientType === 'cowork') {
180
+ return discoverCoworkMcps();
181
+ }
182
+ if (!clientType || clientType === 'code-local' || clientType === 'code-web') {
183
+ return discoverClaudeCodeMcps();
184
+ }
185
+ logger.debug('Unknown client type for MCP discovery', { clientType });
186
+ return null;
187
+ }
188
+
189
+ module.exports = {
190
+ discoverParentMcps,
191
+ discoverClaudeCodeMcps,
192
+ discoverCoworkMcps,
193
+ normalizeMcpJson
194
+ };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * MCP Validators
3
+ *
4
+ * Validation for MCP spec and config file arguments.
5
+ * Extracted from validators.js to keep modules under 300 lines.
6
+ */
7
+
8
+ const fs = require('fs');
9
+
10
+ /**
11
+ * Validate MCP spec format (OPTIONAL - only validates if provided)
12
+ * @param {string} mcp
13
+ * @returns {{valid: boolean, error?: string}}
14
+ */
15
+ function validateMcpSpec(mcp) {
16
+ // Skip validation if not provided - MCP is optional
17
+ if (!mcp) {
18
+ return { valid: true };
19
+ }
20
+
21
+ // Format: name=url or name=command
22
+ if (!mcp.includes('=')) {
23
+ return {
24
+ valid: false,
25
+ error: `Error: --mcp must be in format 'name=url' or 'name=command'. Got: '${mcp}'`
26
+ };
27
+ }
28
+
29
+ // Split on first '=' only (value can contain '=')
30
+ const eqIndex = mcp.indexOf('=');
31
+ const name = mcp.slice(0, eqIndex);
32
+ const value = mcp.slice(eqIndex + 1);
33
+
34
+ if (!name || !value) {
35
+ return {
36
+ valid: false,
37
+ error: `Error: --mcp must have both name and value. Got: '${mcp}'`
38
+ };
39
+ }
40
+
41
+ return { valid: true };
42
+ }
43
+
44
+ /**
45
+ * Validate MCP config file exists and is valid JSON (OPTIONAL)
46
+ * @param {string} mcpConfig
47
+ * @returns {{valid: boolean, error?: string}}
48
+ */
49
+ function validateMcpConfigFile(mcpConfig) {
50
+ // Skip validation if not provided - MCP config is optional
51
+ if (!mcpConfig) {
52
+ return { valid: true };
53
+ }
54
+
55
+ if (!fs.existsSync(mcpConfig)) {
56
+ return {
57
+ valid: false,
58
+ error: `Error: --mcp-config file does not exist: ${mcpConfig}`
59
+ };
60
+ }
61
+
62
+ try {
63
+ const content = fs.readFileSync(mcpConfig, 'utf-8');
64
+ JSON.parse(content);
65
+ } catch (e) {
66
+ return {
67
+ valid: false,
68
+ error: `Error: --mcp-config file is not valid JSON: ${mcpConfig}`
69
+ };
70
+ }
71
+
72
+ return { valid: true };
73
+ }
74
+
75
+ module.exports = {
76
+ validateMcpSpec,
77
+ validateMcpConfigFile
78
+ };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * OpenRouter model catalog cache (F3 #18 / F5 foundation).
3
+ *
4
+ * Caches the combined provider model list to ~/.config/amicus/model-catalog.json
5
+ * with a TTL so model validation doesn't hit the network on every launch.
6
+ * Schema v2: enriched rows ({id, name, contextLength, pricing}) written at every
7
+ * refresh; v1 caches (no schemaVersion) are treated as stale and refreshed, but
8
+ * remain usable as a graceful-degradation fallback when the refresh returns empty.
9
+ * Degrades gracefully: a failed/empty refresh falls back to stale cache, and
10
+ * callers treat an empty catalog as "cannot validate" (never block a launch).
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ // Lazy-load these so jest.doMock() in tests can intercept them after
19
+ // this module is first required (the test pattern re-mocks mid-test).
20
+ function _getConfigDir() { return require('./config').getConfigDir(); }
21
+ function _readApiKeyValues() { return require('./api-key-store').readApiKeyValues(); }
22
+ async function _fetchAllModels(keys) { return require('./model-fetcher').fetchAllModels(keys); }
23
+
24
+ const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
25
+ const CATALOG_SCHEMA_VERSION = 2;
26
+
27
+ /** @returns {string} Absolute path to the catalog cache file */
28
+ function catalogPath() {
29
+ return path.join(_getConfigDir(), 'model-catalog.json');
30
+ }
31
+
32
+ /** @returns {{fetchedAt: number, models: Array}|null} */
33
+ function readCache() {
34
+ try {
35
+ const raw = fs.readFileSync(catalogPath(), 'utf-8');
36
+ const parsed = JSON.parse(raw);
37
+ if (parsed && Array.isArray(parsed.models)) { return parsed; }
38
+ } catch { /* missing/corrupt */ }
39
+ return null;
40
+ }
41
+
42
+ /** Write the cache atomically (tmp+rename). Best-effort; never throws. @param {Array} models */
43
+ function writeCache(models) {
44
+ const target = catalogPath();
45
+ const tmp = `${target}.${process.pid}.tmp`;
46
+ try {
47
+ fs.mkdirSync(_getConfigDir(), { recursive: true, mode: 0o700 });
48
+ fs.writeFileSync(tmp, JSON.stringify({ schemaVersion: CATALOG_SCHEMA_VERSION, fetchedAt: Date.now(), models }, null, 2), { mode: 0o600 });
49
+ fs.renameSync(tmp, target);
50
+ } catch {
51
+ try { fs.unlinkSync(tmp); } catch { /* best-effort */ }
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Force a refresh from the provider APIs and update the cache.
57
+ * @returns {Promise<Array<{id,name}>>} the fetched models (may be [] offline)
58
+ */
59
+ async function refreshCatalog() {
60
+ const keys = _readApiKeyValues();
61
+ const models = await _fetchAllModels(keys);
62
+ // The anthropic rows are a hardcoded zero-network floor: a result containing
63
+ // ONLY them means every network provider failed. Treat that as a failed
64
+ // refresh — never clobber a previously-good cache with the floor (the
65
+ // "stale cache stands" contract).
66
+ const networkRows = (models || []).filter(m => m && typeof m.id === 'string' && !m.id.startsWith('anthropic/'));
67
+ if (networkRows.length === 0) { return []; }
68
+ writeCache(models);
69
+ return models;
70
+ }
71
+
72
+ /**
73
+ * Get the catalog, refreshing if the cache is missing or older than maxAgeMs.
74
+ * Graceful: on an empty refresh, returns stale cache if present, else [].
75
+ * @param {{maxAgeMs?: number}} [opts]
76
+ * @returns {Promise<Array<{id,name}>>}
77
+ */
78
+ async function getCatalog(opts = {}) {
79
+ const maxAgeMs = opts.maxAgeMs === undefined ? DEFAULT_MAX_AGE_MS : opts.maxAgeMs;
80
+ const cache = readCache();
81
+ // A future fetchedAt (clock skew / hand-edited file) reads as indefinitely
82
+ // fresh; acceptable for a model catalog. v1 caches (no schemaVersion) always
83
+ // read as stale so they get refreshed to v2 on next access.
84
+ const fresh = cache && cache.schemaVersion === CATALOG_SCHEMA_VERSION &&
85
+ (Date.now() - cache.fetchedAt) <= maxAgeMs;
86
+ if (fresh) { return cache.models; }
87
+
88
+ const refreshed = await refreshCatalog();
89
+ if (refreshed.length > 0) { return refreshed; }
90
+ return cache ? cache.models : []; // stale fallback / empty
91
+ }
92
+
93
+ /**
94
+ * Catalog rows plus cache timestamp (for UI display).
95
+ * @returns {Promise<{models: Array, fetchedAt: number|null}>}
96
+ */
97
+ async function getCatalogInfo(opts = {}) {
98
+ const models = await getCatalog(opts);
99
+ const cache = readCache();
100
+ return { models, fetchedAt: cache ? cache.fetchedAt : null };
101
+ }
102
+
103
+ module.exports = { getCatalog, refreshCatalog, catalogPath, getCatalogInfo, CATALOG_SCHEMA_VERSION };