openmerit 0.1.11 → 0.2.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 +15 -0
- package/README.md +20 -56
- package/dist/pi/scout-extension.mjs +190 -0
- package/dist/pi/src/index.d.ts +6 -32
- package/dist/pi/src/index.js +49 -787
- package/dist/terminal/public/app.js +37 -37
- package/dist/terminal/public/styles.css +0 -125
- package/dist/terminal/scout-update.json +1 -1
- package/dist/terminal/src/cli.mjs +2 -0
- package/dist/terminal/src/install.mjs +103 -166
- package/dist/terminal/src/scout-credentials.d.ts +12 -0
- package/dist/terminal/src/scout-credentials.js +41 -0
- package/dist/terminal/src/scout-credentials.ts +44 -0
- package/dist/terminal/src/scout-instructions.d.ts +5 -0
- package/dist/terminal/src/scout-instructions.js +116 -0
- package/dist/terminal/src/scout-instructions.ts +119 -0
- package/dist/terminal/src/scout-pi.d.ts +44 -0
- package/dist/terminal/src/scout-pi.js +84 -0
- package/dist/terminal/src/scout-pi.ts +76 -0
- package/dist/terminal/src/scout.d.ts +77 -0
- package/dist/terminal/src/scout.js +181 -0
- package/dist/terminal/src/scout.ts +164 -0
- package/dist/terminal/src/server.mjs +1 -1
- package/dist/terminal/src/telemetry.mjs +2 -2
- package/docs/commands.md +3 -77
- package/docs/getting-started.md +20 -45
- package/docs/index.md +9 -14
- package/docs/inference-terminal.md +2 -63
- package/docs/pi-extension.md +32 -28
- package/docs/roadmap.md +6 -18
- package/docs/scout.md +39 -255
- package/docs/security.md +8 -14
- package/docs/testing.md +8 -34
- package/docs/troubleshooting.md +23 -28
- package/package.json +6 -27
- package/dist/core/src/index.d.ts +0 -101
- package/dist/core/src/index.js +0 -1649
- package/dist/core/src/store.d.ts +0 -35
- package/dist/core/src/store.js +0 -102
- package/dist/pi/src/scheduler.d.ts +0 -11
- package/dist/pi/src/scheduler.js +0 -137
- package/dist/pi/src/wakeup.d.ts +0 -2
- package/dist/pi/src/wakeup.js +0 -108
- package/dist/protocol/src/index.d.ts +0 -484
- package/dist/protocol/src/index.js +0 -47
- package/dist/protocol/src/schemas.d.ts +0 -576
- package/dist/protocol/src/schemas.js +0 -280
- package/dist/terminal/public/brands/vllm.ico +0 -0
- package/dist/terminal/public/flow.css +0 -1
- package/dist/terminal/public/flow.js +0 -770
- package/dist/terminal/src/demo-policy.mjs +0 -205
- package/dist/terminal/src/enterprise-sample.mjs +0 -151
- package/docs/adapter-guide.md +0 -189
- package/docs/architecture.md +0 -59
- package/docs/automation.md +0 -74
- package/docs/budgets.md +0 -37
- package/docs/demo-backfill.md +0 -31
- package/docs/demo-fieldkit.md +0 -49
- package/docs/demo-placement.md +0 -32
- package/docs/demo-spam.md +0 -17
- package/docs/demo-support.md +0 -44
- package/docs/demo.md +0 -74
- package/docs/first-trial.md +0 -60
- package/docs/interactive-demo.md +0 -118
- package/docs/lifecycle.md +0 -30
- package/docs/metrics-and-evidence.md +0 -48
- package/docs/operations.md +0 -38
- package/docs/pareto-spec.md +0 -76
- package/docs/saga/a-steadier-compass.md +0 -25
- package/docs/saga/defining-a-task-for-ai.md +0 -154
- package/docs/saga/the-benchmark-is-not-the-job.md +0 -33
- package/docs/ux-reference.md +0 -32
|
@@ -7,6 +7,10 @@ import { promisify } from 'node:util';
|
|
|
7
7
|
import { openBrowser } from './browser.mjs';
|
|
8
8
|
import { clientVersion, setupHeaders, setupUUID, sendInstallationEvent, setupCorrelationEnabled, telemetryEnabled } from './telemetry.mjs';
|
|
9
9
|
import { configureUpdates, inspectUpdates, recoverUpdates, removeUpdates, runUpdate, withUpdateLock } from './updates.js';
|
|
10
|
+
import { credentialsPath as deviceCredentialsPath, pendingPath as devicePendingPath } from './scout-credentials.js';
|
|
11
|
+
import { rule, skill } from './scout-instructions.js';
|
|
12
|
+
import { piPaths, inspectPi, configurePi, removePi, piConflicts, piVersion } from './scout-pi.js';
|
|
13
|
+
export { rule, skill } from './scout-instructions.js';
|
|
10
14
|
|
|
11
15
|
// One-command coding-agent setup: device sign-in in the browser, then the MCP
|
|
12
16
|
// server, startup rule and Scout skill. Built to be run by a person or by a coding agent:
|
|
@@ -15,115 +19,27 @@ export const exitCodes = { ok: 0, error: 1, pending: 10, denied: 11, conflict: 2
|
|
|
15
19
|
const exec = promisify(execFile);
|
|
16
20
|
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
17
21
|
const policyPath = '.openmerit/scout.json';
|
|
18
|
-
const clients = { 'claude-code': 'Claude Code' };
|
|
19
|
-
|
|
20
|
-
// No paths frontmatter: Claude loads this before the first model decision,
|
|
21
|
-
// including user-scope sessions in Desktop's automatically created folders.
|
|
22
|
-
export const rule = `# Application model decisions · OpenMerit Scout
|
|
23
|
-
|
|
24
|
-
Before choosing, recommending, adding, downloading, replacing or configuring
|
|
25
|
-
ANY application inference model, load the \`openmerit-scout\` skill and consult
|
|
26
|
-
\`scout_models\` on the \`openmerit\` MCP server before committing to a model.
|
|
27
|
-
This applies even when the user only asks you to build a feature, never mentions
|
|
28
|
-
models or Scout, or names a model themselves (assess it; do not silently replace it).
|
|
29
|
-
|
|
30
|
-
A pretrained model, local weights, browser inference, an SDK's default model,
|
|
31
|
-
embeddings, OCR, speech, image classification, detection and segmentation all
|
|
32
|
-
count as model choices, not implementation details. Identify this decision before
|
|
33
|
-
writing model-specific code, installing a model package or downloading weights.
|
|
34
|
-
|
|
35
|
-
Follow the skill's project access policy. If it is missing, ask and save it first.
|
|
36
|
-
If Scout is unavailable, fails or has no suitable evidence/candidate, disclose the
|
|
37
|
-
gap and pause the model decision; do not fall back to a remembered or SDK-default
|
|
38
|
-
model. You may continue work that does not commit to a model.
|
|
39
|
-
|
|
40
|
-
Do not consult Scout for unrelated edits, normal inference calls using an unchanged
|
|
41
|
-
approved model, or this coding agent's own model. Do not reselect approved models
|
|
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.
|
|
49
|
-
`;
|
|
50
|
-
|
|
51
|
-
export const skill = `---
|
|
52
|
-
name: openmerit-scout
|
|
53
|
-
description: Consults OpenMerit Scout before choosing, adding, downloading, replacing or configuring any application inference model, including implicit choices while building features, pretrained browser/local models, SDK defaults, embeddings, OCR, speech, classification, detection, segmentation, LLMs and agents. Not for unrelated edits, ordinary inference calls or this coding agent's own model.
|
|
54
|
-
---
|
|
55
|
-
|
|
56
|
-
# OpenMerit · Scout
|
|
57
|
-
|
|
58
|
-
Before introducing, recommending, replacing or configuring an application
|
|
59
|
-
inference model, call the \`scout_models\` tool on the \`openmerit\` MCP server.
|
|
60
|
-
This applies even when the user never mentions models or Scout. Downloading local
|
|
61
|
-
weights, using a pretrained browser model or accepting an SDK default is a model
|
|
62
|
-
decision: consult Scout before model-specific code, installs or downloads.
|
|
63
|
-
Preserve the user's explicit model choice; assess it rather than silently replacing
|
|
64
|
-
it. Leave unchanged, approved application selections in place.
|
|
65
|
-
|
|
66
|
-
## Project policy
|
|
67
|
-
|
|
68
|
-
Read \`${policyPath}\` before searching. If it is absent, ask once whether this
|
|
69
|
-
project uses **existing-only** (only endpoints it can already reach) or
|
|
70
|
-
**existing-first** (prefer them, ask before searching the whole catalogue), and
|
|
71
|
-
which public catalogue endpoint IDs it can access. Save the answer:
|
|
72
|
-
|
|
73
|
-
\`\`\`sh
|
|
74
|
-
npx -y openmerit@latest project --policy existing-only --endpoint-id <id> ...
|
|
75
|
-
\`\`\`
|
|
76
|
-
|
|
77
|
-
Never infer access from a model name, SDK or environment-variable name. Never
|
|
78
|
-
record credentials, private URLs, prompts or customer data. An empty inventory
|
|
79
|
-
is not permission to search all models.
|
|
80
|
-
|
|
81
|
-
## Searching
|
|
82
|
-
|
|
83
|
-
Call \`scout_models\` with a non-confidential task description and
|
|
84
|
-
\`endpointIds\` set to the saved inventory, including \`[]\` when empty.
|
|
85
|
-
Existing-only never broadens. For existing-first, if no existing candidate fits,
|
|
86
|
-
explain the gap and ask before a separate search without \`endpointIds\`. Send
|
|
87
|
-
only task requirements, never source files, secrets or real customer records.
|
|
88
|
-
|
|
89
|
-
Compare model AND endpoint, evidence, reference cost/time, unknowns and
|
|
90
|
-
warnings. For multiple tasks read \`tasks[].slots[0].recommendations\`; the
|
|
91
|
-
top-level list is empty. Prefer the cheapest usable option the quality evidence
|
|
92
|
-
supports, without claiming benchmarks prove application accuracy. Use the
|
|
93
|
-
returned provider model ID and the project's actual SDK and protocol.
|
|
94
|
-
|
|
95
|
-
## Boundaries
|
|
96
|
-
|
|
97
|
-
Ask before adding providers or credentials, spending, deploying or external
|
|
98
|
-
writes. Report recommendation, endpoint connectivity and application checks as
|
|
99
|
-
separate results, each only after it succeeds. Do not switch this coding agent's
|
|
100
|
-
own model.
|
|
101
|
-
|
|
102
|
-
If Scout fails or has no suitable candidate/evidence for the required deployment,
|
|
103
|
-
say so and pause the model decision; do not substitute a remembered recommendation
|
|
104
|
-
or SDK default. Continue only model-independent work. Local/browser models are not
|
|
105
|
-
exempt when the catalogue lacks coverage. If it reports that the device key is missing,
|
|
106
|
-
expired or revoked, run \`npx -y openmerit@latest login\` and then
|
|
107
|
-
\`npx -y openmerit@latest install\` again. Never ask the user to paste a key.
|
|
108
|
-
`;
|
|
22
|
+
const clients = { 'claude-code': 'Claude Code', pi: 'Pi' };
|
|
109
23
|
|
|
110
24
|
export const help = `OpenMerit · coding-agent setup
|
|
111
25
|
|
|
112
|
-
openmerit install [--client claude-code] [--scope user|project] [--setup-id UUID] [--auto-update on|off] [--plan] [--json] [--no-open]
|
|
113
|
-
openmerit status [--client claude-code] [--scope user|project] [--check] [--json]
|
|
114
|
-
openmerit remove [--client claude-code] [--scope user|project] [--plan] [--json]
|
|
115
|
-
openmerit login [--setup-id UUID] [--json] [--no-open]
|
|
116
|
-
openmerit logout [--
|
|
26
|
+
openmerit install [--client claude-code|pi] [--scope user|project] [--setup-id UUID] [--auto-update on|off] [--plan] [--json] [--no-open]
|
|
27
|
+
openmerit status [--client claude-code|pi] [--scope user|project] [--check] [--json]
|
|
28
|
+
openmerit remove [--client claude-code|pi] [--scope user|project] [--plan] [--json]
|
|
29
|
+
openmerit login [--client claude-code|pi] [--scope user|project] [--setup-id UUID] [--json] [--no-open]
|
|
30
|
+
openmerit logout [--client claude-code|pi] [--scope user|project] [--json]
|
|
117
31
|
openmerit project --policy existing-only|existing-first [--endpoint-id <id> ...] [--plan]
|
|
118
|
-
openmerit doctor [--json]
|
|
119
|
-
openmerit update [--scope user|project] [--auto on|off] [--json]
|
|
32
|
+
openmerit doctor [--client claude-code|pi] [--scope user|project] [--json]
|
|
33
|
+
openmerit update [--client claude-code|pi] [--scope user|project] [--auto on|off] [--json]
|
|
34
|
+
openmerit scout --help Ask Scout from the terminal
|
|
120
35
|
|
|
121
36
|
install signs this device in through the browser (one Approve click), adds the
|
|
122
37
|
OpenMerit MCP server, startup rule and Scout skill, then verifies the connection.
|
|
123
38
|
Automatic Scout instruction updates default on; --auto-update off opts out.
|
|
124
39
|
Background SessionStart/Stop hooks check npm at most daily (15-minute failure
|
|
125
40
|
retry), verify package integrity, and preserve edited files and credentials.
|
|
126
|
-
|
|
41
|
+
Pi uses its native MCP extension; run install again to update its managed adapter.
|
|
42
|
+
Pi does not install Claude hooks or a background updater. Start a new session after setup.
|
|
127
43
|
It opens the approval page and waits up to 9 minutes for the Approve click
|
|
128
44
|
(30 seconds when no browser could be opened). If it exits with approval
|
|
129
45
|
pending, rerun the same command: it resumes the same request.
|
|
@@ -137,9 +53,8 @@ Exit codes: 0 done, 10 approval pending, 11 denied or expired, 20 conflict,
|
|
|
137
53
|
class Stop extends Error { constructor(message, code, next) { super(message); this.exit = code; this.next = next; } }
|
|
138
54
|
|
|
139
55
|
const server = () => (process.env.OPENMERIT_URL ?? 'https://openmerit.ai').replace(/\/+$/, '');
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
const pendingPath = () => join(configDir(), 'login.json');
|
|
56
|
+
const credentialsPath = telemetry => deviceCredentialsPath(telemetry.properties.agent, telemetry.properties.scope, telemetry.root);
|
|
57
|
+
const pendingPath = telemetry => devicePendingPath(telemetry.properties.agent, telemetry.properties.scope, telemetry.root);
|
|
143
58
|
|
|
144
59
|
async function optional(path) {
|
|
145
60
|
try { return await readFile(path, 'utf8'); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
@@ -172,6 +87,7 @@ async function request(path, { method = 'GET', body, key, telemetry } = {}) {
|
|
|
172
87
|
|
|
173
88
|
function detectClient() {
|
|
174
89
|
if (process.env.CLAUDECODE === '1' || process.env.CLAUDE_CODE_ENTRYPOINT) return 'claude-code';
|
|
90
|
+
if (process.env.PI_CODING_AGENT_DIR) return 'pi';
|
|
175
91
|
return null;
|
|
176
92
|
}
|
|
177
93
|
|
|
@@ -205,7 +121,8 @@ async function claude(args, root, key) {
|
|
|
205
121
|
}
|
|
206
122
|
|
|
207
123
|
// Inspect the current Claude Code installation without changing anything.
|
|
208
|
-
async function inspect(root, scope) {
|
|
124
|
+
async function inspect(root, scope, client = 'claude-code') {
|
|
125
|
+
if (client === 'pi') return { ...await inspectPi(root, scope), client };
|
|
209
126
|
const paths = claudePaths(root, scope);
|
|
210
127
|
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
128
|
const updates = await inspectUpdates(dirname(paths.record), scope, record);
|
|
@@ -214,7 +131,7 @@ async function inspect(root, scope) {
|
|
|
214
131
|
const otherScope = configuredServer(config, root, scope === 'user' ? 'project' : 'user');
|
|
215
132
|
const projectFile = await object(join(root, '.mcp.json'));
|
|
216
133
|
return {
|
|
217
|
-
paths, record, owned, current, installedSkill, installedRule, updates,
|
|
134
|
+
paths, record, owned, current, installedSkill, installedRule, updates, client,
|
|
218
135
|
// A full pending hash covers interruption without accepting edited headers.
|
|
219
136
|
// Keep pendingAuth compatibility for records from the earlier installer.
|
|
220
137
|
serverOwned: !current || owned && (serverHash(current) === record.serverHash || serverHash(current) === record.pendingServerHash || !!record.pendingAuth && hash(current.headers?.Authorization ?? '') === record.pendingAuth),
|
|
@@ -227,6 +144,7 @@ async function inspect(root, scope) {
|
|
|
227
144
|
}
|
|
228
145
|
|
|
229
146
|
function conflicts(state) {
|
|
147
|
+
if (state.client === 'pi') return piConflicts(state);
|
|
230
148
|
const found = [...state.updates.conflicts];
|
|
231
149
|
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.');
|
|
232
150
|
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.`);
|
|
@@ -244,8 +162,8 @@ function diagnostics(state) {
|
|
|
244
162
|
};
|
|
245
163
|
}
|
|
246
164
|
|
|
247
|
-
async function credentials() {
|
|
248
|
-
const saved = await object(credentialsPath());
|
|
165
|
+
async function credentials(telemetry) {
|
|
166
|
+
const saved = await object(credentialsPath(telemetry));
|
|
249
167
|
return saved.version === 1 && saved.server === server() && typeof saved.key === 'string' ? saved : null;
|
|
250
168
|
}
|
|
251
169
|
|
|
@@ -253,13 +171,13 @@ async function credentials() {
|
|
|
253
171
|
// key only on disk. Never prints the key.
|
|
254
172
|
async function login({ client, scope, open, log, wait, telemetry }) {
|
|
255
173
|
await telemetry.step('login', 'started');
|
|
256
|
-
let pending = await object(pendingPath());
|
|
174
|
+
let pending = await object(pendingPath(telemetry));
|
|
257
175
|
if (!(pending.server === server() && pending.expiresAt > Date.now() && typeof pending.deviceCode === 'string')) {
|
|
258
176
|
const deviceName = (process.env.OPENMERIT_DEVICE_NAME ?? hostname()).replace(/[^\x20-\x7e]/g, '').slice(0, 64) || 'this device';
|
|
259
177
|
const { status, data } = await request('/connect/api/device', { method: 'POST', body: { client, scope, device_name: deviceName }, telemetry });
|
|
260
178
|
if (status !== 200 || !data.device_code) throw new Stop(data.error ?? `Could not start sign-in (HTTP ${status}).`, status === 429 ? exitCodes.unreachable : exitCodes.error);
|
|
261
179
|
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 };
|
|
262
|
-
await save(pendingPath(), pending);
|
|
180
|
+
await save(pendingPath(telemetry), pending);
|
|
263
181
|
}
|
|
264
182
|
const approvalURL = new URL(pending.url);
|
|
265
183
|
// Rebuild consent on every resume, so old query parameters cannot override a
|
|
@@ -270,7 +188,7 @@ async function login({ client, scope, open, log, wait, telemetry }) {
|
|
|
270
188
|
if (!telemetryEnabled(true)) approvalURL.searchParams.set('analytics', 'off');
|
|
271
189
|
if (process.env.OPENMERIT_ANALYTICS_ACCOUNT === 'off' || !telemetryEnabled(true)) approvalURL.searchParams.set('analytics_account', 'off');
|
|
272
190
|
pending = { ...pending, url: approvalURL.href, setup_id: telemetry.properties.setup_id, installation_id: telemetry.properties.installation_id };
|
|
273
|
-
await save(pendingPath(), pending);
|
|
191
|
+
await save(pendingPath(telemetry), pending);
|
|
274
192
|
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
193
|
await telemetry.step('browser_open', 'started');
|
|
276
194
|
if (open && !pending.opened && !pending.openFailed) {
|
|
@@ -285,7 +203,7 @@ async function login({ client, scope, open, log, wait, telemetry }) {
|
|
|
285
203
|
};
|
|
286
204
|
const opened = await openBrowser(pending.url, { allowed: approvalPage }).catch(() => false);
|
|
287
205
|
pending = { ...pending, ...(opened ? { opened: true } : { openFailed: true }) };
|
|
288
|
-
await save(pendingPath(), pending);
|
|
206
|
+
await save(pendingPath(telemetry), pending);
|
|
289
207
|
}
|
|
290
208
|
await telemetry.step('browser_open', pending.opened ? 'ok' : pending.openFailed ? 'error' : 'pending');
|
|
291
209
|
await telemetry.step('login', 'started');
|
|
@@ -297,14 +215,14 @@ async function login({ client, scope, open, log, wait, telemetry }) {
|
|
|
297
215
|
for (;;) {
|
|
298
216
|
const { status, data } = await request('/connect/api/token', { method: 'POST', body: { device_code: pending.deviceCode }, telemetry });
|
|
299
217
|
if (status === 200 && data.access_token) {
|
|
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() });
|
|
301
|
-
await rm(pendingPath(), { force: true });
|
|
218
|
+
await save(credentialsPath(telemetry), { version: 1, server: server(), key: data.access_token, keyId: data.key_id, setup_id: telemetry.properties.setup_id, headers: setupHeaders(telemetry.properties, 'agent'), createdAt: new Date().toISOString() });
|
|
219
|
+
await rm(pendingPath(telemetry), { force: true });
|
|
302
220
|
await telemetry.step('login', 'ok');
|
|
303
221
|
return data.access_token;
|
|
304
222
|
}
|
|
305
223
|
if (data.error === 'slow_down') interval += 5000;
|
|
306
224
|
else if (data.error !== 'authorization_pending') {
|
|
307
|
-
await rm(pendingPath(), { force: true });
|
|
225
|
+
await rm(pendingPath(telemetry), { force: true });
|
|
308
226
|
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.');
|
|
309
227
|
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.');
|
|
310
228
|
}
|
|
@@ -328,59 +246,71 @@ async function verify(key, telemetry) {
|
|
|
328
246
|
}
|
|
329
247
|
|
|
330
248
|
async function install(root, { client, scope, plan, open, log, wait, telemetry, autoUpdate, json }) {
|
|
331
|
-
const state = await inspect(root, scope);
|
|
249
|
+
const state = await inspect(root, scope, client);
|
|
332
250
|
const enabled = autoUpdate === undefined ? state.updates.saved?.enabled ?? true : autoUpdate === 'on';
|
|
333
251
|
const found = conflicts(state);
|
|
252
|
+
if (client === 'pi' && autoUpdate === 'on') throw new Stop('Pi updates are explicit: rerun install --client pi. Automatic hooks are Claude Code only.', exitCodes.error);
|
|
334
253
|
const changes = [
|
|
335
|
-
`${state.current ? 'Update' : 'Add'} MCP server "openmerit" in Claude Code (${
|
|
254
|
+
`${state.current ? 'Update' : 'Add'} ${client === 'pi' ? 'native Pi MCP extension' : 'MCP server "openmerit" in Claude Code'} (${scope} scope)`,
|
|
336
255
|
`${state.installedSkill === null ? 'Create' : 'Update'} ${state.paths.skill}`,
|
|
337
256
|
`${state.installedRule === null ? 'Create' : 'Update'} ${state.paths.rule} (loaded at session startup)`,
|
|
338
|
-
|
|
339
|
-
|
|
257
|
+
...(client === 'pi' ? [] : [
|
|
258
|
+
`Automatic Scout instruction updates ${enabled ? 'on' : 'off'} (SessionStart/Stop background hooks; disable with openmerit update --auto off)`,
|
|
259
|
+
`Write the owned updater at ${state.updates.paths.runner}; merge its hooks in ${state.updates.paths.settings}`,
|
|
260
|
+
]),
|
|
340
261
|
...(state.legacyRule === 'remove' ? [`Remove the unchanged earlier instructions at ${state.paths.legacyRule}`] : []),
|
|
341
|
-
...(await credentials() ? [] : [`Sign this device in through the browser and store its key in ${credentialsPath()} (mode 600)`]),
|
|
262
|
+
...(await credentials(telemetry) ? [] : [`Sign this device in through the browser and store its key in ${credentialsPath(telemetry)} (mode 600)`]),
|
|
342
263
|
];
|
|
343
264
|
if (plan) return { action: 'install', applied: false, client, scope, changes, conflicts: found };
|
|
344
265
|
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')));
|
|
346
|
-
|
|
266
|
+
if (!json && client !== 'pi') log(changes.find(change => change.startsWith('Automatic Scout')));
|
|
267
|
+
if (client === 'pi') {
|
|
268
|
+
const { stdout } = await exec('pi', ['--version'], { cwd: root, timeout: 10_000 });
|
|
269
|
+
if (stdout.trim() !== piVersion) throw new Stop(`Pi ${piVersion} (latest stable supported) is required; found ${stdout.trim()}. Update Pi first.`, exitCodes.error);
|
|
270
|
+
} else await claude(['--version'], root);
|
|
347
271
|
if (setupCorrelationEnabled()) telemetry.properties.installation_id ??= randomUUID();
|
|
348
272
|
await telemetry.step('preflight', 'ok');
|
|
349
|
-
let key = (await credentials())?.key;
|
|
273
|
+
let key = (await credentials(telemetry))?.key;
|
|
350
274
|
// A revoked or expired key gets a fresh sign-in instead of a broken install.
|
|
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.'); }
|
|
275
|
+
if (key && await verify(key, telemetry) === 'key_rejected') { await rm(credentialsPath(telemetry), { force: true }); key = undefined; log('The saved device key is no longer valid; signing in again.'); }
|
|
352
276
|
key ??= await login({ client, scope, open, log, wait, telemetry });
|
|
353
277
|
await telemetry.step('configuration', 'started');
|
|
354
|
-
const next = serverConfig(key, telemetry);
|
|
355
278
|
const ids = { setup_id: telemetry.properties.setup_id, installation_id: telemetry.properties.installation_id };
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
279
|
+
if (client === 'pi') {
|
|
280
|
+
await save(credentialsPath(telemetry), { ...await credentials(telemetry), headers: setupHeaders(telemetry.properties, 'agent') });
|
|
281
|
+
await configurePi(root, scope, credentialsPath(telemetry), ids);
|
|
282
|
+
telemetry.properties.applied = true;
|
|
283
|
+
} else {
|
|
284
|
+
const next = serverConfig(key, telemetry);
|
|
285
|
+
// Record intent first so an interrupted install is repaired by rerunning.
|
|
286
|
+
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) });
|
|
287
|
+
telemetry.properties.applied = true;
|
|
288
|
+
if (serverHash(state.current) !== serverHash(next)) {
|
|
289
|
+
if (state.current) await claude(['mcp', 'remove', '--scope', state.paths.mcpScope, 'openmerit'], root, key);
|
|
290
|
+
await claude(['mcp', 'add-json', '--scope', state.paths.mcpScope, 'openmerit', JSON.stringify(next)], root, key);
|
|
291
|
+
}
|
|
292
|
+
await save(state.paths.skill, skill);
|
|
293
|
+
await save(state.paths.rule, rule);
|
|
294
|
+
if (state.legacyRule === 'remove') await rm(state.paths.legacyRule, { force: true });
|
|
295
|
+
// Record what Claude actually wrote, so later edits are detected precisely.
|
|
296
|
+
const written = configuredServer(await object(state.paths.mcpFile), root, scope);
|
|
297
|
+
if (!written) throw new Stop('Claude Code did not save the "openmerit" MCP server.', exitCodes.error, 'Run "openmerit doctor".');
|
|
298
|
+
const updates = await configureUpdates(dirname(state.paths.record), scope, state.record, clientVersion, enabled);
|
|
299
|
+
await save(state.paths.record, { version: 3, scope, client, ...ids, serverHash: serverHash(written), skillHash: hash(skill), ruleHash: hash(rule), updates, installedAt: new Date().toISOString() });
|
|
362
300
|
}
|
|
363
|
-
await
|
|
364
|
-
await save(state.paths.rule, rule);
|
|
365
|
-
if (state.legacyRule === 'remove') await rm(state.paths.legacyRule, { force: true });
|
|
366
|
-
// Record what Claude actually wrote, so later edits are detected precisely.
|
|
367
|
-
const written = configuredServer(await object(state.paths.mcpFile), root, scope);
|
|
368
|
-
if (!written) throw new Stop('Claude Code did not save the "openmerit" MCP server.', exitCodes.error, 'Run "openmerit doctor".');
|
|
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)));
|
|
301
|
+
await telemetry.step('configuration', 'ok', diagnostics(await inspect(root, scope, client)));
|
|
372
302
|
const connection = await verify(key, telemetry);
|
|
373
303
|
if (connection !== 'connected') telemetry.outcome = 'error';
|
|
374
304
|
if (connection === 'key_rejected') throw new Stop('OpenMerit rejected this device key (expired or revoked).', exitCodes.denied, 'Run "openmerit login", then rerun install.');
|
|
375
305
|
return { action: 'install', applied: true, client, scope, changes, connection,
|
|
376
306
|
next: connection === 'connected'
|
|
377
|
-
? `Scout is ready in new ${clients[client]} sessions. Start a new session to load the
|
|
378
|
-
:
|
|
307
|
+
? `Scout is ready in new ${clients[client]} sessions. Start a new session to load the extension, instructions and skill.`
|
|
308
|
+
: `Installed, but the connection check failed. Run "openmerit doctor --client ${client} --scope ${scope}".` };
|
|
379
309
|
}
|
|
380
310
|
|
|
381
311
|
async function status(root, { client, scope, check, telemetry }) {
|
|
382
312
|
await telemetry.step('status', 'started');
|
|
383
|
-
const state = await inspect(root, scope), saved = await credentials();
|
|
313
|
+
const state = await inspect(root, scope, client), saved = await credentials(telemetry);
|
|
384
314
|
const details = diagnostics(state), found = conflicts(state);
|
|
385
315
|
const connection = check && saved ? await verify(saved.key, telemetry) : 'not_checked';
|
|
386
316
|
telemetry.outcome = ['failed', 'key_rejected'].includes(connection) ? 'error' : found.length ? 'conflict' : details.installed ? 'ok' : 'not_installed';
|
|
@@ -393,11 +323,11 @@ async function status(root, { client, scope, check, telemetry }) {
|
|
|
393
323
|
}
|
|
394
324
|
|
|
395
325
|
async function revokeKey(telemetry) {
|
|
396
|
-
const saved = await credentials();
|
|
326
|
+
const saved = await credentials(telemetry);
|
|
397
327
|
if (!saved) return false;
|
|
398
328
|
await telemetry.step('remove', 'started');
|
|
399
329
|
const { status } = await request('/connect/api/keys/self', { method: 'DELETE', key: saved.key, telemetry }).catch(() => ({ status: 0 }));
|
|
400
|
-
await rm(credentialsPath(), { force: true });
|
|
330
|
+
await rm(credentialsPath(telemetry), { force: true });
|
|
401
331
|
if (status !== 200) telemetry.outcome = 'error';
|
|
402
332
|
await telemetry.step('remove', status === 200 ? 'ok' : 'error', status === 200 ? {} : {
|
|
403
333
|
code: status === 0 ? 'openmerit_unreachable' : status === 401 || status === 403 ? 'access_denied' : 'command_error',
|
|
@@ -407,7 +337,7 @@ async function revokeKey(telemetry) {
|
|
|
407
337
|
|
|
408
338
|
async function remove(root, { client, scope, plan, telemetry }) {
|
|
409
339
|
await telemetry.step('remove', 'started');
|
|
410
|
-
const state = await inspect(root, scope);
|
|
340
|
+
const state = await inspect(root, scope, client);
|
|
411
341
|
if (!state.owned) {
|
|
412
342
|
telemetry.outcome = 'not_installed';
|
|
413
343
|
await telemetry.step('remove', 'not_installed');
|
|
@@ -421,13 +351,16 @@ async function remove(root, { client, scope, plan, telemetry }) {
|
|
|
421
351
|
kept.push(...state.updates.conflicts);
|
|
422
352
|
if (plan) return { action: 'remove', applied: false, client, scope, changes, keeps: kept };
|
|
423
353
|
telemetry.properties.applied = true;
|
|
424
|
-
if (
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
354
|
+
if (client === 'pi') await removePi(state);
|
|
355
|
+
else {
|
|
356
|
+
if (state.current && state.serverOwned) await claude(['mcp', 'remove', '--scope', state.paths.mcpScope, 'openmerit'], root);
|
|
357
|
+
if (state.installedSkill !== null && state.skillOwned) await rm(dirname(state.paths.skill), { recursive: true, force: true });
|
|
358
|
+
if (state.installedRule !== null && state.ruleOwned) await rm(state.paths.rule, { force: true });
|
|
359
|
+
await removeUpdates(dirname(state.paths.record), scope, state.record);
|
|
360
|
+
await rm(state.paths.record, { force: true });
|
|
361
|
+
}
|
|
429
362
|
const revoked = await revokeKey(telemetry);
|
|
430
|
-
await telemetry.step('remove', telemetry.outcome, diagnostics(await inspect(root, scope)));
|
|
363
|
+
await telemetry.step('remove', telemetry.outcome, diagnostics(await inspect(root, scope, client)));
|
|
431
364
|
return { action: 'remove', applied: true, client, scope, changes, keeps: kept, keyRevoked: revoked };
|
|
432
365
|
}
|
|
433
366
|
|
|
@@ -440,20 +373,21 @@ async function project(root, { policy, ids, plan }) {
|
|
|
440
373
|
return { action: 'project', applied: !plan, path: policyPath, policy: value, note: 'Endpoint IDs limit discovery only; they do not prove access or authorize spending.' };
|
|
441
374
|
}
|
|
442
375
|
|
|
443
|
-
async function doctor(root, { scope, telemetry }) {
|
|
376
|
+
async function doctor(root, { client, scope, telemetry }) {
|
|
444
377
|
await telemetry.step('status', 'started');
|
|
445
378
|
const checks = [];
|
|
446
379
|
const add = (name, ok, detail) => checks.push({ name, ok, detail });
|
|
447
380
|
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
448
381
|
add('node', major > 22 || major === 22 && minor >= 19, `Node.js ${process.versions.node} (22.19+ required)`);
|
|
449
|
-
|
|
450
|
-
|
|
382
|
+
const binary = client === 'pi' ? 'pi' : 'claude';
|
|
383
|
+
try { const { stdout } = await exec(binary, ['--version'], { timeout: 10_000 }); add(binary, client !== 'pi' || stdout.trim() === piVersion, stdout.trim().slice(0, 80)); }
|
|
384
|
+
catch { add(binary, false, `${clients[client]} (${binary}) is not on PATH`); }
|
|
451
385
|
const reach = await request('/scout/api/status', { telemetry }).catch(error => ({ status: 0, error }));
|
|
452
386
|
add('openmerit', reach.status === 200, reach.status === 200 ? `${server()} reachable` : `${server()} unreachable`);
|
|
453
|
-
const saved = await credentials();
|
|
454
|
-
add('key', !!saved, saved ? `Device key ${saved.keyId ?? ''} stored in ${credentialsPath()}` : 'Not signed in. Run
|
|
387
|
+
const saved = await credentials(telemetry);
|
|
388
|
+
add('key', !!saved, saved ? `Device key ${saved.keyId ?? ''} stored in ${credentialsPath(telemetry)}` : 'Not signed in. Run targeted install or login.');
|
|
455
389
|
if (saved && reach.status === 200) { const connection = await verify(saved.key, telemetry); add('mcp', connection === 'connected', `MCP check: ${connection}`); }
|
|
456
|
-
const state = await inspect(root, scope).catch(() => null);
|
|
390
|
+
const state = await inspect(root, scope, client).catch(() => null);
|
|
457
391
|
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
392
|
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
393
|
telemetry.outcome = checks.every(check => check.ok) ? 'ok' : 'error';
|
|
@@ -507,7 +441,7 @@ async function runMain(args, { root = process.cwd(), print = console.log, printE
|
|
|
507
441
|
if (!action || rest.includes('--help') || rest.includes('-h')) { print(help); return exitCodes.ok; }
|
|
508
442
|
const options = { client: undefined, scope: 'user', plan: false, check: false, json: false, open: true, policy: undefined, ids: [], telemetry };
|
|
509
443
|
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];
|
|
444
|
+
login: ['--client', '--scope', '--setup-id', '--json', '--no-open'], logout: ['--client', '--scope', '--json'], project: ['--policy', '--endpoint-id', '--plan', '--json'], doctor: ['--client', '--scope', '--json'], update: ['--client', '--scope', '--auto', '--json'] }[action];
|
|
511
445
|
if (!allowed) throw new Error('Unknown command. Run openmerit --help.');
|
|
512
446
|
for (let i = 0; i < rest.length; i++) {
|
|
513
447
|
const option = rest[i];
|
|
@@ -523,12 +457,14 @@ async function runMain(args, { root = process.cwd(), print = console.log, printE
|
|
|
523
457
|
else options[option.slice(2)] = true;
|
|
524
458
|
}
|
|
525
459
|
if (!['user', 'project'].includes(options.scope)) throw new Error('Use --scope user or --scope project.');
|
|
526
|
-
if (
|
|
527
|
-
options.client ??= detectClient();
|
|
528
|
-
if (!options.client) throw new Error('Choose the coding agent with --client claude-code.');
|
|
460
|
+
if (action !== 'project') {
|
|
461
|
+
options.client ??= detectClient() ?? (['install', 'status', 'remove'].includes(action) ? undefined : 'claude-code');
|
|
462
|
+
if (!options.client) throw new Error('Choose the coding agent with --client claude-code or --client pi.');
|
|
529
463
|
if (!clients[options.client]) throw new Error(`Unsupported client "${options.client}". Supported: ${Object.keys(clients).join(', ')}.`);
|
|
530
464
|
}
|
|
531
465
|
root = resolve(root);
|
|
466
|
+
telemetry.root = root;
|
|
467
|
+
telemetry.properties.agent = options.client ?? 'claude-code';
|
|
532
468
|
// Agents run commands with a few-minute timeout; wait well inside it, then resume.
|
|
533
469
|
const wait = process.env.OPENMERIT_LOGIN_WAIT === undefined ? undefined : Number(process.env.OPENMERIT_LOGIN_WAIT);
|
|
534
470
|
const log = text => (options.json ? printError : print)(text);
|
|
@@ -538,15 +474,15 @@ async function runMain(args, { root = process.cwd(), print = console.log, printE
|
|
|
538
474
|
// pending approval owns its setup ID even when the resumed command omits it.
|
|
539
475
|
if (['install', 'status', 'remove', 'doctor'].includes(action)) {
|
|
540
476
|
telemetry.stage = 'preflight';
|
|
541
|
-
const state = await inspect(root, options.scope);
|
|
477
|
+
const state = await inspect(root, options.scope, options.client);
|
|
542
478
|
Object.assign(telemetry.properties, diagnostics(state));
|
|
543
479
|
if (state.owned) for (const key of ['setup_id', 'installation_id']) if (setupUUID(state.record[key])) telemetry.properties[key] = state.record[key];
|
|
544
480
|
}
|
|
545
481
|
if (options.setup_id) telemetry.properties.setup_id = options.setup_id;
|
|
546
482
|
if (['install', 'login'].includes(action)) {
|
|
547
|
-
const saved = await credentials();
|
|
483
|
+
const saved = await credentials(telemetry);
|
|
548
484
|
if (!telemetry.properties.setup_id && setupUUID(saved?.setup_id)) telemetry.properties.setup_id = saved.setup_id;
|
|
549
|
-
const pending = await object(pendingPath());
|
|
485
|
+
const pending = await object(pendingPath(telemetry));
|
|
550
486
|
if (pending.server === server() && pending.expiresAt > Date.now() && typeof pending.deviceCode === 'string') {
|
|
551
487
|
if (setupUUID(pending.setup_id)) telemetry.properties.setup_id = pending.setup_id;
|
|
552
488
|
if (!telemetry.properties.installation_id && setupUUID(pending.installation_id)) telemetry.properties.installation_id = pending.installation_id;
|
|
@@ -554,8 +490,9 @@ async function runMain(args, { root = process.cwd(), print = console.log, printE
|
|
|
554
490
|
}
|
|
555
491
|
await telemetry.step('started', 'started');
|
|
556
492
|
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(); });
|
|
493
|
+
const base = dirname((options.client === 'pi' ? piPaths : claudePaths)(root, options.scope).record);
|
|
494
|
+
const maintenance = work => options.plan ? work() : withUpdateLock(base, async () => { if (options.client !== 'pi') await recoverUpdates(base, options.scope); return work(); });
|
|
495
|
+
if (action === 'update' && options.client === 'pi') throw new Stop('Update Pi Scout by running npx -y openmerit@latest install --client pi with the same --scope. Automatic hooks are Claude Code only.', exitCodes.error);
|
|
559
496
|
const result = action === 'install' ? await maintenance(() => install(root, { ...options, log, wait }))
|
|
560
497
|
: action === 'status' ? await status(root, options)
|
|
561
498
|
: action === 'remove' ? await maintenance(() => remove(root, options))
|
|
@@ -563,7 +500,7 @@ async function runMain(args, { root = process.cwd(), print = console.log, printE
|
|
|
563
500
|
: action === 'project' ? await project(root, options)
|
|
564
501
|
: action === 'doctor' ? await doctor(root, options)
|
|
565
502
|
: 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:
|
|
503
|
+
: (await revokeKey(telemetry), await login({ client: options.client, scope: options.scope, open: options.open, log, wait, telemetry }), { action, signedIn: true, note: `Signed in. Run "openmerit install --client ${options.client} --scope ${options.scope}" to refresh this installation.` });
|
|
567
504
|
if (result.conflicts?.length && telemetry.outcome !== 'error') { telemetry.outcome = 'conflict'; telemetry.properties.code = 'install_conflict'; }
|
|
568
505
|
if (options.plan) await telemetry.step(telemetry.stage, telemetry.outcome, { applied: false });
|
|
569
506
|
print(options.json ? JSON.stringify({ ok: action !== 'update' || !['conflict', 'retry', 'manual_required'].includes(result.status), ...result }, null, 2) : render(result));
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type ScoutClient = 'claude-code' | 'pi';
|
|
2
|
+
export type ScoutScope = 'user' | 'project';
|
|
3
|
+
export interface DeviceCredentials {
|
|
4
|
+
version: 1;
|
|
5
|
+
server: string;
|
|
6
|
+
key: string;
|
|
7
|
+
keyId?: string;
|
|
8
|
+
headers?: Record<string, string>;
|
|
9
|
+
}
|
|
10
|
+
export declare function credentialsPath(client: ScoutClient, scope: ScoutScope, root: string): string;
|
|
11
|
+
export declare function pendingPath(client: ScoutClient, scope: ScoutScope, root: string): string;
|
|
12
|
+
export declare function readCredentials(path: string): DeviceCredentials | null;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
// Retain the existing Claude user key in place. Other installations own their
|
|
6
|
+
// own key and approval, so removing one cannot revoke another's connection.
|
|
7
|
+
export function credentialsPath(client, scope, root) {
|
|
8
|
+
const base = resolve(process.env.OPENMERIT_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'openmerit'));
|
|
9
|
+
if (client === 'claude-code' && scope === 'user')
|
|
10
|
+
return join(base, 'credentials.json');
|
|
11
|
+
const installation = scope === 'project' ? createHash('sha256').update(resolve(root)).digest('hex').slice(0, 24) : 'user';
|
|
12
|
+
return join(base, client, installation, 'credentials.json');
|
|
13
|
+
}
|
|
14
|
+
export function pendingPath(client, scope, root) {
|
|
15
|
+
return join(dirname(credentialsPath(client, scope, root)), 'login.json');
|
|
16
|
+
}
|
|
17
|
+
export function readCredentials(path) {
|
|
18
|
+
let value;
|
|
19
|
+
try {
|
|
20
|
+
value = JSON.parse(readFileSync(path, 'utf8'));
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
|
|
24
|
+
return null;
|
|
25
|
+
throw new Error('Scout credentials could not be read. Run the targeted install command again.');
|
|
26
|
+
}
|
|
27
|
+
if (!value || typeof value !== 'object' || !('version' in value) || value.version !== 1
|
|
28
|
+
|| !('server' in value) || typeof value.server !== 'string'
|
|
29
|
+
|| !('key' in value) || typeof value.key !== 'string' || !/^om_[A-Za-z0-9_-]{43}$/.test(value.key))
|
|
30
|
+
return null;
|
|
31
|
+
const url = new URL(value.server);
|
|
32
|
+
if (url.username || url.password || (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))))
|
|
33
|
+
return null;
|
|
34
|
+
const result = { version: 1, server: value.server.replace(/\/+$/, ''), key: value.key };
|
|
35
|
+
if ('keyId' in value && typeof value.keyId === 'string')
|
|
36
|
+
result.keyId = value.keyId;
|
|
37
|
+
if ('headers' in value && value.headers && typeof value.headers === 'object') {
|
|
38
|
+
result.headers = Object.fromEntries(Object.entries(value.headers).filter(([key, v]) => /^X-Scout-[A-Za-z-]+$/.test(key) && typeof v === 'string'));
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export type ScoutClient = 'claude-code' | 'pi';
|
|
7
|
+
export type ScoutScope = 'user' | 'project';
|
|
8
|
+
export interface DeviceCredentials {
|
|
9
|
+
version: 1;
|
|
10
|
+
server: string;
|
|
11
|
+
key: string;
|
|
12
|
+
keyId?: string;
|
|
13
|
+
headers?: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Retain the existing Claude user key in place. Other installations own their
|
|
17
|
+
// own key and approval, so removing one cannot revoke another's connection.
|
|
18
|
+
export function credentialsPath(client: ScoutClient, scope: ScoutScope, root: string): string {
|
|
19
|
+
const base = resolve(process.env.OPENMERIT_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'openmerit'));
|
|
20
|
+
if (client === 'claude-code' && scope === 'user') return join(base, 'credentials.json');
|
|
21
|
+
const installation = scope === 'project' ? createHash('sha256').update(resolve(root)).digest('hex').slice(0, 24) : 'user';
|
|
22
|
+
return join(base, client, installation, 'credentials.json');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function pendingPath(client: ScoutClient, scope: ScoutScope, root: string): string {
|
|
26
|
+
return join(dirname(credentialsPath(client, scope, root)), 'login.json');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function readCredentials(path: string): DeviceCredentials | null {
|
|
30
|
+
let value: unknown;
|
|
31
|
+
try { value = JSON.parse(readFileSync(path, 'utf8')); }
|
|
32
|
+
catch (error) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return null; throw new Error('Scout credentials could not be read. Run the targeted install command again.'); }
|
|
33
|
+
if (!value || typeof value !== 'object' || !('version' in value) || value.version !== 1
|
|
34
|
+
|| !('server' in value) || typeof value.server !== 'string'
|
|
35
|
+
|| !('key' in value) || typeof value.key !== 'string' || !/^om_[A-Za-z0-9_-]{43}$/.test(value.key)) return null;
|
|
36
|
+
const url = new URL(value.server);
|
|
37
|
+
if (url.username || url.password || (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)))) return null;
|
|
38
|
+
const result: DeviceCredentials = { version: 1, server: value.server.replace(/\/+$/, ''), key: value.key };
|
|
39
|
+
if ('keyId' in value && typeof value.keyId === 'string') result.keyId = value.keyId;
|
|
40
|
+
if ('headers' in value && value.headers && typeof value.headers === 'object') {
|
|
41
|
+
result.headers = Object.fromEntries(Object.entries(value.headers).filter(([key, v]) => /^X-Scout-[A-Za-z-]+$/.test(key) && typeof v === 'string'));
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ScoutClient } from './scout-credentials.ts';
|
|
2
|
+
export declare const rule = "# Application model decisions \u00B7 OpenMerit Scout\n\nBefore choosing, recommending, adding, downloading, replacing or configuring\nANY application inference model, load the `openmerit-scout` skill and consult\n`scout_models` on the `openmerit` MCP server before committing to a model.\nThis applies even when the user only asks you to build a feature, never mentions\nmodels or Scout, or names a model themselves (assess it; do not silently replace it).\n\nA pretrained model, local weights, browser inference, an SDK's default model,\nembeddings, OCR, speech, image classification, detection and segmentation all\ncount as model choices, not implementation details. Identify this decision before\nwriting model-specific code, installing a model package or downloading weights.\n\nFollow the skill's project access policy. If it is missing, ask and save it first.\nScout may answer with clarifying questions: relay them to the user and continue\nthe same Scout conversation with their answers before choosing.\nIf Scout is unavailable, fails or has no suitable evidence/candidate, disclose the\ngap and pause the model decision; do not fall back to a remembered or SDK-default\nmodel. You may continue work that does not commit to a model.\n\nDo not consult Scout for unrelated edits, normal inference calls using an unchanged\napproved model, or this coding agent's own model. Do not reselect approved models\nunless the task introduces or changes a model decision.\n\nWhen the user says \"update openmerit\", run\n`npx -y openmerit@latest update --scope user` (use `--scope project` if this\nrule is in the project's .claude/rules directory). Report the result, including\nany conflict or manual-upgrade requirement. Do not delete edited files, bypass\npermissions or rerun login automatically. A new session loads updated instructions.\n";
|
|
3
|
+
export declare const skill = "---\nname: openmerit-scout\ndescription: Consults OpenMerit Scout before choosing, adding, downloading, replacing or configuring any application inference model, including implicit choices while building features, pretrained browser/local models, SDK defaults, embeddings, OCR, speech, classification, detection, segmentation, LLMs and agents. Not for unrelated edits, ordinary inference calls or this coding agent's own model.\n---\n\n# OpenMerit \u00B7 Scout\n\nBefore introducing, recommending, replacing or configuring an application\ninference model, call the `scout_models` tool on the `openmerit` MCP server.\nThis applies even when the user never mentions models or Scout. Downloading local\nweights, using a pretrained browser model or accepting an SDK default is a model\ndecision: consult Scout before model-specific code, installs or downloads.\nPreserve the user's explicit model choice; assess it rather than silently replacing\nit. Leave unchanged, approved application selections in place.\n\n## Project policy\n\nRead `.openmerit/scout.json` before searching. If it is absent, ask once whether this\nproject uses **existing-only** (only endpoints it can already reach) or\n**existing-first** (prefer them, ask before searching the whole catalogue), and\nwhich public catalogue endpoint IDs it can access. Save the answer:\n\n```sh\nnpx -y openmerit@latest project --policy existing-only --endpoint-id <id> ...\n```\n\nNever infer access from a model name, SDK or environment-variable name. Never\nrecord credentials, private URLs, prompts or customer data. An empty inventory\nis not permission to search all models.\n\n## Searching\n\nCall `scout_models` with a non-confidential task description as `prompt` and\n`endpointIds` set to the saved inventory, including `[]` when empty. Send\nonly task requirements, never source files, secrets or real customer records.\nIf the MCP tool is unavailable, run\n`npx -y openmerit@latest scout \"<task>\" --json`; it applies the saved inventory.\n\nScout is a conversation with the agent behind openmerit.ai/scout. When\n`status` is `needs_answers`, fill in what the user or project already states,\nshow the user Scout's brief and remaining questions, and wait for their answers.\nThen call again with the returned `conversationId` and `brief` set to the\nrewritten components, or `skip: true` if the user wants Scout to assume. Never\nanswer Scout's questions with guesses. Send later refinements to the same\n`conversationId`. A call with only a `conversationId` reads an interrupted turn.\n\nWhen `status` is `recommended`, Scout has split the work into `tasks`, one\nper model, each with up to five ranked `models`; cover every task. Each\nmodel's `cost` names the `endpointId` and `providerModelId` to call.\nExisting-only never broadens. For existing-first, if no existing candidate fits a\ntask, explain the gap and ask before a new conversation without `endpointIds`.\nCompare quality evidence, reference cost/time and watch-outs. Prefer the cheapest\nusable option the evidence supports, without claiming benchmarks prove\napplication accuracy. Use the project's actual SDK and protocol.\n\n## Boundaries\n\nAsk before adding providers or credentials, spending, deploying or external\nwrites. Report recommendation, endpoint connectivity and application checks as\nseparate results, each only after it succeeds. Do not switch this coding agent's\nown model.\n\nIf Scout fails or has no suitable candidate/evidence for the required deployment,\nsay so and pause the model decision; do not substitute a remembered recommendation\nor SDK default. Continue only model-independent work. Local/browser models are not\nexempt when the catalogue lacks coverage. If it reports that the device key is missing,\nexpired or revoked, run `npx -y openmerit@latest login` and then\n`npx -y openmerit@latest install` again. Never ask the user to paste a key.\n";
|
|
4
|
+
export declare function scoutRule(client: ScoutClient): string;
|
|
5
|
+
export declare function scoutSkill(client: ScoutClient): string;
|