openmerit 0.1.8 → 0.1.10
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/dist/core/src/index.d.ts +7 -7
- package/dist/pi/src/index.js +4 -4
- package/dist/protocol/src/index.d.ts +15 -15
- package/dist/terminal/scout-update.json +1 -0
- package/dist/terminal/src/cli.mjs +2 -1
- package/dist/terminal/src/install.mjs +200 -57
- package/dist/terminal/src/telemetry.mjs +82 -10
- package/dist/terminal/src/updates.d.ts +62 -0
- package/dist/terminal/src/updates.js +395 -0
- package/dist/terminal/src/updates.ts +312 -0
- package/docs/inference-terminal.md +4 -0
- package/docs/scout-data-policy.md +10 -6
- package/docs/scout.md +71 -10
- package/docs/security.md +7 -1
- package/docs/testing.md +1 -1
- package/package.json +12 -6
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
|
|
2
2
|
import { dirname, join, resolve } from 'node:path';
|
|
3
3
|
import { homedir, hostname } from 'node:os';
|
|
4
|
-
import { createHash } from 'node:crypto';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
5
|
import { execFile } from 'node:child_process';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
7
|
import { openBrowser } from './browser.mjs';
|
|
8
|
+
import { clientVersion, setupHeaders, setupUUID, sendInstallationEvent, setupCorrelationEnabled, telemetryEnabled } from './telemetry.mjs';
|
|
9
|
+
import { configureUpdates, inspectUpdates, recoverUpdates, removeUpdates, runUpdate, withUpdateLock } from './updates.js';
|
|
8
10
|
|
|
9
11
|
// One-command coding-agent setup: device sign-in in the browser, then the MCP
|
|
10
12
|
// server, startup rule and Scout skill. Built to be run by a person or by a coding agent:
|
|
@@ -38,6 +40,12 @@ model. You may continue work that does not commit to a model.
|
|
|
38
40
|
Do not consult Scout for unrelated edits, normal inference calls using an unchanged
|
|
39
41
|
approved model, or this coding agent's own model. Do not reselect approved models
|
|
40
42
|
unless the task introduces or changes a model decision.
|
|
43
|
+
|
|
44
|
+
When the user says "update openmerit", run
|
|
45
|
+
\`npx -y openmerit@latest update --scope user\` (use \`--scope project\` if this
|
|
46
|
+
rule is in the project's .claude/rules directory). Report the result, including
|
|
47
|
+
any conflict or manual-upgrade requirement. Do not delete edited files, bypass
|
|
48
|
+
permissions or rerun login automatically. A new session loads updated instructions.
|
|
41
49
|
`;
|
|
42
50
|
|
|
43
51
|
export const skill = `---
|
|
@@ -101,16 +109,21 @@ expired or revoked, run \`npx -y openmerit@latest login\` and then
|
|
|
101
109
|
|
|
102
110
|
export const help = `OpenMerit · coding-agent setup
|
|
103
111
|
|
|
104
|
-
openmerit install [--client claude-code] [--scope user|project] [--plan] [--json] [--no-open]
|
|
112
|
+
openmerit install [--client claude-code] [--scope user|project] [--setup-id UUID] [--auto-update on|off] [--plan] [--json] [--no-open]
|
|
105
113
|
openmerit status [--client claude-code] [--scope user|project] [--check] [--json]
|
|
106
114
|
openmerit remove [--client claude-code] [--scope user|project] [--plan] [--json]
|
|
107
|
-
openmerit login [--json] [--no-open] Replace this device's key
|
|
115
|
+
openmerit login [--setup-id UUID] [--json] [--no-open] Replace this device's key
|
|
108
116
|
openmerit logout [--json] Revoke this device's key
|
|
109
117
|
openmerit project --policy existing-only|existing-first [--endpoint-id <id> ...] [--plan]
|
|
110
118
|
openmerit doctor [--json]
|
|
119
|
+
openmerit update [--scope user|project] [--auto on|off] [--json]
|
|
111
120
|
|
|
112
121
|
install signs this device in through the browser (one Approve click), adds the
|
|
113
122
|
OpenMerit MCP server, startup rule and Scout skill, then verifies the connection.
|
|
123
|
+
Automatic Scout instruction updates default on; --auto-update off opts out.
|
|
124
|
+
Background SessionStart/Stop hooks check npm at most daily (15-minute failure
|
|
125
|
+
retry), verify package integrity, and preserve edited files and credentials.
|
|
126
|
+
Start a new Claude Code session to reliably load updated instructions.
|
|
114
127
|
It opens the approval page and waits up to 9 minutes for the Approve click
|
|
115
128
|
(30 seconds when no browser could be opened). If it exits with approval
|
|
116
129
|
pending, rerun the same command: it resumes the same request.
|
|
@@ -144,12 +157,12 @@ async function save(path, value) {
|
|
|
144
157
|
await writeFile(path, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
145
158
|
}
|
|
146
159
|
|
|
147
|
-
async function request(path, { method = 'GET', body, key } = {}) {
|
|
160
|
+
async function request(path, { method = 'GET', body, key, telemetry } = {}) {
|
|
148
161
|
let response;
|
|
149
162
|
try {
|
|
150
163
|
response = await fetch(`${server()}${path}`, {
|
|
151
164
|
method, redirect: 'error', signal: AbortSignal.timeout(15_000),
|
|
152
|
-
headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...(key ? { Authorization: `Bearer ${key}` } : {}),
|
|
165
|
+
headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...(key ? { Authorization: `Bearer ${key}` } : {}), ...setupHeaders(telemetry?.properties) },
|
|
153
166
|
...(body ? { body: JSON.stringify(body) } : {}),
|
|
154
167
|
});
|
|
155
168
|
} catch { throw new Stop(`Could not reach ${server()}. Check your connection and retry.`, exitCodes.unreachable); }
|
|
@@ -177,7 +190,7 @@ function claudePaths(root, scope) {
|
|
|
177
190
|
};
|
|
178
191
|
}
|
|
179
192
|
const configuredServer = (config, root, scope) => scope === 'user' ? config.mcpServers?.openmerit : config.projects?.[root]?.mcpServers?.openmerit;
|
|
180
|
-
const serverConfig = key => ({ type: 'http', url: `${server()}/scout/mcp`, headers: { Authorization: `Bearer ${key}`,
|
|
193
|
+
const serverConfig = (key, telemetry) => ({ type: 'http', url: `${server()}/scout/mcp`, headers: { Authorization: `Bearer ${key}`, ...setupHeaders(telemetry.properties, 'agent') } });
|
|
181
194
|
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;
|
|
182
195
|
const serverHash = value => value ? hash(JSON.stringify(canonical(value))) : null;
|
|
183
196
|
|
|
@@ -195,14 +208,16 @@ async function claude(args, root, key) {
|
|
|
195
208
|
async function inspect(root, scope) {
|
|
196
209
|
const paths = claudePaths(root, scope);
|
|
197
210
|
const [record, config, installedSkill, installedRule, legacyRule] = await Promise.all([object(paths.record), object(paths.mcpFile), optional(paths.skill), optional(paths.rule), optional(paths.legacyRule)]);
|
|
211
|
+
const updates = await inspectUpdates(dirname(paths.record), scope, record);
|
|
198
212
|
const owned = [2, 3].includes(record.version) && record.scope === scope;
|
|
199
213
|
const current = configuredServer(config, root, scope);
|
|
200
214
|
const otherScope = configuredServer(config, root, scope === 'user' ? 'project' : 'user');
|
|
201
215
|
const projectFile = await object(join(root, '.mcp.json'));
|
|
202
216
|
return {
|
|
203
|
-
paths, record, owned, current, installedSkill, installedRule,
|
|
204
|
-
// A pending
|
|
205
|
-
|
|
217
|
+
paths, record, owned, current, installedSkill, installedRule, updates,
|
|
218
|
+
// A full pending hash covers interruption without accepting edited headers.
|
|
219
|
+
// Keep pendingAuth compatibility for records from the earlier installer.
|
|
220
|
+
serverOwned: !current || owned && (serverHash(current) === record.serverHash || serverHash(current) === record.pendingServerHash || !!record.pendingAuth && hash(current.headers?.Authorization ?? '') === record.pendingAuth),
|
|
206
221
|
skillOwned: installedSkill === null || owned && [record.skillHash, record.pendingSkill].includes(hash(installedSkill)),
|
|
207
222
|
ruleOwned: installedRule === null || owned && record.version === 3 && [record.ruleHash, record.pendingRule].includes(hash(installedRule)),
|
|
208
223
|
// The pre-release installer recorded its rule hash; only an unchanged rule is removed.
|
|
@@ -212,7 +227,7 @@ async function inspect(root, scope) {
|
|
|
212
227
|
}
|
|
213
228
|
|
|
214
229
|
function conflicts(state) {
|
|
215
|
-
const found = [];
|
|
230
|
+
const found = [...state.updates.conflicts];
|
|
216
231
|
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.');
|
|
217
232
|
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.`);
|
|
218
233
|
if (!state.ruleOwned) found.push(`${state.paths.rule} exists and was not created by this installer or was edited. Move or delete it, then retry.`);
|
|
@@ -220,6 +235,15 @@ function conflicts(state) {
|
|
|
220
235
|
return found;
|
|
221
236
|
}
|
|
222
237
|
|
|
238
|
+
function diagnostics(state) {
|
|
239
|
+
return {
|
|
240
|
+
installed: state.owned && !!state.current && state.serverOwned && state.installedSkill !== null && state.skillOwned && state.installedRule !== null && state.ruleOwned && !state.shadowed,
|
|
241
|
+
server_status: state.current ? state.serverOwned ? 'matches' : 'modified_or_unowned' : 'missing',
|
|
242
|
+
skill_status: state.installedSkill === null ? 'missing' : state.skillOwned ? 'matches' : 'modified_or_unowned',
|
|
243
|
+
rule_status: state.installedRule === null ? 'missing' : state.ruleOwned ? 'matches' : 'modified_or_unowned',
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
223
247
|
async function credentials() {
|
|
224
248
|
const saved = await object(credentialsPath());
|
|
225
249
|
return saved.version === 1 && saved.server === server() && typeof saved.key === 'string' ? saved : null;
|
|
@@ -227,33 +251,55 @@ async function credentials() {
|
|
|
227
251
|
|
|
228
252
|
// Device login. Starts or resumes a login, waits a bounded time, and stores the
|
|
229
253
|
// key only on disk. Never prints the key.
|
|
230
|
-
async function login({ client, scope, open, log, wait }) {
|
|
254
|
+
async function login({ client, scope, open, log, wait, telemetry }) {
|
|
255
|
+
await telemetry.step('login', 'started');
|
|
231
256
|
let pending = await object(pendingPath());
|
|
232
257
|
if (!(pending.server === server() && pending.expiresAt > Date.now() && typeof pending.deviceCode === 'string')) {
|
|
233
258
|
const deviceName = (process.env.OPENMERIT_DEVICE_NAME ?? hostname()).replace(/[^\x20-\x7e]/g, '').slice(0, 64) || 'this device';
|
|
234
|
-
const { status, data } = await request('/connect/api/device', { method: 'POST', body: { client, scope, device_name: deviceName } });
|
|
259
|
+
const { status, data } = await request('/connect/api/device', { method: 'POST', body: { client, scope, device_name: deviceName }, telemetry });
|
|
235
260
|
if (status !== 200 || !data.device_code) throw new Stop(data.error ?? `Could not start sign-in (HTTP ${status}).`, status === 429 ? exitCodes.unreachable : exitCodes.error);
|
|
236
|
-
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 };
|
|
261
|
+
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, setup_id: telemetry.properties.setup_id, installation_id: telemetry.properties.installation_id };
|
|
237
262
|
await save(pendingPath(), pending);
|
|
238
263
|
}
|
|
264
|
+
const approvalURL = new URL(pending.url);
|
|
265
|
+
// Rebuild consent on every resume, so old query parameters cannot override a
|
|
266
|
+
// later refusal. Only analytics-enabled, linked setup IDs reach the page.
|
|
267
|
+
for (const key of ['setup_id', 'analytics', 'analytics_account']) approvalURL.searchParams.delete(key);
|
|
268
|
+
if (setupCorrelationEnabled() && setupUUID(telemetry.properties.setup_id)) approvalURL.searchParams.set('setup_id', telemetry.properties.setup_id);
|
|
269
|
+
// Preserve explicit refusals across surfaces; unlinking must not disable usage.
|
|
270
|
+
if (!telemetryEnabled(true)) approvalURL.searchParams.set('analytics', 'off');
|
|
271
|
+
if (process.env.OPENMERIT_ANALYTICS_ACCOUNT === 'off' || !telemetryEnabled(true)) approvalURL.searchParams.set('analytics_account', 'off');
|
|
272
|
+
pending = { ...pending, url: approvalURL.href, setup_id: telemetry.properties.setup_id, installation_id: telemetry.properties.installation_id };
|
|
273
|
+
await save(pendingPath(), pending);
|
|
239
274
|
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`);
|
|
275
|
+
await telemetry.step('browser_open', 'started');
|
|
240
276
|
if (open && !pending.opened && !pending.openFailed) {
|
|
241
277
|
// Only the approval page on the configured OpenMerit server may be opened.
|
|
242
|
-
const approvalPage = url => {
|
|
278
|
+
const approvalPage = url => {
|
|
279
|
+
const u = new URL(url);
|
|
280
|
+
return u.origin === server() && u.pathname === '/connect' && !!u.searchParams.get('code')
|
|
281
|
+
&& [...u.searchParams.keys()].every(key => ['code', 'setup_id', 'analytics', 'analytics_account'].includes(key) && u.searchParams.getAll(key).length === 1)
|
|
282
|
+
&& (!u.searchParams.has('setup_id') || setupUUID(u.searchParams.get('setup_id')))
|
|
283
|
+
&& ['analytics', 'analytics_account'].every(key => !u.searchParams.has(key) || u.searchParams.get(key) === 'off')
|
|
284
|
+
&& !u.username && !u.password && !u.hash;
|
|
285
|
+
};
|
|
243
286
|
const opened = await openBrowser(pending.url, { allowed: approvalPage }).catch(() => false);
|
|
244
287
|
pending = { ...pending, ...(opened ? { opened: true } : { openFailed: true }) };
|
|
245
288
|
await save(pendingPath(), pending);
|
|
246
289
|
}
|
|
290
|
+
await telemetry.step('browser_open', pending.opened ? 'ok' : pending.openFailed ? 'error' : 'pending');
|
|
291
|
+
await telemetry.step('login', 'started');
|
|
247
292
|
// With the approval page open, keep waiting for the Approve click (inside a
|
|
248
293
|
// coding agent's 10-minute command limit) so nobody has to rerun anything.
|
|
249
294
|
// Without a browser, return soon so the agent can show the link.
|
|
250
295
|
const deadline = Date.now() + (wait ?? (pending.opened ? 540 : 30)) * 1000;
|
|
251
296
|
let interval = (pending.interval ?? 5) * 1000;
|
|
252
297
|
for (;;) {
|
|
253
|
-
const { status, data } = await request('/connect/api/token', { method: 'POST', body: { device_code: pending.deviceCode } });
|
|
298
|
+
const { status, data } = await request('/connect/api/token', { method: 'POST', body: { device_code: pending.deviceCode }, telemetry });
|
|
254
299
|
if (status === 200 && data.access_token) {
|
|
255
|
-
await save(credentialsPath(), { version: 1, server: server(), key: data.access_token, keyId: data.key_id, createdAt: new Date().toISOString() });
|
|
300
|
+
await save(credentialsPath(), { version: 1, server: server(), key: data.access_token, keyId: data.key_id, setup_id: telemetry.properties.setup_id, createdAt: new Date().toISOString() });
|
|
256
301
|
await rm(pendingPath(), { force: true });
|
|
302
|
+
await telemetry.step('login', 'ok');
|
|
257
303
|
return data.access_token;
|
|
258
304
|
}
|
|
259
305
|
if (data.error === 'slow_down') interval += 5000;
|
|
@@ -268,36 +314,49 @@ async function login({ client, scope, open, log, wait }) {
|
|
|
268
314
|
}
|
|
269
315
|
|
|
270
316
|
// A real MCP initialize and tool discovery with the stored key; no search.
|
|
271
|
-
async function verify(key) {
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
317
|
+
async function verify(key, telemetry) {
|
|
318
|
+
await telemetry.step('verification', 'started');
|
|
319
|
+
const call = (id, method, params = {}) => request('/scout/mcp', { method: 'POST', key, telemetry, body: { jsonrpc: '2.0', id, method, params } });
|
|
320
|
+
const init = await call(1, 'initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'openmerit-cli', version: clientVersion ?? 'unknown' } });
|
|
321
|
+
let connection = init.status === 401 ? 'key_rejected' : 'failed';
|
|
322
|
+
if (init.status === 200 && init.data.result) {
|
|
323
|
+
const tools = await call(2, 'tools/list');
|
|
324
|
+
connection = tools.status === 401 ? 'key_rejected' : tools.status === 200 && tools.data.result?.tools?.some(tool => tool.name === 'scout_models') ? 'connected' : 'failed';
|
|
325
|
+
}
|
|
326
|
+
await telemetry.step('verification', connection === 'connected' ? 'ok' : 'error', { connection, ...(connection === 'connected' ? {} : { code: connection === 'key_rejected' ? 'access_denied' : 'command_error' }) });
|
|
327
|
+
return connection;
|
|
278
328
|
}
|
|
279
329
|
|
|
280
|
-
async function install(root, { client, scope, plan, open, log, wait }) {
|
|
330
|
+
async function install(root, { client, scope, plan, open, log, wait, telemetry, autoUpdate, json }) {
|
|
281
331
|
const state = await inspect(root, scope);
|
|
332
|
+
const enabled = autoUpdate === undefined ? state.updates.saved?.enabled ?? true : autoUpdate === 'on';
|
|
282
333
|
const found = conflicts(state);
|
|
283
334
|
const changes = [
|
|
284
335
|
`${state.current ? 'Update' : 'Add'} MCP server "openmerit" in Claude Code (${state.paths.mcpScope} scope, ${state.paths.mcpFile})`,
|
|
285
336
|
`${state.installedSkill === null ? 'Create' : 'Update'} ${state.paths.skill}`,
|
|
286
337
|
`${state.installedRule === null ? 'Create' : 'Update'} ${state.paths.rule} (loaded at session startup)`,
|
|
338
|
+
`Automatic Scout instruction updates ${enabled ? 'on' : 'off'} (SessionStart/Stop background hooks; disable with openmerit update --auto off)`,
|
|
339
|
+
`Write the owned updater at ${state.updates.paths.runner}; merge its hooks in ${state.updates.paths.settings}`,
|
|
287
340
|
...(state.legacyRule === 'remove' ? [`Remove the unchanged earlier instructions at ${state.paths.legacyRule}`] : []),
|
|
288
341
|
...(await credentials() ? [] : [`Sign this device in through the browser and store its key in ${credentialsPath()} (mode 600)`]),
|
|
289
342
|
];
|
|
290
343
|
if (plan) return { action: 'install', applied: false, client, scope, changes, conflicts: found };
|
|
291
344
|
if (found.length) throw new Stop(found.join('\n'), exitCodes.conflict, 'Nothing was changed.');
|
|
345
|
+
if (!json) log(changes.find(change => change.startsWith('Automatic Scout')));
|
|
292
346
|
await claude(['--version'], root);
|
|
347
|
+
if (setupCorrelationEnabled()) telemetry.properties.installation_id ??= randomUUID();
|
|
348
|
+
await telemetry.step('preflight', 'ok');
|
|
293
349
|
let key = (await credentials())?.key;
|
|
294
350
|
// A revoked or expired key gets a fresh sign-in instead of a broken install.
|
|
295
|
-
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.'); }
|
|
296
|
-
key ??= await login({ client, scope, open, log, wait });
|
|
297
|
-
|
|
351
|
+
if (key && await verify(key, telemetry) === 'key_rejected') { await rm(credentialsPath(), { force: true }); key = undefined; log('The saved device key is no longer valid; signing in again.'); }
|
|
352
|
+
key ??= await login({ client, scope, open, log, wait, telemetry });
|
|
353
|
+
await telemetry.step('configuration', 'started');
|
|
354
|
+
const next = serverConfig(key, telemetry);
|
|
355
|
+
const ids = { setup_id: telemetry.properties.setup_id, installation_id: telemetry.properties.installation_id };
|
|
298
356
|
// Record intent first so an interrupted install is repaired by rerunning.
|
|
299
|
-
await save(state.paths.record, { version: 3, scope, client, serverHash: serverHash(state.current), skillHash: state.installedSkill === null ? null : hash(state.installedSkill), ruleHash: state.installedRule === null ? null : hash(state.installedRule),
|
|
300
|
-
|
|
357
|
+
await save(state.paths.record, { version: 3, scope, client, ...ids, updates: state.record.updates, pendingUpdates: state.record.pendingUpdates, serverHash: serverHash(state.current), skillHash: state.installedSkill === null ? null : hash(state.installedSkill), ruleHash: state.installedRule === null ? null : hash(state.installedRule), pendingServerHash: serverHash(next), pendingSkill: hash(skill), pendingRule: hash(rule) });
|
|
358
|
+
telemetry.properties.applied = true;
|
|
359
|
+
if (serverHash(state.current) !== serverHash(next)) {
|
|
301
360
|
if (state.current) await claude(['mcp', 'remove', '--scope', state.paths.mcpScope, 'openmerit'], root, key);
|
|
302
361
|
await claude(['mcp', 'add-json', '--scope', state.paths.mcpScope, 'openmerit', JSON.stringify(next)], root, key);
|
|
303
362
|
}
|
|
@@ -307,8 +366,11 @@ async function install(root, { client, scope, plan, open, log, wait }) {
|
|
|
307
366
|
// Record what Claude actually wrote, so later edits are detected precisely.
|
|
308
367
|
const written = configuredServer(await object(state.paths.mcpFile), root, scope);
|
|
309
368
|
if (!written) throw new Stop('Claude Code did not save the "openmerit" MCP server.', exitCodes.error, 'Run "openmerit doctor".');
|
|
310
|
-
await
|
|
311
|
-
|
|
369
|
+
const updates = await configureUpdates(dirname(state.paths.record), scope, state.record, clientVersion, enabled);
|
|
370
|
+
await save(state.paths.record, { version: 3, scope, client, ...ids, serverHash: serverHash(written), skillHash: hash(skill), ruleHash: hash(rule), updates, installedAt: new Date().toISOString() });
|
|
371
|
+
await telemetry.step('configuration', 'ok', diagnostics(await inspect(root, scope)));
|
|
372
|
+
const connection = await verify(key, telemetry);
|
|
373
|
+
if (connection !== 'connected') telemetry.outcome = 'error';
|
|
312
374
|
if (connection === 'key_rejected') throw new Stop('OpenMerit rejected this device key (expired or revoked).', exitCodes.denied, 'Run "openmerit login", then rerun install.');
|
|
313
375
|
return { action: 'install', applied: true, client, scope, changes, connection,
|
|
314
376
|
next: connection === 'connected'
|
|
@@ -316,37 +378,56 @@ async function install(root, { client, scope, plan, open, log, wait }) {
|
|
|
316
378
|
: 'Installed, but the connection check failed. Run "openmerit doctor".' };
|
|
317
379
|
}
|
|
318
380
|
|
|
319
|
-
async function status(root, { client, scope, check }) {
|
|
381
|
+
async function status(root, { client, scope, check, telemetry }) {
|
|
382
|
+
await telemetry.step('status', 'started');
|
|
320
383
|
const state = await inspect(root, scope), saved = await credentials();
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
384
|
+
const details = diagnostics(state), found = conflicts(state);
|
|
385
|
+
const connection = check && saved ? await verify(saved.key, telemetry) : 'not_checked';
|
|
386
|
+
telemetry.outcome = ['failed', 'key_rejected'].includes(connection) ? 'error' : found.length ? 'conflict' : details.installed ? 'ok' : 'not_installed';
|
|
387
|
+
await telemetry.step('status', telemetry.outcome, { ...details, connection });
|
|
388
|
+
return { action: 'status', client, scope, installed: details.installed, signedIn: !!saved,
|
|
389
|
+
server: details.server_status, skill: details.skill_status, rule: details.rule_status,
|
|
390
|
+
autoUpdates: state.updates.saved?.enabled ?? false, instructionVersion: state.updates.saved?.version ?? null,
|
|
391
|
+
updateHooks: state.updates.saved?.enabled ? state.updates.active && !state.updates.conflicts.length ? 'ready' : 'incomplete' : 'disabled',
|
|
392
|
+
conflicts: found, connection };
|
|
327
393
|
}
|
|
328
394
|
|
|
329
|
-
async function revokeKey() {
|
|
395
|
+
async function revokeKey(telemetry) {
|
|
330
396
|
const saved = await credentials();
|
|
331
397
|
if (!saved) return false;
|
|
332
|
-
|
|
398
|
+
await telemetry.step('remove', 'started');
|
|
399
|
+
const { status } = await request('/connect/api/keys/self', { method: 'DELETE', key: saved.key, telemetry }).catch(() => ({ status: 0 }));
|
|
333
400
|
await rm(credentialsPath(), { force: true });
|
|
401
|
+
if (status !== 200) telemetry.outcome = 'error';
|
|
402
|
+
await telemetry.step('remove', status === 200 ? 'ok' : 'error', status === 200 ? {} : {
|
|
403
|
+
code: status === 0 ? 'openmerit_unreachable' : status === 401 || status === 403 ? 'access_denied' : 'command_error',
|
|
404
|
+
});
|
|
334
405
|
return status === 200;
|
|
335
406
|
}
|
|
336
407
|
|
|
337
|
-
async function remove(root, { client, scope, plan }) {
|
|
408
|
+
async function remove(root, { client, scope, plan, telemetry }) {
|
|
409
|
+
await telemetry.step('remove', 'started');
|
|
338
410
|
const state = await inspect(root, scope);
|
|
339
|
-
if (!state.owned)
|
|
411
|
+
if (!state.owned) {
|
|
412
|
+
telemetry.outcome = 'not_installed';
|
|
413
|
+
await telemetry.step('remove', 'not_installed');
|
|
414
|
+
return { action: 'remove', applied: false, client, scope, changes: [], note: 'No OpenMerit installation by this installer in this scope.' };
|
|
415
|
+
}
|
|
340
416
|
const changes = [...(state.current && state.serverOwned ? [`Remove MCP server "openmerit" (${state.paths.mcpScope} scope)`] : []),
|
|
341
417
|
...(state.installedSkill !== null && state.skillOwned ? [`Delete ${state.paths.skill}`] : []),
|
|
342
|
-
...(state.installedRule !== null && state.ruleOwned ? [`Delete ${state.paths.rule}`] : []),
|
|
418
|
+
...(state.installedRule !== null && state.ruleOwned ? [`Delete ${state.paths.rule}`] : []),
|
|
419
|
+
...(state.updates.saved ? ['Remove unchanged owned update hooks and updater; retain edited hooks and their runner'] : []), 'Revoke and delete this device key'];
|
|
343
420
|
const kept = [...(state.serverOwned ? [] : ['the edited "openmerit" MCP server']), ...(state.skillOwned ? [] : [state.paths.skill]), ...(state.ruleOwned ? [] : [state.paths.rule]), policyPath];
|
|
421
|
+
kept.push(...state.updates.conflicts);
|
|
344
422
|
if (plan) return { action: 'remove', applied: false, client, scope, changes, keeps: kept };
|
|
423
|
+
telemetry.properties.applied = true;
|
|
345
424
|
if (state.current && state.serverOwned) await claude(['mcp', 'remove', '--scope', state.paths.mcpScope, 'openmerit'], root);
|
|
346
425
|
if (state.installedSkill !== null && state.skillOwned) await rm(dirname(state.paths.skill), { recursive: true, force: true });
|
|
347
426
|
if (state.installedRule !== null && state.ruleOwned) await rm(state.paths.rule, { force: true });
|
|
427
|
+
await removeUpdates(dirname(state.paths.record), scope, state.record);
|
|
348
428
|
await rm(state.paths.record, { force: true });
|
|
349
|
-
const revoked = await revokeKey();
|
|
429
|
+
const revoked = await revokeKey(telemetry);
|
|
430
|
+
await telemetry.step('remove', telemetry.outcome, diagnostics(await inspect(root, scope)));
|
|
350
431
|
return { action: 'remove', applied: true, client, scope, changes, keeps: kept, keyRevoked: revoked };
|
|
351
432
|
}
|
|
352
433
|
|
|
@@ -359,26 +440,31 @@ async function project(root, { policy, ids, plan }) {
|
|
|
359
440
|
return { action: 'project', applied: !plan, path: policyPath, policy: value, note: 'Endpoint IDs limit discovery only; they do not prove access or authorize spending.' };
|
|
360
441
|
}
|
|
361
442
|
|
|
362
|
-
async function doctor(root, { scope }) {
|
|
443
|
+
async function doctor(root, { scope, telemetry }) {
|
|
444
|
+
await telemetry.step('status', 'started');
|
|
363
445
|
const checks = [];
|
|
364
446
|
const add = (name, ok, detail) => checks.push({ name, ok, detail });
|
|
365
447
|
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
366
448
|
add('node', major > 22 || major === 22 && minor >= 19, `Node.js ${process.versions.node} (22.19+ required)`);
|
|
367
449
|
try { const { stdout } = await exec('claude', ['--version'], { timeout: 10_000 }); add('claude', true, stdout.trim().slice(0, 80)); }
|
|
368
450
|
catch { add('claude', false, 'Claude Code (claude) is not on PATH'); }
|
|
369
|
-
const reach = await request('/scout/api/status').catch(error => ({ status: 0, error }));
|
|
451
|
+
const reach = await request('/scout/api/status', { telemetry }).catch(error => ({ status: 0, error }));
|
|
370
452
|
add('openmerit', reach.status === 200, reach.status === 200 ? `${server()} reachable` : `${server()} unreachable`);
|
|
371
453
|
const saved = await credentials();
|
|
372
454
|
add('key', !!saved, saved ? `Device key ${saved.keyId ?? ''} stored in ${credentialsPath()}` : 'Not signed in. Run "openmerit install" or "openmerit login".');
|
|
373
|
-
if (saved && reach.status === 200) { const connection = await verify(saved.key); add('mcp', connection === 'connected', `MCP check: ${connection}`); }
|
|
455
|
+
if (saved && reach.status === 200) { const connection = await verify(saved.key, telemetry); add('mcp', connection === 'connected', `MCP check: ${connection}`); }
|
|
374
456
|
const state = await inspect(root, scope).catch(() => null);
|
|
375
457
|
if (state) add('install', state.owned && !!state.current && state.installedSkill !== null && state.installedRule !== null && !conflicts(state).length, state.owned ? conflicts(state).join(' ') || (state.current && state.installedSkill !== null && state.installedRule !== null ? `Installed (${scope} scope)` : 'Installation incomplete. Rerun "openmerit install".') : `Not installed in ${scope} scope`);
|
|
458
|
+
if (state?.updates.saved?.enabled) add('updater', state.updates.active && !state.updates.conflicts.length, state.updates.active && !state.updates.conflicts.length ? 'Automatic instruction hooks installed' : 'Automatic instruction hooks missing or edited. Rerun install after reviewing conflicts.');
|
|
459
|
+
telemetry.outcome = checks.every(check => check.ok) ? 'ok' : 'error';
|
|
460
|
+
await telemetry.step('status', telemetry.outcome, { ...(state ? diagnostics(state) : {}), ...(reach.status === 200 ? {} : { code: 'openmerit_unreachable', connection: 'failed' }) });
|
|
376
461
|
return { action: 'doctor', ok: checks.every(check => check.ok), checks };
|
|
377
462
|
}
|
|
378
463
|
|
|
379
464
|
function render(result) {
|
|
380
465
|
if (result.action === 'doctor') return result.checks.map(c => `${c.ok ? '✓' : '✗'} ${c.name}: ${c.detail}`).join('\n');
|
|
381
|
-
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} · rule: ${result.rule} · connection: ${result.connection}`, ...result.conflicts].join('\n');
|
|
466
|
+
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} · rule: ${result.rule} · connection: ${result.connection}`, `Automatic instruction updates: ${result.autoUpdates ? 'on' : 'off'} · hooks: ${result.updateHooks} · instruction version: ${result.instructionVersion ?? 'bootstrap required'}`, ...result.conflicts].join('\n');
|
|
467
|
+
if (result.action === 'update') return result.message;
|
|
382
468
|
if (result.action === 'project') return `${result.applied ? 'Saved' : 'Would save'} ${result.path}: ${result.policy.policy}, ${result.policy.endpointIds.length} endpoint ID(s). ${result.note}`;
|
|
383
469
|
if (result.action === 'login') return result.note;
|
|
384
470
|
if (result.action === 'logout') return result.note;
|
|
@@ -389,12 +475,39 @@ function render(result) {
|
|
|
389
475
|
return lines.join('\n');
|
|
390
476
|
}
|
|
391
477
|
|
|
392
|
-
export async function main(args,
|
|
478
|
+
export async function main(args, dependencies = {}) {
|
|
479
|
+
const telemetry = {
|
|
480
|
+
stage: 'started', outcome: 'ok',
|
|
481
|
+
properties: { command: ['install', 'status', 'remove', 'login', 'logout', 'project', 'doctor'].includes(args[0]) ? args[0] : 'unknown', agent: 'claude-code', scope: 'user', applied: false, installed: false, connection: 'not_checked' },
|
|
482
|
+
async step(stage, outcome, properties = {}) {
|
|
483
|
+
this.stage = stage;
|
|
484
|
+
if (outcome === 'ok') delete this.properties.code;
|
|
485
|
+
Object.assign(this.properties, properties);
|
|
486
|
+
if (args[0] !== 'update') await sendInstallationEvent({ ...this.properties, stage, outcome });
|
|
487
|
+
},
|
|
488
|
+
async fail(error) {
|
|
489
|
+
this.outcome = ({ 10: 'pending', 11: 'denied', 20: 'conflict' })[error.exit] ?? 'error';
|
|
490
|
+
const code = ({ 10: 'approval_pending', 11: 'access_denied', 20: 'install_conflict', 30: 'openmerit_unreachable' })[error.exit] ?? 'command_error';
|
|
491
|
+
await this.step(this.stage, this.outcome, { code, ...(this.stage === 'verification' ? { connection: error.exit === exitCodes.denied ? 'key_rejected' : 'failed' } : {}) });
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
try {
|
|
495
|
+
const code = await runMain(args, dependencies, telemetry);
|
|
496
|
+
await telemetry.step('completed', telemetry.outcome);
|
|
497
|
+
return code;
|
|
498
|
+
} catch (error) {
|
|
499
|
+
await telemetry.fail(error);
|
|
500
|
+
await telemetry.step('completed', telemetry.outcome);
|
|
501
|
+
throw error;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function runMain(args, { root = process.cwd(), print = console.log, printError = console.error }, telemetry) {
|
|
393
506
|
const [action, ...rest] = args;
|
|
394
507
|
if (!action || rest.includes('--help') || rest.includes('-h')) { print(help); return exitCodes.ok; }
|
|
395
|
-
const options = { client: undefined, scope: 'user', plan: false, check: false, json: false, open: true, policy: undefined, ids: [] };
|
|
396
|
-
const allowed = { install: ['--client', '--scope', '--plan', '--json', '--no-open'], status: ['--client', '--scope', '--check', '--json'], remove: ['--client', '--scope', '--plan', '--json'],
|
|
397
|
-
login: ['--json', '--no-open'], logout: ['--json'], project: ['--policy', '--endpoint-id', '--plan', '--json'], doctor: ['--scope', '--json'] }[action];
|
|
508
|
+
const options = { client: undefined, scope: 'user', plan: false, check: false, json: false, open: true, policy: undefined, ids: [], telemetry };
|
|
509
|
+
const allowed = { install: ['--client', '--scope', '--setup-id', '--plan', '--json', '--no-open', '--auto-update'], status: ['--client', '--scope', '--check', '--json'], remove: ['--client', '--scope', '--plan', '--json'],
|
|
510
|
+
login: ['--setup-id', '--json', '--no-open'], logout: ['--json'], project: ['--policy', '--endpoint-id', '--plan', '--json'], doctor: ['--scope', '--json'], update: ['--scope', '--auto', '--json'] }[action];
|
|
398
511
|
if (!allowed) throw new Error('Unknown command. Run openmerit --help.');
|
|
399
512
|
for (let i = 0; i < rest.length; i++) {
|
|
400
513
|
const option = rest[i];
|
|
@@ -402,8 +515,10 @@ export async function main(args, { root = process.cwd(), print = console.log, pr
|
|
|
402
515
|
const value = () => { const v = rest[++i]; if (v === undefined || v.startsWith('--')) throw new Error(`${option} needs a value.`); return v; };
|
|
403
516
|
if (option === '--client') options.client = value();
|
|
404
517
|
else if (option === '--scope') options.scope = value();
|
|
518
|
+
else if (option === '--setup-id') { options.setup_id = value(); if (!setupUUID(options.setup_id)) throw new Error('--setup-id must be a UUID v4.'); }
|
|
405
519
|
else if (option === '--policy') options.policy = value();
|
|
406
520
|
else if (option === '--endpoint-id') options.ids.push(value());
|
|
521
|
+
else if (option === '--auto-update' || option === '--auto') { const setting = value(); if (!['on', 'off'].includes(setting)) throw new Error(`${option} needs on or off.`); options.autoUpdate = setting; }
|
|
407
522
|
else if (option === '--no-open') options.open = false;
|
|
408
523
|
else options[option.slice(2)] = true;
|
|
409
524
|
}
|
|
@@ -418,17 +533,45 @@ export async function main(args, { root = process.cwd(), print = console.log, pr
|
|
|
418
533
|
const wait = process.env.OPENMERIT_LOGIN_WAIT === undefined ? undefined : Number(process.env.OPENMERIT_LOGIN_WAIT);
|
|
419
534
|
const log = text => (options.json ? printError : print)(text);
|
|
420
535
|
try {
|
|
421
|
-
|
|
536
|
+
telemetry.properties.scope = options.scope;
|
|
537
|
+
// Hydrate correlation without creating identities or writing anything. A
|
|
538
|
+
// pending approval owns its setup ID even when the resumed command omits it.
|
|
539
|
+
if (['install', 'status', 'remove', 'doctor'].includes(action)) {
|
|
540
|
+
telemetry.stage = 'preflight';
|
|
541
|
+
const state = await inspect(root, options.scope);
|
|
542
|
+
Object.assign(telemetry.properties, diagnostics(state));
|
|
543
|
+
if (state.owned) for (const key of ['setup_id', 'installation_id']) if (setupUUID(state.record[key])) telemetry.properties[key] = state.record[key];
|
|
544
|
+
}
|
|
545
|
+
if (options.setup_id) telemetry.properties.setup_id = options.setup_id;
|
|
546
|
+
if (['install', 'login'].includes(action)) {
|
|
547
|
+
const saved = await credentials();
|
|
548
|
+
if (!telemetry.properties.setup_id && setupUUID(saved?.setup_id)) telemetry.properties.setup_id = saved.setup_id;
|
|
549
|
+
const pending = await object(pendingPath());
|
|
550
|
+
if (pending.server === server() && pending.expiresAt > Date.now() && typeof pending.deviceCode === 'string') {
|
|
551
|
+
if (setupUUID(pending.setup_id)) telemetry.properties.setup_id = pending.setup_id;
|
|
552
|
+
if (!telemetry.properties.installation_id && setupUUID(pending.installation_id)) telemetry.properties.installation_id = pending.installation_id;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
await telemetry.step('started', 'started');
|
|
556
|
+
await telemetry.step('preflight', 'started');
|
|
557
|
+
const base = dirname(claudePaths(root, options.scope).record);
|
|
558
|
+
const maintenance = work => options.plan ? work() : withUpdateLock(base, async () => { await recoverUpdates(base, options.scope); return work(); });
|
|
559
|
+
const result = action === 'install' ? await maintenance(() => install(root, { ...options, log, wait }))
|
|
422
560
|
: action === 'status' ? await status(root, options)
|
|
423
|
-
: action === 'remove' ? await remove(root, options)
|
|
561
|
+
: action === 'remove' ? await maintenance(() => remove(root, options))
|
|
562
|
+
: action === 'update' ? { action, ...await runUpdate(base, options.scope, options.autoUpdate === undefined ? {} : { enabled: options.autoUpdate === 'on' }) }
|
|
424
563
|
: action === 'project' ? await project(root, options)
|
|
425
564
|
: action === 'doctor' ? await doctor(root, options)
|
|
426
|
-
: 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.' }
|
|
427
|
-
: (await revokeKey(), await
|
|
428
|
-
|
|
565
|
+
: action === 'logout' ? { action, revoked: await revokeKey(telemetry), note: 'Signed out. This device key was revoked and deleted. Agents configured with it stop working until you run install again.' }
|
|
566
|
+
: (await revokeKey(telemetry), await login({ client: 'claude-code', scope: 'user', open: options.open, log, wait, telemetry }), { action, signedIn: true, note: 'Signed in. Run "openmerit install" again to give your coding agents the new key.' });
|
|
567
|
+
if (result.conflicts?.length && telemetry.outcome !== 'error') { telemetry.outcome = 'conflict'; telemetry.properties.code = 'install_conflict'; }
|
|
568
|
+
if (options.plan) await telemetry.step(telemetry.stage, telemetry.outcome, { applied: false });
|
|
569
|
+
print(options.json ? JSON.stringify({ ok: action !== 'update' || !['conflict', 'retry', 'manual_required'].includes(result.status), ...result }, null, 2) : render(result));
|
|
570
|
+
if (action === 'update') return result.status === 'conflict' ? exitCodes.conflict : result.status === 'retry' ? exitCodes.unreachable : result.status === 'manual_required' ? exitCodes.error : exitCodes.ok;
|
|
429
571
|
return action === 'doctor' && !result.ok ? exitCodes.error : exitCodes.ok;
|
|
430
572
|
} catch (error) {
|
|
431
573
|
if (!(error instanceof Stop)) throw error;
|
|
574
|
+
await telemetry.fail(error);
|
|
432
575
|
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));
|
|
433
576
|
else printError([error.message, error.next].filter(Boolean).join('\n'));
|
|
434
577
|
return error.exit;
|