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,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model Fetcher
|
|
3
|
+
*
|
|
4
|
+
* Fetches available model lists from provider APIs for the dropdown selector.
|
|
5
|
+
* Uses the same HTTPS pattern as api-key-store.js validateApiKey().
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const https = require('https');
|
|
9
|
+
|
|
10
|
+
/** Hardcoded Anthropic models (no public listing endpoint) */
|
|
11
|
+
const ANTHROPIC_MODELS = [
|
|
12
|
+
{ id: 'anthropic/claude-opus-4-6', name: 'Claude Opus 4.6', contextLength: null, pricing: null },
|
|
13
|
+
{ id: 'anthropic/claude-sonnet-4-6', name: 'Claude Sonnet 4.6', contextLength: null, pricing: null },
|
|
14
|
+
{ id: 'anthropic/claude-haiku-4-5', name: 'Claude Haiku 4.5', contextLength: null, pricing: null },
|
|
15
|
+
{ id: 'anthropic/claude-sonnet-4-5', name: 'Claude Sonnet 4.5', contextLength: null, pricing: null },
|
|
16
|
+
{ id: 'anthropic/claude-3-5-haiku', name: 'Claude 3.5 Haiku', contextLength: null, pricing: null }
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const PROVIDER_FAMILY_NAMES = {
|
|
20
|
+
openrouter: 'OpenRouter',
|
|
21
|
+
google: 'Google',
|
|
22
|
+
openai: 'OpenAI',
|
|
23
|
+
anthropic: 'Anthropic'
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Provider API configs for fetching model lists */
|
|
27
|
+
const PROVIDER_FETCH_CONFIG = {
|
|
28
|
+
openrouter: {
|
|
29
|
+
url: 'https://openrouter.ai/api/v1/models',
|
|
30
|
+
// Public endpoint: works keyless; attach auth only when a key exists (F5).
|
|
31
|
+
authHeader: (key) => (key ? { 'Authorization': `Bearer ${key}` } : {}),
|
|
32
|
+
normalize: (body) => {
|
|
33
|
+
const data = JSON.parse(body);
|
|
34
|
+
return (data.data || []).map(m => ({
|
|
35
|
+
id: `openrouter/${m.id}`,
|
|
36
|
+
name: m.name || m.id,
|
|
37
|
+
contextLength: m.context_length ?? null,
|
|
38
|
+
pricing: m.pricing
|
|
39
|
+
? { prompt: m.pricing.prompt ?? null,
|
|
40
|
+
completion: m.pricing.completion ?? null }
|
|
41
|
+
: null
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
google: {
|
|
46
|
+
url: null, // built dynamically with key
|
|
47
|
+
authHeader: () => ({}),
|
|
48
|
+
buildUrl: (key) => `https://generativelanguage.googleapis.com/v1beta/models?key=${key}`,
|
|
49
|
+
normalize: (body) => {
|
|
50
|
+
const data = JSON.parse(body);
|
|
51
|
+
return (data.models || []).map(m => ({
|
|
52
|
+
id: `google/${m.name.replace('models/', '')}`,
|
|
53
|
+
name: m.displayName || m.name.replace('models/', ''),
|
|
54
|
+
contextLength: m.inputTokenLimit ?? null,
|
|
55
|
+
pricing: null
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
openai: {
|
|
60
|
+
url: 'https://api.openai.com/v1/models',
|
|
61
|
+
authHeader: (key) => ({ 'Authorization': `Bearer ${key}` }),
|
|
62
|
+
normalize: (body) => {
|
|
63
|
+
const data = JSON.parse(body);
|
|
64
|
+
return (data.data || []).map(m => ({
|
|
65
|
+
id: `openai/${m.id}`,
|
|
66
|
+
name: m.id,
|
|
67
|
+
contextLength: null,
|
|
68
|
+
pricing: null
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Fetch models from a single provider API
|
|
78
|
+
* @param {string} provider - Provider name (openrouter, google, openai, anthropic)
|
|
79
|
+
* @param {string} key - API key
|
|
80
|
+
* @returns {Promise<Array<{id: string, name: string, contextLength: number|null, pricing: {prompt: string|null, completion: string|null}|null}>>} Normalized model list
|
|
81
|
+
*/
|
|
82
|
+
function fetchModelsFromProvider(provider, key) {
|
|
83
|
+
if (provider === 'anthropic') {
|
|
84
|
+
return Promise.resolve(ANTHROPIC_MODELS);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const config = PROVIDER_FETCH_CONFIG[provider];
|
|
88
|
+
if (!config) {
|
|
89
|
+
return Promise.resolve([]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const url = config.buildUrl ? config.buildUrl(key) : config.url;
|
|
93
|
+
const headers = config.authHeader(key);
|
|
94
|
+
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
let chunks = '';
|
|
97
|
+
const timer = setTimeout(() => {
|
|
98
|
+
req.destroy();
|
|
99
|
+
resolve([]);
|
|
100
|
+
}, FETCH_TIMEOUT_MS);
|
|
101
|
+
|
|
102
|
+
const req = https.get(url, { headers }, (res) => {
|
|
103
|
+
if (res.statusCode !== 200) {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
res.on('data', () => {});
|
|
106
|
+
res.on('end', () => resolve([]));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
res.on('data', (chunk) => { chunks += chunk; });
|
|
110
|
+
res.on('end', () => {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
try {
|
|
113
|
+
resolve(config.normalize(chunks));
|
|
114
|
+
} catch (_err) {
|
|
115
|
+
resolve([]);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
req.on('error', () => {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
resolve([]);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Providers to fetch: every keyed provider + openrouter (keyless-capable) + anthropic. */
|
|
127
|
+
function providersToFetch(keys) {
|
|
128
|
+
const set = new Set(Object.keys(keys).filter(p => keys[p]));
|
|
129
|
+
set.add('openrouter');
|
|
130
|
+
set.add('anthropic');
|
|
131
|
+
return Array.from(set);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Fetch models from all providers that have keys configured; openrouter is
|
|
136
|
+
* always included (keyless public endpoint) as is anthropic (hardcoded list).
|
|
137
|
+
* @param {Object<string, string>} keys - Map of provider → API key string
|
|
138
|
+
* @returns {Promise<Array<{id: string, name: string, contextLength: number|null, pricing: object|null}>>} Combined model list
|
|
139
|
+
*/
|
|
140
|
+
async function fetchAllModels(keys) {
|
|
141
|
+
const providers = providersToFetch(keys);
|
|
142
|
+
const results = await Promise.all(providers.map(p => fetchModelsFromProvider(p, keys[p] || '')));
|
|
143
|
+
return results.flat();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Group models by provider family for <optgroup> rendering
|
|
148
|
+
* @param {Array<{id: string, name: string, contextLength: number|null, pricing: {prompt: string|null, completion: string|null}|null}>} models
|
|
149
|
+
* @returns {Array<{family: string, models: Array<{id: string, name: string}>}>}
|
|
150
|
+
*/
|
|
151
|
+
function groupModelsByFamily(models) {
|
|
152
|
+
if (models.length === 0) { return []; }
|
|
153
|
+
|
|
154
|
+
const groups = new Map();
|
|
155
|
+
|
|
156
|
+
for (const model of models) {
|
|
157
|
+
const prefix = model.id.split('/')[0];
|
|
158
|
+
const family = PROVIDER_FAMILY_NAMES[prefix] || prefix;
|
|
159
|
+
if (!groups.has(family)) {
|
|
160
|
+
groups.set(family, []);
|
|
161
|
+
}
|
|
162
|
+
groups.get(family).push(model);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return Array.from(groups.entries()).map(([family, familyModels]) => ({
|
|
166
|
+
family,
|
|
167
|
+
models: familyModels
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = {
|
|
172
|
+
fetchModelsFromProvider,
|
|
173
|
+
fetchAllModels,
|
|
174
|
+
providersToFetch,
|
|
175
|
+
groupModelsByFamily,
|
|
176
|
+
ANTHROPIC_MODELS,
|
|
177
|
+
PROVIDER_FETCH_CONFIG,
|
|
178
|
+
PROVIDER_FAMILY_NAMES
|
|
179
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model Validator
|
|
3
|
+
*
|
|
4
|
+
* Validates that a direct-API fallback model exists on the provider.
|
|
5
|
+
* When a model is not found, prompts the user to pick an alternative
|
|
6
|
+
* (interactive) or fails with available models (headless).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const readline = require('readline');
|
|
10
|
+
const { fetchModelsFromProvider } = require('./model-fetcher');
|
|
11
|
+
const { readApiKeyValues } = require('./api-key-store');
|
|
12
|
+
const { loadConfig, saveConfig, getConfigPath } = require('./config');
|
|
13
|
+
const { logger } = require('./logger');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Normalize a model ID to include the provider prefix.
|
|
17
|
+
* @param {string} provider - e.g. 'google', 'openai'
|
|
18
|
+
* @param {string} id - Model ID, may or may not have provider prefix
|
|
19
|
+
* @returns {string} Normalized ID with provider prefix
|
|
20
|
+
*/
|
|
21
|
+
function normalizeModelId(provider, id) {
|
|
22
|
+
if (id.startsWith(provider + '/')) {
|
|
23
|
+
return id;
|
|
24
|
+
}
|
|
25
|
+
return `${provider}/${id}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Alias-to-search-term mapping for filtering provider model lists */
|
|
29
|
+
const ALIAS_SEARCH_TERMS = {
|
|
30
|
+
'gemini': 'gemini', 'gemini-pro': 'gemini',
|
|
31
|
+
'gpt': 'gpt', 'gpt-pro': 'gpt', 'codex': 'gpt',
|
|
32
|
+
'claude': 'claude', 'sonnet': 'claude', 'opus': 'claude', 'haiku': 'claude',
|
|
33
|
+
'deepseek': 'deepseek',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Validate a direct-API fallback model exists on the provider.
|
|
38
|
+
* Returns silently if valid. On failure: prompts (interactive) or throws (headless).
|
|
39
|
+
*
|
|
40
|
+
* @param {string} resolvedModel - e.g. 'google/gemini-3.1-flash-lite-preview'
|
|
41
|
+
* @param {string} alias - Original alias name (e.g. 'gemini')
|
|
42
|
+
* @param {object} [options]
|
|
43
|
+
* @param {boolean} [options.headless] - If true, throw instead of prompting
|
|
44
|
+
* @returns {Promise<string>} Confirmed model string
|
|
45
|
+
*/
|
|
46
|
+
async function validateDirectModel(resolvedModel, alias, options = {}) {
|
|
47
|
+
const parts = resolvedModel.split('/');
|
|
48
|
+
if (parts.length < 2) { return resolvedModel; }
|
|
49
|
+
|
|
50
|
+
const provider = parts[0];
|
|
51
|
+
const modelId = parts.slice(1).join('/');
|
|
52
|
+
|
|
53
|
+
const keys = readApiKeyValues();
|
|
54
|
+
const providerKey = keys[provider];
|
|
55
|
+
if (!providerKey) { return resolvedModel; }
|
|
56
|
+
|
|
57
|
+
let models;
|
|
58
|
+
try {
|
|
59
|
+
models = await fetchModelsFromProvider(provider, providerKey);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
logger.debug({ msg: 'Model fetch failed, skipping validation', error: err.message });
|
|
62
|
+
return resolvedModel;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!models || models.length === 0) { return resolvedModel; }
|
|
66
|
+
|
|
67
|
+
const found = models.some(m => m.id === resolvedModel || m.id === modelId);
|
|
68
|
+
if (found) { return resolvedModel; }
|
|
69
|
+
|
|
70
|
+
const relevant = filterRelevantModels(models, alias);
|
|
71
|
+
|
|
72
|
+
if (options.headless || !process.stdin.isTTY) {
|
|
73
|
+
const list = relevant.slice(0, 10).map(m => ` ${normalizeModelId(provider, m.id)}`).join('\n');
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Model '${modelId}' not found on ${provider} API.\n` +
|
|
76
|
+
`Available models:\n${list}\n` +
|
|
77
|
+
`Fix with: amicus setup --add-alias ${alias}=${relevant[0] ? normalizeModelId(provider, relevant[0].id) : 'provider/model'}`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return promptModelSelection(relevant, alias, provider, modelId);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Filter models to those relevant to the alias
|
|
86
|
+
* @param {Array<{id: string, name: string}>} models
|
|
87
|
+
* @param {string} alias - e.g. 'gemini', 'gpt', 'opus'
|
|
88
|
+
* @returns {Array<{id: string, name: string}>} Filtered, sorted, max 15
|
|
89
|
+
*/
|
|
90
|
+
function filterRelevantModels(models, alias) {
|
|
91
|
+
const term = (ALIAS_SEARCH_TERMS[alias] || alias).toLowerCase();
|
|
92
|
+
|
|
93
|
+
let filtered = models.filter(m =>
|
|
94
|
+
m.id.toLowerCase().includes(term) ||
|
|
95
|
+
m.name.toLowerCase().includes(term)
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
if (filtered.length === 0) { filtered = models; }
|
|
99
|
+
|
|
100
|
+
filtered.sort((a, b) => a.name.localeCompare(b.name));
|
|
101
|
+
return filtered.slice(0, 15);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Interactive prompt — ask user to pick from available models */
|
|
105
|
+
async function promptModelSelection(models, alias, provider, failedModelId) {
|
|
106
|
+
process.stderr.write(`\n Model '${failedModelId}' not found on ${provider} API.\n`);
|
|
107
|
+
process.stderr.write(' Available models:\n');
|
|
108
|
+
models.forEach((m, i) => {
|
|
109
|
+
const label = (m.name && m.name !== m.id) ? `${m.name} (${m.id})` : m.id;
|
|
110
|
+
process.stderr.write(` ${i + 1}. ${label}\n`);
|
|
111
|
+
});
|
|
112
|
+
process.stderr.write('\n');
|
|
113
|
+
|
|
114
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
115
|
+
|
|
116
|
+
const answer = await new Promise(resolve => {
|
|
117
|
+
rl.question(` Select a model (1-${models.length}) or press Enter to cancel: `, resolve);
|
|
118
|
+
});
|
|
119
|
+
rl.close();
|
|
120
|
+
|
|
121
|
+
const idx = parseInt(answer, 10) - 1;
|
|
122
|
+
if (isNaN(idx) || idx < 0 || idx >= models.length) {
|
|
123
|
+
throw new Error('Model selection cancelled.');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const selected = models[idx];
|
|
127
|
+
const newModel = normalizeModelId(provider, selected.id);
|
|
128
|
+
|
|
129
|
+
let config = loadConfig();
|
|
130
|
+
if (!config) {
|
|
131
|
+
const fs = require('fs');
|
|
132
|
+
const configPath = getConfigPath();
|
|
133
|
+
if (fs.existsSync(configPath)) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Cannot save model selection: config file at ${configPath} is malformed. ` +
|
|
136
|
+
'Fix it manually or run \'amicus setup\'.'
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
config = {};
|
|
140
|
+
}
|
|
141
|
+
if (!config.aliases) { config.aliases = {}; }
|
|
142
|
+
config.aliases[alias] = newModel;
|
|
143
|
+
try {
|
|
144
|
+
saveConfig(config);
|
|
145
|
+
process.stderr.write(` Saved: ${alias} → ${newModel}\n`);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
process.stderr.write(` Warning: Could not save selection (${err.message}). Using for this session only.\n`);
|
|
148
|
+
}
|
|
149
|
+
process.stderr.write(` (To change later: amicus setup --add-alias ${alias}=...)\n\n`);
|
|
150
|
+
|
|
151
|
+
return newModel;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Validate an OpenRouter-resolved model against the cached catalog (F3 #18).
|
|
156
|
+
* Only enforces for `openrouter/`-prefixed models (the catalog is authoritative
|
|
157
|
+
* there). Graceful when the catalog is empty/unavailable. Fails fast with
|
|
158
|
+
* suggestions when the model is genuinely absent.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} resolvedModel
|
|
161
|
+
* @param {string} [alias]
|
|
162
|
+
* @returns {Promise<string>} the model (unchanged) when valid/unverifiable
|
|
163
|
+
*/
|
|
164
|
+
async function validateAgainstCatalog(resolvedModel, alias) {
|
|
165
|
+
if (!resolvedModel.startsWith('openrouter/')) { return resolvedModel; }
|
|
166
|
+
|
|
167
|
+
const { getCatalog } = require('./model-catalog');
|
|
168
|
+
let catalog;
|
|
169
|
+
try { catalog = await getCatalog(); } catch { return resolvedModel; }
|
|
170
|
+
if (!catalog || catalog.length === 0) { return resolvedModel; }
|
|
171
|
+
|
|
172
|
+
// Only enforce when the OpenRouter catalog is actually present. If the fetch
|
|
173
|
+
// was unavailable (e.g. no key reached the fetcher) the catalog won't contain
|
|
174
|
+
// any openrouter/* ids — degrade gracefully instead of false-rejecting.
|
|
175
|
+
if (!catalog.some(m => m.id.startsWith('openrouter/'))) { return resolvedModel; }
|
|
176
|
+
|
|
177
|
+
if (catalog.some(m => m.id === resolvedModel)) { return resolvedModel; }
|
|
178
|
+
|
|
179
|
+
const relevant = filterRelevantModels(catalog, alias || resolvedModel.split('/').pop());
|
|
180
|
+
const list = relevant.slice(0, 10).map(m => ` ${m.id}`).join('\n');
|
|
181
|
+
throw new Error(
|
|
182
|
+
`Model '${resolvedModel}' not found in the OpenRouter catalog.\n` +
|
|
183
|
+
(list ? `Did you mean:\n${list}\n` : '') +
|
|
184
|
+
`Fix: amicus setup --add-alias ${alias || '<alias>'}=${relevant[0] ? relevant[0].id : 'openrouter/provider/model'}\n` +
|
|
185
|
+
'Run \'amicus models --refresh\' to update the catalog, or pass --no-validate-model to skip.'
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Non-blocking advisory check for inherited models (F5: continue/resume).
|
|
191
|
+
* Same catalog logic as validateAgainstCatalog, but warns on stderr instead
|
|
192
|
+
* of throwing — a session that already ran with this model must stay openable.
|
|
193
|
+
* @param {string} model
|
|
194
|
+
*/
|
|
195
|
+
async function warnIfNotInCatalog(model) {
|
|
196
|
+
if (typeof model !== 'string' || !model) { return; }
|
|
197
|
+
try {
|
|
198
|
+
await validateAgainstCatalog(model);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
process.stderr.write(
|
|
201
|
+
`Warning: ${String(err.message).split('\n')[0]} ` +
|
|
202
|
+
'(continuing anyway — run \'amicus models --check\' to review aliases)\n'
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = { validateDirectModel, filterRelevantModels, normalizeModelId, validateAgainstCatalog, warnIfNotInCatalog };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Ensures that the project's node_modules/.bin directory is included in the PATH,
|
|
6
|
+
* and on Windows also adds the platform-specific native opencode binary directory
|
|
7
|
+
* so that `spawn('opencode', ...)` without shell:true can resolve the .exe.
|
|
8
|
+
* The OpenCode SDK spawns the 'opencode' command, and this ensures it can be found.
|
|
9
|
+
*/
|
|
10
|
+
function ensureNodeModulesBinInPath() {
|
|
11
|
+
const nodeModulesRoot = path.join(__dirname, '..', '..', 'node_modules');
|
|
12
|
+
const nodeModulesBin = path.join(nodeModulesRoot, '.bin');
|
|
13
|
+
|
|
14
|
+
if (!process.env.PATH.includes(nodeModulesBin)) {
|
|
15
|
+
process.env.PATH = `${nodeModulesBin}${path.delimiter}${process.env.PATH}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// On Windows, Node's spawn() does not execute .cmd shims without shell:true.
|
|
19
|
+
// Add the platform-specific native binary directory so `opencode` resolves
|
|
20
|
+
// to opencode.exe directly (Windows searches PATHEXT-aware when .exe is present).
|
|
21
|
+
if (os.platform() === 'win32') {
|
|
22
|
+
const archMap = { x64: 'x64', arm64: 'arm64' };
|
|
23
|
+
const arch = archMap[os.arch()] || os.arch();
|
|
24
|
+
const nativeBin = path.join(nodeModulesRoot, `opencode-windows-${arch}`, 'bin');
|
|
25
|
+
if (!process.env.PATH.includes(nativeBin)) {
|
|
26
|
+
process.env.PATH = `${nativeBin}${path.delimiter}${process.env.PATH}`;
|
|
27
|
+
}
|
|
28
|
+
// Baseline variant: the default build needs AVX2; opencode ships a
|
|
29
|
+
// -baseline (pre-AVX2) build for older CPUs. Windows resolves the first
|
|
30
|
+
// PATH entry containing a real opencode.exe, so default-before-baseline
|
|
31
|
+
// order matters — do not delete this block as "dead code".
|
|
32
|
+
const nativeBinBaseline = path.join(nodeModulesRoot, `opencode-windows-${arch}-baseline`, 'bin');
|
|
33
|
+
if (!process.env.PATH.includes(nativeBinBaseline)) {
|
|
34
|
+
process.env.PATH = `${nativeBinBaseline}${path.delimiter}${process.env.PATH}`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = {
|
|
40
|
+
ensureNodeModulesBinInPath,
|
|
41
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform listener-PID lookup.
|
|
3
|
+
*
|
|
4
|
+
* Finds the PID of the process LISTENING on a local TCP port. Replaces the
|
|
5
|
+
* Unix-only `lsof` call so the OpenCode Go server can be force-killed on
|
|
6
|
+
* Windows too (F3 #15).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { execFileSync } = require('child_process');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {number} port - TCP port to inspect
|
|
13
|
+
* @returns {number|null} PID of the LISTENING process, or null if none/none found
|
|
14
|
+
*/
|
|
15
|
+
function findListenerPid(port) {
|
|
16
|
+
try {
|
|
17
|
+
if (process.platform === 'win32') {
|
|
18
|
+
const out = execFileSync('netstat', ['-ano', '-p', 'TCP'], {
|
|
19
|
+
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
20
|
+
});
|
|
21
|
+
for (const line of out.split(/\r?\n/)) {
|
|
22
|
+
// Columns: Proto Local Foreign State PID
|
|
23
|
+
const m = line.trim().match(/^TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)$/i);
|
|
24
|
+
if (m && Number(m[1]) === port) { return Number(m[2]); }
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const out = execFileSync('lsof', ['-ti', `:${port}`, '-sTCP:LISTEN'], {
|
|
29
|
+
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
30
|
+
}).trim();
|
|
31
|
+
// take only the first PID if multiple processes share the port
|
|
32
|
+
const pid = parseInt(out.split(/\s+/)[0], 10);
|
|
33
|
+
return Number.isInteger(pid) ? pid : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { findListenerPid };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// src/utils/prompt-source.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module prompt-source
|
|
6
|
+
* Resolve the prompt for start/fanout from --prompt XOR --prompt-file (F4).
|
|
7
|
+
* --prompt-file exists because Windows caps a CLI argument at ~32 KB, which
|
|
8
|
+
* forced fragile `--prompt "$(cat briefing)"` launches.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} args - Parsed CLI args (kebab-case keys, as from parseArgs)
|
|
16
|
+
* @returns {{prompt: string, promptMeta: {source: 'inline'|'file', file: string|null, chars: number}} | {error: string}}
|
|
17
|
+
*/
|
|
18
|
+
function resolvePromptSource(args) {
|
|
19
|
+
const inline = args.prompt;
|
|
20
|
+
const file = args['prompt-file'];
|
|
21
|
+
|
|
22
|
+
if (inline !== undefined && file !== undefined) {
|
|
23
|
+
return { error: 'Error: --prompt and --prompt-file are mutually exclusive' };
|
|
24
|
+
}
|
|
25
|
+
if (inline === undefined && file === undefined) {
|
|
26
|
+
return { error: 'Error: --prompt or --prompt-file is required' };
|
|
27
|
+
}
|
|
28
|
+
if (inline === true || file === true) {
|
|
29
|
+
return { error: 'Error: --prompt/--prompt-file requires a value' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (file !== undefined) {
|
|
33
|
+
let text;
|
|
34
|
+
try {
|
|
35
|
+
text = fs.readFileSync(file, 'utf-8');
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return { error: `Error: cannot read --prompt-file ${file}: ${err.message}` };
|
|
38
|
+
}
|
|
39
|
+
if (text.charCodeAt(0) === 0xFEFF) { text = text.slice(1); }
|
|
40
|
+
if (!text.trim()) {
|
|
41
|
+
return { error: `Error: --prompt-file ${file} is empty` };
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
prompt: text,
|
|
45
|
+
promptMeta: { source: 'file', file: path.resolve(file), chars: text.length },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const text = String(inline);
|
|
50
|
+
return { prompt: text, promptMeta: { source: 'inline', file: null, chars: text.length } };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { resolvePromptSource };
|