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.
Files changed (72) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +20 -56
  3. package/dist/pi/scout-extension.mjs +190 -0
  4. package/dist/pi/src/index.d.ts +6 -32
  5. package/dist/pi/src/index.js +49 -787
  6. package/dist/terminal/public/app.js +37 -37
  7. package/dist/terminal/public/styles.css +0 -125
  8. package/dist/terminal/scout-update.json +1 -1
  9. package/dist/terminal/src/cli.mjs +2 -0
  10. package/dist/terminal/src/install.mjs +103 -166
  11. package/dist/terminal/src/scout-credentials.d.ts +12 -0
  12. package/dist/terminal/src/scout-credentials.js +41 -0
  13. package/dist/terminal/src/scout-credentials.ts +44 -0
  14. package/dist/terminal/src/scout-instructions.d.ts +5 -0
  15. package/dist/terminal/src/scout-instructions.js +116 -0
  16. package/dist/terminal/src/scout-instructions.ts +119 -0
  17. package/dist/terminal/src/scout-pi.d.ts +44 -0
  18. package/dist/terminal/src/scout-pi.js +84 -0
  19. package/dist/terminal/src/scout-pi.ts +76 -0
  20. package/dist/terminal/src/scout.d.ts +77 -0
  21. package/dist/terminal/src/scout.js +181 -0
  22. package/dist/terminal/src/scout.ts +164 -0
  23. package/dist/terminal/src/server.mjs +1 -1
  24. package/dist/terminal/src/telemetry.mjs +2 -2
  25. package/docs/commands.md +3 -77
  26. package/docs/getting-started.md +20 -45
  27. package/docs/index.md +9 -14
  28. package/docs/inference-terminal.md +2 -63
  29. package/docs/pi-extension.md +32 -28
  30. package/docs/roadmap.md +6 -18
  31. package/docs/scout.md +39 -255
  32. package/docs/security.md +8 -14
  33. package/docs/testing.md +8 -34
  34. package/docs/troubleshooting.md +23 -28
  35. package/package.json +6 -27
  36. package/dist/core/src/index.d.ts +0 -101
  37. package/dist/core/src/index.js +0 -1649
  38. package/dist/core/src/store.d.ts +0 -35
  39. package/dist/core/src/store.js +0 -102
  40. package/dist/pi/src/scheduler.d.ts +0 -11
  41. package/dist/pi/src/scheduler.js +0 -137
  42. package/dist/pi/src/wakeup.d.ts +0 -2
  43. package/dist/pi/src/wakeup.js +0 -108
  44. package/dist/protocol/src/index.d.ts +0 -484
  45. package/dist/protocol/src/index.js +0 -47
  46. package/dist/protocol/src/schemas.d.ts +0 -576
  47. package/dist/protocol/src/schemas.js +0 -280
  48. package/dist/terminal/public/brands/vllm.ico +0 -0
  49. package/dist/terminal/public/flow.css +0 -1
  50. package/dist/terminal/public/flow.js +0 -770
  51. package/dist/terminal/src/demo-policy.mjs +0 -205
  52. package/dist/terminal/src/enterprise-sample.mjs +0 -151
  53. package/docs/adapter-guide.md +0 -189
  54. package/docs/architecture.md +0 -59
  55. package/docs/automation.md +0 -74
  56. package/docs/budgets.md +0 -37
  57. package/docs/demo-backfill.md +0 -31
  58. package/docs/demo-fieldkit.md +0 -49
  59. package/docs/demo-placement.md +0 -32
  60. package/docs/demo-spam.md +0 -17
  61. package/docs/demo-support.md +0 -44
  62. package/docs/demo.md +0 -74
  63. package/docs/first-trial.md +0 -60
  64. package/docs/interactive-demo.md +0 -118
  65. package/docs/lifecycle.md +0 -30
  66. package/docs/metrics-and-evidence.md +0 -48
  67. package/docs/operations.md +0 -38
  68. package/docs/pareto-spec.md +0 -76
  69. package/docs/saga/a-steadier-compass.md +0 -25
  70. package/docs/saga/defining-a-task-for-ai.md +0 -154
  71. package/docs/saga/the-benchmark-is-not-the-job.md +0 -33
  72. 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] Replace this device's key
116
- openmerit logout [--json] Revoke this device's key
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
- Start a new Claude Code session to reliably load updated instructions.
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 configDir = () => resolve(process.env.OPENMERIT_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'openmerit'));
141
- const credentialsPath = () => join(configDir(), 'credentials.json');
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 (${state.paths.mcpScope} scope, ${state.paths.mcpFile})`,
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
- `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}`,
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
- await claude(['--version'], root);
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
- // Record intent first so an interrupted install is repaired by rerunning.
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)) {
360
- if (state.current) await claude(['mcp', 'remove', '--scope', state.paths.mcpScope, 'openmerit'], root, key);
361
- await claude(['mcp', 'add-json', '--scope', state.paths.mcpScope, 'openmerit', JSON.stringify(next)], root, key);
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 save(state.paths.skill, skill);
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 startup rule and updated skill; reconnecting /mcp alone does not reload instructions.`
378
- : 'Installed, but the connection check failed. Run "openmerit doctor".' };
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 (state.current && state.serverOwned) await claude(['mcp', 'remove', '--scope', state.paths.mcpScope, 'openmerit'], root);
425
- if (state.installedSkill !== null && state.skillOwned) await rm(dirname(state.paths.skill), { recursive: true, force: true });
426
- if (state.installedRule !== null && state.ruleOwned) await rm(state.paths.rule, { force: true });
427
- await removeUpdates(dirname(state.paths.record), scope, state.record);
428
- await rm(state.paths.record, { force: true });
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
- try { const { stdout } = await exec('claude', ['--version'], { timeout: 10_000 }); add('claude', true, stdout.trim().slice(0, 80)); }
450
- catch { add('claude', false, 'Claude Code (claude) is not on PATH'); }
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 "openmerit install" or "openmerit login".');
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 (['install', 'status', 'remove'].includes(action)) {
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: '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.' });
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;