openmerit 0.1.7-preview.4 → 0.1.7
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 +14 -0
- package/README.md +7 -3
- package/dist/eval/src/scout.mjs +12 -1
- package/dist/terminal/public/app.js +33 -32
- package/dist/terminal/src/browser.mjs +7 -6
- package/dist/terminal/src/cli.mjs +10 -1
- package/dist/terminal/src/install.mjs +399 -0
- package/dist/terminal/src/server.mjs +28 -3
- package/dist/terminal/src/telemetry.mjs +118 -0
- package/docs/agent-evaluations.md +13 -3
- package/docs/commands.md +1 -1
- package/docs/getting-started.md +2 -2
- package/docs/index.md +1 -1
- package/docs/inference-terminal.md +19 -3
- package/docs/scout-data-policy.md +77 -0
- package/docs/scout.md +321 -31
- package/docs/security.md +38 -2
- package/docs/testing.md +24 -2
- package/package.json +6 -2
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
// By default, only open the loopback address returned by our own server.
|
|
4
|
+
// Callers can pass a stricter or different check. Never use a shell.
|
|
5
|
+
const terminalURL = url => { const target = new URL(url); return target.protocol === 'http:' && target.hostname === '127.0.0.1' && /^http:\/\/127\.0\.0\.1:[0-9]+$/.test(url) && !target.username && !target.password && target.pathname === '/' && !target.search && !target.hash; };
|
|
6
|
+
export function browserCommand(url, platform = process.platform, env = process.env, allowed = terminalURL) {
|
|
7
|
+
if (!allowed(url)) throw new Error('Refusing to open an unexpected URL.');
|
|
7
8
|
if (env.SSH_CONNECTION || env.SSH_TTY) return null;
|
|
8
9
|
if (platform === 'darwin') return ['open', [url]];
|
|
9
10
|
if (platform === 'win32') return ['rundll32.exe', ['url.dll,FileProtocolHandler', url]];
|
|
@@ -11,8 +12,8 @@ export function browserCommand(url, platform = process.platform, env = process.e
|
|
|
11
12
|
return null;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
export async function openBrowser(url, { platform = process.platform, env = process.env, spawnProcess = spawn, timeoutMs = 3000 } = {}) {
|
|
15
|
-
const command = browserCommand(url, platform, env);
|
|
15
|
+
export async function openBrowser(url, { platform = process.platform, env = process.env, spawnProcess = spawn, timeoutMs = 3000, allowed } = {}) {
|
|
16
|
+
const command = browserCommand(url, platform, env, allowed);
|
|
16
17
|
if (!command) return false;
|
|
17
18
|
return new Promise(resolve => {
|
|
18
19
|
let child, timer;
|
|
@@ -5,10 +5,12 @@ import { startTerminal } from './server.mjs';
|
|
|
5
5
|
import { createCollector, loadConfig } from './collect.mjs';
|
|
6
6
|
import { sampleSnapshot } from './sample.mjs';
|
|
7
7
|
import { openBrowser } from './browser.mjs';
|
|
8
|
+
import { commandTelemetry } from './telemetry.mjs';
|
|
8
9
|
|
|
9
10
|
const help = `OpenMerit · Inference Terminal
|
|
10
11
|
|
|
11
12
|
openmerit dash [path] [options]
|
|
13
|
+
openmerit install --help Connect OpenMerit to your coding agent
|
|
12
14
|
openmerit eval --help Evaluate CLI changes across coding agents
|
|
13
15
|
|
|
14
16
|
--port <number> Require a specific local port (default: 4310 or a free port)
|
|
@@ -27,6 +29,7 @@ Coding-harness usage is outside this terminal's scope.
|
|
|
27
29
|
|
|
28
30
|
async function main() {
|
|
29
31
|
const args = process.argv.slice(2);
|
|
32
|
+
if (['install', 'status', 'remove', 'login', 'logout', 'project', 'doctor'].includes(args[0])) { const { main } = await import('./install.mjs'); process.exitCode = await main(args); return; }
|
|
30
33
|
if (args[0] === 'eval') { const { main } = await import('../../eval/src/cli.mjs'); await main(args.slice(1)); return; }
|
|
31
34
|
if (!args.length || args.includes('--help') || args.includes('-h')) { console.log(help); return; }
|
|
32
35
|
if (args.shift() !== 'dash') throw new Error('Unknown command. Run openmerit --help.');
|
|
@@ -62,4 +65,10 @@ async function main() {
|
|
|
62
65
|
}
|
|
63
66
|
if (!noOpen && !await openBrowser(terminal.url)) console.log(' Open the local URL above in your browser.');
|
|
64
67
|
}
|
|
65
|
-
|
|
68
|
+
const completeTelemetry = commandTelemetry(process.argv.slice(2));
|
|
69
|
+
const setupCodes = { 10: 'approval_pending', 11: 'access_denied', 20: 'install_conflict', 30: 'openmerit_unreachable' };
|
|
70
|
+
main().then(() => completeTelemetry(process.exitCode ? 'error' : 'ok', undefined, setupCodes[process.exitCode]), async error => {
|
|
71
|
+
console.error(`\n ${error.code === 'EADDRINUSE' ? 'Port is in use. Choose another with --port.' : error.message}\n`);
|
|
72
|
+
process.exitCode = 1;
|
|
73
|
+
await completeTelemetry('error', error);
|
|
74
|
+
});
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { homedir, hostname } from 'node:os';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { openBrowser } from './browser.mjs';
|
|
8
|
+
|
|
9
|
+
// One-command coding-agent setup: device sign-in in the browser, then the MCP
|
|
10
|
+
// server and Scout skill. Built to be run by a person or by a coding agent:
|
|
11
|
+
// no interactive prompts, bounded waits, resumable logins and explicit exit codes.
|
|
12
|
+
export const exitCodes = { ok: 0, error: 1, pending: 10, denied: 11, conflict: 20, unreachable: 30 };
|
|
13
|
+
const exec = promisify(execFile);
|
|
14
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
15
|
+
const policyPath = '.openmerit/scout.json';
|
|
16
|
+
const clients = { 'claude-code': 'Claude Code' };
|
|
17
|
+
|
|
18
|
+
export const skill = `---
|
|
19
|
+
name: openmerit-scout
|
|
20
|
+
description: Use when choosing, adding, replacing or configuring an application inference model (an LLM or other model the user's application calls), for example when building extraction, classification, generation or agent features. Consults OpenMerit Scout for an evidence-based shortlist. Not for unrelated edits, ordinary inference calls or this coding agent's own model.
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
# OpenMerit · Scout
|
|
24
|
+
|
|
25
|
+
Before introducing, recommending, replacing or configuring an application
|
|
26
|
+
inference model, call the \`scout_models\` tool on the \`openmerit\` MCP server.
|
|
27
|
+
This applies even when the user never mentions Scout. Preserve the user's explicit
|
|
28
|
+
model choice; assess it rather than silently replacing it. Leave unchanged,
|
|
29
|
+
approved application selections in place.
|
|
30
|
+
|
|
31
|
+
## Project policy
|
|
32
|
+
|
|
33
|
+
Read \`${policyPath}\` before searching. If it is absent, ask once whether this
|
|
34
|
+
project uses **existing-only** (only endpoints it can already reach) or
|
|
35
|
+
**existing-first** (prefer them, ask before searching the whole catalogue), and
|
|
36
|
+
which public catalogue endpoint IDs it can access. Save the answer:
|
|
37
|
+
|
|
38
|
+
\`\`\`sh
|
|
39
|
+
npx -y openmerit@latest project --policy existing-only --endpoint-id <id> ...
|
|
40
|
+
\`\`\`
|
|
41
|
+
|
|
42
|
+
Never infer access from a model name, SDK or environment-variable name. Never
|
|
43
|
+
record credentials, private URLs, prompts or customer data. An empty inventory
|
|
44
|
+
is not permission to search all models.
|
|
45
|
+
|
|
46
|
+
## Searching
|
|
47
|
+
|
|
48
|
+
Call \`scout_models\` with a non-confidential task description and
|
|
49
|
+
\`endpointIds\` set to the saved inventory, including \`[]\` when empty.
|
|
50
|
+
Existing-only never broadens. For existing-first, if no existing candidate fits,
|
|
51
|
+
explain the gap and ask before a separate search without \`endpointIds\`. Send
|
|
52
|
+
only task requirements, never source files, secrets or real customer records.
|
|
53
|
+
|
|
54
|
+
Compare model AND endpoint, evidence, reference cost/time, unknowns and
|
|
55
|
+
warnings. For multiple tasks read \`tasks[].slots[0].recommendations\`; the
|
|
56
|
+
top-level list is empty. Prefer the cheapest usable option the quality evidence
|
|
57
|
+
supports, without claiming benchmarks prove application accuracy. Use the
|
|
58
|
+
returned provider model ID and the project's actual SDK and protocol.
|
|
59
|
+
|
|
60
|
+
## Boundaries
|
|
61
|
+
|
|
62
|
+
Ask before adding providers or credentials, spending, deploying or external
|
|
63
|
+
writes. Report recommendation, endpoint connectivity and application checks as
|
|
64
|
+
separate results, each only after it succeeds. Do not switch this coding agent's
|
|
65
|
+
own model.
|
|
66
|
+
|
|
67
|
+
If Scout fails, say so and pause the model decision; do not substitute a
|
|
68
|
+
remembered recommendation. If it reports that the device key is missing,
|
|
69
|
+
expired or revoked, run \`npx -y openmerit@latest login\` and then
|
|
70
|
+
\`npx -y openmerit@latest install\` again. Never ask the user to paste a key.
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
export const help = `OpenMerit · coding-agent setup
|
|
74
|
+
|
|
75
|
+
openmerit install [--client claude-code] [--scope user|project] [--plan] [--json] [--no-open]
|
|
76
|
+
openmerit status [--client claude-code] [--scope user|project] [--check] [--json]
|
|
77
|
+
openmerit remove [--client claude-code] [--scope user|project] [--plan] [--json]
|
|
78
|
+
openmerit login [--json] [--no-open] Replace this device's key
|
|
79
|
+
openmerit logout [--json] Revoke this device's key
|
|
80
|
+
openmerit project --policy existing-only|existing-first [--endpoint-id <id> ...] [--plan]
|
|
81
|
+
openmerit doctor [--json]
|
|
82
|
+
|
|
83
|
+
install signs this device in through the browser (one Approve click), adds the
|
|
84
|
+
OpenMerit MCP server and the Scout skill, then verifies the connection.
|
|
85
|
+
It opens the approval page and waits up to 9 minutes for the Approve click
|
|
86
|
+
(30 seconds when no browser could be opened). If it exits with approval
|
|
87
|
+
pending, rerun the same command: it resumes the same request.
|
|
88
|
+
--client defaults to the coding agent running the command. --scope defaults to user.
|
|
89
|
+
--plan shows the changes without making them. Keys are never printed.
|
|
90
|
+
|
|
91
|
+
Exit codes: 0 done, 10 approval pending, 11 denied or expired, 20 conflict,
|
|
92
|
+
30 OpenMerit unreachable, 1 other error.
|
|
93
|
+
`;
|
|
94
|
+
|
|
95
|
+
class Stop extends Error { constructor(message, code, next) { super(message); this.exit = code; this.next = next; } }
|
|
96
|
+
|
|
97
|
+
const server = () => (process.env.OPENMERIT_URL ?? 'https://openmerit.ai').replace(/\/+$/, '');
|
|
98
|
+
const configDir = () => resolve(process.env.OPENMERIT_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'openmerit'));
|
|
99
|
+
const credentialsPath = () => join(configDir(), 'credentials.json');
|
|
100
|
+
const pendingPath = () => join(configDir(), 'login.json');
|
|
101
|
+
|
|
102
|
+
async function optional(path) {
|
|
103
|
+
try { return await readFile(path, 'utf8'); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
104
|
+
}
|
|
105
|
+
async function object(path) {
|
|
106
|
+
const text = await optional(path);
|
|
107
|
+
if (text === null) return {};
|
|
108
|
+
let value;
|
|
109
|
+
try { value = JSON.parse(text); } catch { throw new Stop(`Invalid JSON in ${path}; nothing was changed.`, exitCodes.conflict); }
|
|
110
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Stop(`Expected a JSON object in ${path}.`, exitCodes.conflict);
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
async function save(path, value) {
|
|
114
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
115
|
+
await writeFile(path, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function request(path, { method = 'GET', body, key } = {}) {
|
|
119
|
+
let response;
|
|
120
|
+
try {
|
|
121
|
+
response = await fetch(`${server()}${path}`, {
|
|
122
|
+
method, redirect: 'error', signal: AbortSignal.timeout(15_000),
|
|
123
|
+
headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...(key ? { Authorization: `Bearer ${key}` } : {}), 'X-Scout-Client': 'cli', 'X-Scout-Analytics-Content': 'off' },
|
|
124
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
125
|
+
});
|
|
126
|
+
} catch { throw new Stop(`Could not reach ${server()}. Check your connection and retry.`, exitCodes.unreachable); }
|
|
127
|
+
const data = await response.json().catch(() => ({}));
|
|
128
|
+
return { status: response.status, data };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function detectClient() {
|
|
132
|
+
if (process.env.CLAUDECODE === '1' || process.env.CLAUDE_CODE_ENTRYPOINT) return 'claude-code';
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Claude Code locations. Project scope keeps the key out of the repository:
|
|
137
|
+
// the MCP entry uses Claude's private "local" scope, only the skill is in the project.
|
|
138
|
+
function claudePaths(root, scope) {
|
|
139
|
+
const config = resolve(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'));
|
|
140
|
+
const base = scope === 'user' ? config : join(root, '.claude');
|
|
141
|
+
return {
|
|
142
|
+
skill: join(base, 'skills/openmerit-scout/SKILL.md'),
|
|
143
|
+
legacyRule: join(base, 'rules/openmerit.md'),
|
|
144
|
+
record: join(base, 'openmerit-install.json'),
|
|
145
|
+
mcpFile: process.env.CLAUDE_CONFIG_DIR ? join(config, '.claude.json') : join(homedir(), '.claude.json'),
|
|
146
|
+
mcpScope: scope === 'user' ? 'user' : 'local',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const configuredServer = (config, root, scope) => scope === 'user' ? config.mcpServers?.openmerit : config.projects?.[root]?.mcpServers?.openmerit;
|
|
150
|
+
const serverConfig = key => ({ type: 'http', url: `${server()}/scout/mcp`, headers: { Authorization: `Bearer ${key}`, 'X-Scout-Analytics-Content': 'off' } });
|
|
151
|
+
const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(k => [k, canonical(value[k])])) : value;
|
|
152
|
+
const serverHash = value => value ? hash(JSON.stringify(canonical(value))) : null;
|
|
153
|
+
|
|
154
|
+
async function claude(args, root, key) {
|
|
155
|
+
try { return await exec('claude', args, { cwd: root, timeout: 30_000, maxBuffer: 1024 * 1024, windowsHide: true }); }
|
|
156
|
+
catch (error) {
|
|
157
|
+
if (error.code === 'ENOENT') throw new Stop('Claude Code (the claude command) was not found on PATH. Install it, then retry.', exitCodes.error);
|
|
158
|
+
// Claude echoes headers on some failures: scrub the key, and show only a short excerpt.
|
|
159
|
+
const detail = `${error.stderr ?? ''}${error.stdout ?? ''}`.split(key || '\u0000').join('[key]').replace(/om_[A-Za-z0-9_-]{20,}/g, '[key]').trim().slice(0, 300);
|
|
160
|
+
throw new Stop(`Claude Code could not complete "claude ${args.slice(0, 2).join(' ')}"${detail ? `: ${detail}` : '.'}`, exitCodes.error);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Inspect the current Claude Code installation without changing anything.
|
|
165
|
+
async function inspect(root, scope) {
|
|
166
|
+
const paths = claudePaths(root, scope);
|
|
167
|
+
const [record, config, installedSkill, legacyRule] = await Promise.all([object(paths.record), object(paths.mcpFile), optional(paths.skill), optional(paths.legacyRule)]);
|
|
168
|
+
const owned = record.version === 2 && record.scope === scope;
|
|
169
|
+
const current = configuredServer(config, root, scope);
|
|
170
|
+
const otherScope = configuredServer(config, root, scope === 'user' ? 'project' : 'user');
|
|
171
|
+
const projectFile = await object(join(root, '.mcp.json'));
|
|
172
|
+
return {
|
|
173
|
+
paths, record, owned, current, installedSkill,
|
|
174
|
+
// A pending header hash covers an install interrupted between add and record.
|
|
175
|
+
serverOwned: !current || owned && (serverHash(current) === record.serverHash || !!record.pendingAuth && hash(current.headers?.Authorization ?? '') === record.pendingAuth),
|
|
176
|
+
skillOwned: installedSkill === null || owned && [record.skillHash, record.pendingSkill].includes(hash(installedSkill)),
|
|
177
|
+
// The pre-release installer recorded its rule hash; only an unchanged rule is removed.
|
|
178
|
+
legacyRule: legacyRule !== null && record.version === 1 && hash(legacyRule) === record.ruleHash ? 'remove' : legacyRule !== null ? 'keep' : 'none',
|
|
179
|
+
shadowed: !!(otherScope || projectFile.mcpServers?.openmerit),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function conflicts(state) {
|
|
184
|
+
const found = [];
|
|
185
|
+
if (!state.serverOwned) found.push('Claude Code already has an MCP server named "openmerit" that this installer did not create or that was edited. Remove it with "claude mcp remove openmerit", then retry.');
|
|
186
|
+
if (!state.skillOwned) found.push(`${state.paths.skill} exists and was not created by this installer or was edited. Move or delete it, then retry.`);
|
|
187
|
+
if (state.shadowed) found.push('An "openmerit" MCP server exists in another Claude Code scope (or in .mcp.json). Remove it, then retry.');
|
|
188
|
+
return found;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function credentials() {
|
|
192
|
+
const saved = await object(credentialsPath());
|
|
193
|
+
return saved.version === 1 && saved.server === server() && typeof saved.key === 'string' ? saved : null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Device login. Starts or resumes a login, waits a bounded time, and stores the
|
|
197
|
+
// key only on disk. Never prints the key.
|
|
198
|
+
async function login({ client, scope, open, log, wait }) {
|
|
199
|
+
let pending = await object(pendingPath());
|
|
200
|
+
if (!(pending.server === server() && pending.expiresAt > Date.now() && typeof pending.deviceCode === 'string')) {
|
|
201
|
+
const deviceName = (process.env.OPENMERIT_DEVICE_NAME ?? hostname()).replace(/[^\x20-\x7e]/g, '').slice(0, 64) || 'this device';
|
|
202
|
+
const { status, data } = await request('/connect/api/device', { method: 'POST', body: { client, scope, device_name: deviceName } });
|
|
203
|
+
if (status !== 200 || !data.device_code) throw new Stop(data.error ?? `Could not start sign-in (HTTP ${status}).`, status === 429 ? exitCodes.unreachable : exitCodes.error);
|
|
204
|
+
pending = { server: server(), deviceCode: data.device_code, userCode: data.user_code, url: data.verification_uri_complete, interval: data.interval, expiresAt: Date.now() + data.expires_in * 1000, opened: false };
|
|
205
|
+
await save(pendingPath(), pending);
|
|
206
|
+
}
|
|
207
|
+
log(`Approve this device in your browser (opening it now):\n\n ${pending.url}\n\n Code: ${pending.userCode} (expires ${new Date(pending.expiresAt).toLocaleTimeString()})\n`);
|
|
208
|
+
if (open && !pending.opened && !pending.openFailed) {
|
|
209
|
+
// Only the approval page on the configured OpenMerit server may be opened.
|
|
210
|
+
const approvalPage = url => { const u = new URL(url); return `${u.origin}` === server() && u.pathname === '/connect' && [...u.searchParams.keys()].join() === 'code' && !u.username && !u.password && !u.hash; };
|
|
211
|
+
const opened = await openBrowser(pending.url, { allowed: approvalPage }).catch(() => false);
|
|
212
|
+
pending = { ...pending, ...(opened ? { opened: true } : { openFailed: true }) };
|
|
213
|
+
await save(pendingPath(), pending);
|
|
214
|
+
}
|
|
215
|
+
// With the approval page open, keep waiting for the Approve click (inside a
|
|
216
|
+
// coding agent's 10-minute command limit) so nobody has to rerun anything.
|
|
217
|
+
// Without a browser, return soon so the agent can show the link.
|
|
218
|
+
const deadline = Date.now() + (wait ?? (pending.opened ? 540 : 30)) * 1000;
|
|
219
|
+
let interval = (pending.interval ?? 5) * 1000;
|
|
220
|
+
for (;;) {
|
|
221
|
+
const { status, data } = await request('/connect/api/token', { method: 'POST', body: { device_code: pending.deviceCode } });
|
|
222
|
+
if (status === 200 && data.access_token) {
|
|
223
|
+
await save(credentialsPath(), { version: 1, server: server(), key: data.access_token, keyId: data.key_id, createdAt: new Date().toISOString() });
|
|
224
|
+
await rm(pendingPath(), { force: true });
|
|
225
|
+
return data.access_token;
|
|
226
|
+
}
|
|
227
|
+
if (data.error === 'slow_down') interval += 5000;
|
|
228
|
+
else if (data.error !== 'authorization_pending') {
|
|
229
|
+
await rm(pendingPath(), { force: true });
|
|
230
|
+
if (data.error === 'access_denied') throw new Stop('The sign-in request was denied. Nothing was installed.', exitCodes.denied, 'Rerun the command to start a new request.');
|
|
231
|
+
throw new Stop('The sign-in code expired or was already used. Nothing was installed.', exitCodes.denied, 'Rerun the command to get a new code.');
|
|
232
|
+
}
|
|
233
|
+
if (Date.now() + interval > deadline) throw new Stop(`Waiting for approval of code ${pending.userCode}.`, exitCodes.pending, `Open ${pending.url}, click Approve, then rerun the same command. It resumes this request.`);
|
|
234
|
+
await new Promise(resolve => setTimeout(resolve, interval));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// A real MCP initialize and tool discovery with the stored key; no search.
|
|
239
|
+
async function verify(key) {
|
|
240
|
+
const call = (id, method, params = {}) => request('/scout/mcp', { method: 'POST', key, body: { jsonrpc: '2.0', id, method, params } });
|
|
241
|
+
const init = await call(1, 'initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'openmerit-cli', version: '1' } });
|
|
242
|
+
if (init.status === 401) return 'key_rejected';
|
|
243
|
+
if (init.status !== 200 || !init.data.result) return 'failed';
|
|
244
|
+
const tools = await call(2, 'tools/list');
|
|
245
|
+
return tools.data.result?.tools?.some(tool => tool.name === 'scout_models') ? 'connected' : 'failed';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function install(root, { client, scope, plan, open, log, wait }) {
|
|
249
|
+
const state = await inspect(root, scope);
|
|
250
|
+
const found = conflicts(state);
|
|
251
|
+
const changes = [
|
|
252
|
+
`${state.current ? 'Update' : 'Add'} MCP server "openmerit" in Claude Code (${state.paths.mcpScope} scope, ${state.paths.mcpFile})`,
|
|
253
|
+
`${state.installedSkill === null ? 'Create' : 'Update'} ${state.paths.skill}`,
|
|
254
|
+
...(state.legacyRule === 'remove' ? [`Remove the unchanged earlier instructions at ${state.paths.legacyRule}`] : []),
|
|
255
|
+
...(await credentials() ? [] : [`Sign this device in through the browser and store its key in ${credentialsPath()} (mode 600)`]),
|
|
256
|
+
];
|
|
257
|
+
if (plan) return { action: 'install', applied: false, client, scope, changes, conflicts: found };
|
|
258
|
+
if (found.length) throw new Stop(found.join('\n'), exitCodes.conflict, 'Nothing was changed.');
|
|
259
|
+
await claude(['--version'], root);
|
|
260
|
+
let key = (await credentials())?.key;
|
|
261
|
+
// A revoked or expired key gets a fresh sign-in instead of a broken install.
|
|
262
|
+
if (key && await verify(key) === 'key_rejected') { await rm(credentialsPath(), { force: true }); key = undefined; log('The saved device key is no longer valid; signing in again.'); }
|
|
263
|
+
key ??= await login({ client, scope, open, log, wait });
|
|
264
|
+
const next = serverConfig(key);
|
|
265
|
+
// Record intent first so an interrupted install is repaired by rerunning.
|
|
266
|
+
await save(state.paths.record, { version: 2, scope, client, serverHash: serverHash(state.current), skillHash: state.installedSkill === null ? null : hash(state.installedSkill), pendingAuth: hash(next.headers.Authorization), pendingSkill: hash(skill) });
|
|
267
|
+
if (state.current?.url !== next.url || state.current?.headers?.Authorization !== next.headers.Authorization) {
|
|
268
|
+
if (state.current) await claude(['mcp', 'remove', '--scope', state.paths.mcpScope, 'openmerit'], root, key);
|
|
269
|
+
await claude(['mcp', 'add-json', '--scope', state.paths.mcpScope, 'openmerit', JSON.stringify(next)], root, key);
|
|
270
|
+
}
|
|
271
|
+
await save(state.paths.skill, skill);
|
|
272
|
+
if (state.legacyRule === 'remove') await rm(state.paths.legacyRule, { force: true });
|
|
273
|
+
// Record what Claude actually wrote, so later edits are detected precisely.
|
|
274
|
+
const written = configuredServer(await object(state.paths.mcpFile), root, scope);
|
|
275
|
+
if (!written) throw new Stop('Claude Code did not save the "openmerit" MCP server.', exitCodes.error, 'Run "openmerit doctor".');
|
|
276
|
+
await save(state.paths.record, { version: 2, scope, client, serverHash: serverHash(written), skillHash: hash(skill), installedAt: new Date().toISOString() });
|
|
277
|
+
const connection = await verify(key);
|
|
278
|
+
if (connection === 'key_rejected') throw new Stop('OpenMerit rejected this device key (expired or revoked).', exitCodes.denied, 'Run "openmerit login", then rerun install.');
|
|
279
|
+
return { action: 'install', applied: true, client, scope, changes, connection,
|
|
280
|
+
next: connection === 'connected'
|
|
281
|
+
? `Scout is ready in new ${clients[client]} sessions. In a session that is already open, run /mcp and reconnect "openmerit".`
|
|
282
|
+
: 'Installed, but the connection check failed. Run "openmerit doctor".' };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function status(root, { client, scope, check }) {
|
|
286
|
+
const state = await inspect(root, scope), saved = await credentials();
|
|
287
|
+
const installed = state.owned && !!state.current && state.serverOwned && state.installedSkill !== null && state.skillOwned && !state.shadowed;
|
|
288
|
+
return { action: 'status', client, scope, installed, signedIn: !!saved,
|
|
289
|
+
server: state.current ? state.serverOwned ? 'matches' : 'modified_or_unowned' : 'missing',
|
|
290
|
+
skill: state.installedSkill === null ? 'missing' : state.skillOwned ? 'matches' : 'modified_or_unowned',
|
|
291
|
+
conflicts: conflicts(state), connection: check && saved ? await verify(saved.key) : 'not_checked' };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function revokeKey() {
|
|
295
|
+
const saved = await credentials();
|
|
296
|
+
if (!saved) return false;
|
|
297
|
+
const { status } = await request('/connect/api/keys/self', { method: 'DELETE', key: saved.key }).catch(() => ({ status: 0 }));
|
|
298
|
+
await rm(credentialsPath(), { force: true });
|
|
299
|
+
return status === 200;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function remove(root, { client, scope, plan }) {
|
|
303
|
+
const state = await inspect(root, scope);
|
|
304
|
+
if (!state.owned) return { action: 'remove', applied: false, client, scope, changes: [], note: 'No OpenMerit installation by this installer in this scope.' };
|
|
305
|
+
const changes = [...(state.current && state.serverOwned ? [`Remove MCP server "openmerit" (${state.paths.mcpScope} scope)`] : []),
|
|
306
|
+
...(state.installedSkill !== null && state.skillOwned ? [`Delete ${state.paths.skill}`] : []), 'Revoke and delete this device key'];
|
|
307
|
+
const kept = [...(state.serverOwned ? [] : ['the edited "openmerit" MCP server']), ...(state.skillOwned ? [] : [state.paths.skill]), policyPath];
|
|
308
|
+
if (plan) return { action: 'remove', applied: false, client, scope, changes, keeps: kept };
|
|
309
|
+
if (state.current && state.serverOwned) await claude(['mcp', 'remove', '--scope', state.paths.mcpScope, 'openmerit'], root);
|
|
310
|
+
if (state.installedSkill !== null && state.skillOwned) await rm(dirname(state.paths.skill), { recursive: true, force: true });
|
|
311
|
+
await rm(state.paths.record, { force: true });
|
|
312
|
+
const revoked = await revokeKey();
|
|
313
|
+
return { action: 'remove', applied: true, client, scope, changes, keeps: kept, keyRevoked: revoked };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function project(root, { policy, ids, plan }) {
|
|
317
|
+
if (!['existing-only', 'existing-first'].includes(policy)) throw new Stop('Choose --policy existing-only or existing-first.', exitCodes.error);
|
|
318
|
+
if (ids.length > 100 || new Set(ids).size !== ids.length || ids.some(id => typeof id !== 'string' || id.includes('://') || !/^[a-zA-Z0-9][a-zA-Z0-9/_.:+-]{0,159}$/.test(id))) throw new Stop('Use up to 100 unique public catalogue endpoint IDs, never credentials or URLs.', exitCodes.error);
|
|
319
|
+
const value = { version: 1, policy, endpointIds: ids }, file = join(root, policyPath), existing = await object(file);
|
|
320
|
+
if (Object.keys(existing).some(key => !['version', 'policy', 'endpointIds'].includes(key))) throw new Stop(`${policyPath} has unrecognized fields; edit it yourself instead.`, exitCodes.conflict);
|
|
321
|
+
if (!plan) await save(file, value);
|
|
322
|
+
return { action: 'project', applied: !plan, path: policyPath, policy: value, note: 'Endpoint IDs limit discovery only; they do not prove access or authorize spending.' };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function doctor(root, { scope }) {
|
|
326
|
+
const checks = [];
|
|
327
|
+
const add = (name, ok, detail) => checks.push({ name, ok, detail });
|
|
328
|
+
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
329
|
+
add('node', major > 22 || major === 22 && minor >= 19, `Node.js ${process.versions.node} (22.19+ required)`);
|
|
330
|
+
try { const { stdout } = await exec('claude', ['--version'], { timeout: 10_000 }); add('claude', true, stdout.trim().slice(0, 80)); }
|
|
331
|
+
catch { add('claude', false, 'Claude Code (claude) is not on PATH'); }
|
|
332
|
+
const reach = await request('/scout/api/status').catch(error => ({ status: 0, error }));
|
|
333
|
+
add('openmerit', reach.status === 200, reach.status === 200 ? `${server()} reachable` : `${server()} unreachable`);
|
|
334
|
+
const saved = await credentials();
|
|
335
|
+
add('key', !!saved, saved ? `Device key ${saved.keyId ?? ''} stored in ${credentialsPath()}` : 'Not signed in. Run "openmerit install" or "openmerit login".');
|
|
336
|
+
if (saved && reach.status === 200) { const connection = await verify(saved.key); add('mcp', connection === 'connected', `MCP check: ${connection}`); }
|
|
337
|
+
const state = await inspect(root, scope).catch(() => null);
|
|
338
|
+
if (state) add('install', state.owned && !conflicts(state).length, state.owned ? conflicts(state).join(' ') || `Installed (${scope} scope)` : `Not installed in ${scope} scope`);
|
|
339
|
+
return { action: 'doctor', ok: checks.every(check => check.ok), checks };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function render(result) {
|
|
343
|
+
if (result.action === 'doctor') return result.checks.map(c => `${c.ok ? '✓' : '✗'} ${c.name}: ${c.detail}`).join('\n');
|
|
344
|
+
if (result.action === 'status') return [`OpenMerit in ${clients[result.client]} (${result.scope} scope): ${result.installed ? 'installed' : 'not installed'}`, `Signed in: ${result.signedIn ? 'yes' : 'no'} · MCP server: ${result.server} · skill: ${result.skill} · connection: ${result.connection}`, ...result.conflicts].join('\n');
|
|
345
|
+
if (result.action === 'project') return `${result.applied ? 'Saved' : 'Would save'} ${result.path}: ${result.policy.policy}, ${result.policy.endpointIds.length} endpoint ID(s). ${result.note}`;
|
|
346
|
+
if (result.action === 'login') return result.note;
|
|
347
|
+
if (result.action === 'logout') return result.note;
|
|
348
|
+
const lines = [result.applied ? `Done (${result.action}):` : result.changes.length ? `Plan (${result.action}, nothing changed):` : result.note, ...result.changes.map(c => ` • ${c}`)];
|
|
349
|
+
if (result.conflicts?.length) lines.push('Conflicts (install would stop):', ...result.conflicts.map(c => ` • ${c}`));
|
|
350
|
+
if (result.connection) lines.push(`Connection check: ${result.connection}`);
|
|
351
|
+
if (result.next) lines.push('', result.next);
|
|
352
|
+
return lines.join('\n');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export async function main(args, { root = process.cwd(), print = console.log, printError = console.error } = {}) {
|
|
356
|
+
const [action, ...rest] = args;
|
|
357
|
+
if (!action || rest.includes('--help') || rest.includes('-h')) { print(help); return exitCodes.ok; }
|
|
358
|
+
const options = { client: undefined, scope: 'user', plan: false, check: false, json: false, open: true, policy: undefined, ids: [] };
|
|
359
|
+
const allowed = { install: ['--client', '--scope', '--plan', '--json', '--no-open'], status: ['--client', '--scope', '--check', '--json'], remove: ['--client', '--scope', '--plan', '--json'],
|
|
360
|
+
login: ['--json', '--no-open'], logout: ['--json'], project: ['--policy', '--endpoint-id', '--plan', '--json'], doctor: ['--scope', '--json'] }[action];
|
|
361
|
+
if (!allowed) throw new Error('Unknown command. Run openmerit --help.');
|
|
362
|
+
for (let i = 0; i < rest.length; i++) {
|
|
363
|
+
const option = rest[i];
|
|
364
|
+
if (!allowed.includes(option)) throw new Error(`Unknown option for ${action}: ${option}`);
|
|
365
|
+
const value = () => { const v = rest[++i]; if (v === undefined || v.startsWith('--')) throw new Error(`${option} needs a value.`); return v; };
|
|
366
|
+
if (option === '--client') options.client = value();
|
|
367
|
+
else if (option === '--scope') options.scope = value();
|
|
368
|
+
else if (option === '--policy') options.policy = value();
|
|
369
|
+
else if (option === '--endpoint-id') options.ids.push(value());
|
|
370
|
+
else if (option === '--no-open') options.open = false;
|
|
371
|
+
else options[option.slice(2)] = true;
|
|
372
|
+
}
|
|
373
|
+
if (!['user', 'project'].includes(options.scope)) throw new Error('Use --scope user or --scope project.');
|
|
374
|
+
if (['install', 'status', 'remove'].includes(action)) {
|
|
375
|
+
options.client ??= detectClient();
|
|
376
|
+
if (!options.client) throw new Error('Choose the coding agent with --client claude-code.');
|
|
377
|
+
if (!clients[options.client]) throw new Error(`Unsupported client "${options.client}". Supported: ${Object.keys(clients).join(', ')}.`);
|
|
378
|
+
}
|
|
379
|
+
root = resolve(root);
|
|
380
|
+
// Agents run commands with a few-minute timeout; wait well inside it, then resume.
|
|
381
|
+
const wait = process.env.OPENMERIT_LOGIN_WAIT === undefined ? undefined : Number(process.env.OPENMERIT_LOGIN_WAIT);
|
|
382
|
+
const log = text => (options.json ? printError : print)(text);
|
|
383
|
+
try {
|
|
384
|
+
const result = action === 'install' ? await install(root, { ...options, log, wait })
|
|
385
|
+
: action === 'status' ? await status(root, options)
|
|
386
|
+
: action === 'remove' ? await remove(root, options)
|
|
387
|
+
: action === 'project' ? await project(root, options)
|
|
388
|
+
: action === 'doctor' ? await doctor(root, options)
|
|
389
|
+
: action === 'logout' ? { action, revoked: await revokeKey(), note: 'Signed out. This device key was revoked and deleted. Agents configured with it stop working until you run install again.' }
|
|
390
|
+
: (await revokeKey(), await rm(pendingPath(), { force: true }), await login({ client: 'claude-code', scope: 'user', open: options.open, log, wait }), { action, signedIn: true, note: 'Signed in. Run "openmerit install" again to give your coding agents the new key.' });
|
|
391
|
+
print(options.json ? JSON.stringify({ ok: true, ...result }, null, 2) : render(result));
|
|
392
|
+
return action === 'doctor' && !result.ok ? exitCodes.error : exitCodes.ok;
|
|
393
|
+
} catch (error) {
|
|
394
|
+
if (!(error instanceof Stop)) throw error;
|
|
395
|
+
if (options.json) print(JSON.stringify({ ok: false, action, code: Object.keys(exitCodes).find(k => exitCodes[k] === error.exit), message: error.message, next: error.next }, null, 2));
|
|
396
|
+
else printError([error.message, error.next].filter(Boolean).join('\n'));
|
|
397
|
+
return error.exit;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { basename } from 'node:path';
|
|
5
5
|
import { createCollector, loadConfig } from './collect.mjs';
|
|
6
6
|
import { sampleSnapshot } from './sample.mjs';
|
|
7
|
+
import { telemetryEnabled, terminalProperties, sendTerminalEvent, errorProperties } from './telemetry.mjs';
|
|
7
8
|
|
|
8
9
|
const assets = new Map([['/', ['index.html', 'text/html']], ['/app.js', ['app.js', 'text/javascript']], ['/flow.js', ['flow.js', 'text/javascript']], ['/flow.css', ['flow.css', 'text/css']], ['/styles.css', ['styles.css', 'text/css']], ['/favicon.svg', ['favicon.svg', 'image/svg+xml']]]);
|
|
9
10
|
for (const file of ['baseten.png', 'groq.ico', 'cerebras.png', 'qwen.png', 'meta.ico', 'nomic.png', 'baai.png', 'ollama.png', 'lm-studio.png', 'vllm.png', 'anthropic.png', 'google.png', 'deepseek.ico', 'cohere.png', 'openai.png', 'mistral.png', 'openrouter.png', 'huggingface.svg']) assets.set(`/brands/${file}`, [`brands/${file}`, file.endsWith('.svg') ? 'image/svg+xml' : file.endsWith('.ico') ? 'image/x-icon' : 'image/png']);
|
|
@@ -16,7 +17,7 @@ export async function startTerminal({ root = process.cwd(), port = 4310, interva
|
|
|
16
17
|
if(busy||closed)return;
|
|
17
18
|
busy=true;
|
|
18
19
|
try {const next=await collect();if(!closed)snapshot=next;}
|
|
19
|
-
catch {if(snapshot.loading)snapshot={...snapshot,loadError:true};}
|
|
20
|
+
catch (error) {if(snapshot.loading)snapshot={...snapshot,loadError:true};sendTerminalEvent('terminal_error',{code:'collection_error',...errorProperties(error)});}
|
|
20
21
|
finally {busy=false;}
|
|
21
22
|
};
|
|
22
23
|
const server = createServer(async (request, response) => {
|
|
@@ -26,10 +27,34 @@ export async function startTerminal({ root = process.cwd(), port = 4310, interva
|
|
|
26
27
|
const headers = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'" };
|
|
27
28
|
const send = (code, body, type = 'application/json') => { response.writeHead(code, { ...headers, 'Content-Type': `${type}; charset=utf-8` }); response.end(body); };
|
|
28
29
|
if (!validHosts.includes(host) || (request.headers.origin && request.headers.origin !== `http://${host}`) || request.headers['sec-fetch-site'] === 'cross-site') return send(403, '{"error":"Local access only"}');
|
|
29
|
-
if (request.method !== 'GET') return send(405, '{"error":"Read-only terminal"}');
|
|
30
30
|
let path;
|
|
31
31
|
try { path = new URL(request.url, `http://${host}`).pathname; }
|
|
32
32
|
catch { return send(400, '{"error":"Invalid request URL"}'); }
|
|
33
|
+
if (path === '/api/analytics' && request.method === 'POST') {
|
|
34
|
+
if (!telemetryEnabled() || request.headers.dnt === '1' || request.headers['sec-gpc'] === '1' || request.headers['x-scout-analytics'] === 'off') return send(204, '');
|
|
35
|
+
if (request.headers['content-type'] !== 'application/json') return send(415, '{}');
|
|
36
|
+
if (Number(request.headers['content-length']) > 2048) return send(413, '{}');
|
|
37
|
+
let size = 0, body = '', done = false;
|
|
38
|
+
const timeout = setTimeout(() => { if (!done) { done = true; send(408, '{}'); request.destroy(); } }, 1000);
|
|
39
|
+
request.on('data', chunk => {
|
|
40
|
+
if (done) return;
|
|
41
|
+
size += chunk.length;
|
|
42
|
+
if (size > 2048) { done = true; clearTimeout(timeout); send(413, '{}'); request.resume(); return; }
|
|
43
|
+
body += chunk.toString('utf8');
|
|
44
|
+
});
|
|
45
|
+
request.on('error', () => { done = true; clearTimeout(timeout); });
|
|
46
|
+
request.on('end', () => {
|
|
47
|
+
clearTimeout(timeout); if (done) return; done = true;
|
|
48
|
+
try {
|
|
49
|
+
const value = JSON.parse(body), properties = terminalProperties(value.event, value.properties);
|
|
50
|
+
if (!properties) return send(400, '{}');
|
|
51
|
+
sendTerminalEvent(value.event, properties);
|
|
52
|
+
return send(204, '');
|
|
53
|
+
} catch { return send(400, '{}'); }
|
|
54
|
+
});
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (request.method !== 'GET') return send(405, '{"error":"Read-only terminal"}');
|
|
33
58
|
if (path === '/api/snapshot') {
|
|
34
59
|
const data=collect.current?.(snapshot)??snapshot;
|
|
35
60
|
const revision=new URL(request.url,`http://${host}`).searchParams.get('repositoryRevision');
|
|
@@ -42,7 +67,7 @@ export async function startTerminal({ root = process.cwd(), port = 4310, interva
|
|
|
42
67
|
return send(200,JSON.stringify(data));
|
|
43
68
|
}
|
|
44
69
|
if (path === '/api/sample') return send(200, JSON.stringify(sampleSnapshot()));
|
|
45
|
-
if (path === '/api/info') return send(200, JSON.stringify({ version: 1, defaultMode: demo ? 'sample' : 'live', interval }));
|
|
70
|
+
if (path === '/api/info') return send(200, JSON.stringify({ version: 1, defaultMode: demo ? 'sample' : 'live', interval, telemetry: telemetryEnabled() }));
|
|
46
71
|
const asset = assets.get(path);
|
|
47
72
|
if (!asset) return send(404, '{"error":"Not found"}');
|
|
48
73
|
try { return send(200, await readFile(fileURLToPath(new URL(`../public/${asset[0]}`, import.meta.url))), asset[1]); }
|