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,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 };
|