openmerit 0.1.10 → 0.1.12

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 (45) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +20 -53
  3. package/dist/pi/scout-extension.mjs +177 -0
  4. package/dist/pi/src/index.d.ts +6 -32
  5. package/dist/pi/src/index.js +49 -787
  6. package/dist/terminal/scout-update.json +1 -1
  7. package/dist/terminal/src/install.mjs +102 -166
  8. package/dist/terminal/src/scout-credentials.d.ts +12 -0
  9. package/dist/terminal/src/scout-credentials.js +41 -0
  10. package/dist/terminal/src/scout-credentials.ts +44 -0
  11. package/dist/terminal/src/scout-instructions.d.ts +5 -0
  12. package/dist/terminal/src/scout-instructions.js +103 -0
  13. package/dist/terminal/src/scout-instructions.ts +106 -0
  14. package/dist/terminal/src/scout-pi.d.ts +44 -0
  15. package/dist/terminal/src/scout-pi.js +84 -0
  16. package/dist/terminal/src/scout-pi.ts +76 -0
  17. package/dist/terminal/src/telemetry.mjs +2 -2
  18. package/dist/terminal/src/updates.js +2 -1
  19. package/dist/terminal/src/updates.ts +2 -1
  20. package/docs/adapter-guide.md +2 -0
  21. package/docs/architecture.md +7 -5
  22. package/docs/automation.md +2 -0
  23. package/docs/budgets.md +2 -0
  24. package/docs/commands.md +3 -1
  25. package/docs/demo.md +1 -1
  26. package/docs/first-trial.md +2 -0
  27. package/docs/getting-started.md +19 -44
  28. package/docs/index.md +10 -6
  29. package/docs/inference-terminal.md +1 -1
  30. package/docs/lifecycle.md +2 -0
  31. package/docs/operations.md +2 -0
  32. package/docs/pi-extension.md +32 -28
  33. package/docs/roadmap.md +6 -5
  34. package/docs/scout-data-policy.md +22 -64
  35. package/docs/scout.md +137 -19
  36. package/docs/security.md +8 -2
  37. package/docs/testing.md +2 -0
  38. package/docs/troubleshooting.md +23 -28
  39. package/docs/ux-reference.md +1 -1
  40. package/package.json +11 -7
  41. package/dist/pi/src/scheduler.d.ts +0 -11
  42. package/dist/pi/src/scheduler.js +0 -137
  43. package/dist/pi/src/wakeup.d.ts +0 -2
  44. package/dist/pi/src/wakeup.js +0 -108
  45. package/docs/memo.md +0 -126
@@ -1 +1 @@
1
- {"format":1,"contract":"scout-recommend-v1","version":"0.1.10","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 · 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 and\n`endpointIds` set to the saved inventory, including `[]` when empty.\nExisting-only never broadens. For existing-first, if no existing candidate fits,\nexplain the gap and ask before a separate search without `endpointIds`. Send\nonly task requirements, never source files, secrets or real customer records.\n\nCompare model AND endpoint, evidence, reference cost/time, unknowns and\nwarnings. For multiple tasks read `tasks[].slots[0].recommendations`; the\ntop-level list is empty. Prefer the cheapest usable option the quality evidence\nsupports, without claiming benchmarks prove application accuracy. Use the\nreturned provider model ID and 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","rule":"# Application model decisions · 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.\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"}
1
+ {"format":1,"contract":"scout-recommend-v1","version":"0.1.12","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 · 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 and\n`endpointIds` set to the saved inventory, including `[]` when empty.\nExisting-only never broadens. For existing-first, if no existing candidate fits,\nexplain the gap and ask before a separate search without `endpointIds`. Send\nonly task requirements, never source files, secrets or real customer records.\n\nCompare model AND endpoint, evidence, reference cost/time, unknowns and\nwarnings. For multiple tasks read `tasks[].slots[0].recommendations`; the\ntop-level list is empty. Prefer the cheapest usable option the quality evidence\nsupports, without claiming benchmarks prove application accuracy. Use the\nreturned provider model ID and 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","rule":"# Application model decisions · 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.\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"}
@@ -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,26 @@ 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]
120
34
 
121
35
  install signs this device in through the browser (one Approve click), adds the
122
36
  OpenMerit MCP server, startup rule and Scout skill, then verifies the connection.
123
37
  Automatic Scout instruction updates default on; --auto-update off opts out.
124
38
  Background SessionStart/Stop hooks check npm at most daily (15-minute failure
125
39
  retry), verify package integrity, and preserve edited files and credentials.
126
- Start a new Claude Code session to reliably load updated instructions.
40
+ Pi uses its native MCP extension; run install again to update its managed adapter.
41
+ Pi does not install Claude hooks or a background updater. Start a new session after setup.
127
42
  It opens the approval page and waits up to 9 minutes for the Approve click
128
43
  (30 seconds when no browser could be opened). If it exits with approval
129
44
  pending, rerun the same command: it resumes the same request.
@@ -137,9 +52,8 @@ Exit codes: 0 done, 10 approval pending, 11 denied or expired, 20 conflict,
137
52
  class Stop extends Error { constructor(message, code, next) { super(message); this.exit = code; this.next = next; } }
138
53
 
139
54
  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');
55
+ const credentialsPath = telemetry => deviceCredentialsPath(telemetry.properties.agent, telemetry.properties.scope, telemetry.root);
56
+ const pendingPath = telemetry => devicePendingPath(telemetry.properties.agent, telemetry.properties.scope, telemetry.root);
143
57
 
144
58
  async function optional(path) {
145
59
  try { return await readFile(path, 'utf8'); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
@@ -172,6 +86,7 @@ async function request(path, { method = 'GET', body, key, telemetry } = {}) {
172
86
 
173
87
  function detectClient() {
174
88
  if (process.env.CLAUDECODE === '1' || process.env.CLAUDE_CODE_ENTRYPOINT) return 'claude-code';
89
+ if (process.env.PI_CODING_AGENT_DIR) return 'pi';
175
90
  return null;
176
91
  }
177
92
 
@@ -205,7 +120,8 @@ async function claude(args, root, key) {
205
120
  }
206
121
 
207
122
  // Inspect the current Claude Code installation without changing anything.
208
- async function inspect(root, scope) {
123
+ async function inspect(root, scope, client = 'claude-code') {
124
+ if (client === 'pi') return { ...await inspectPi(root, scope), client };
209
125
  const paths = claudePaths(root, scope);
210
126
  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
127
  const updates = await inspectUpdates(dirname(paths.record), scope, record);
@@ -214,7 +130,7 @@ async function inspect(root, scope) {
214
130
  const otherScope = configuredServer(config, root, scope === 'user' ? 'project' : 'user');
215
131
  const projectFile = await object(join(root, '.mcp.json'));
216
132
  return {
217
- paths, record, owned, current, installedSkill, installedRule, updates,
133
+ paths, record, owned, current, installedSkill, installedRule, updates, client,
218
134
  // A full pending hash covers interruption without accepting edited headers.
219
135
  // Keep pendingAuth compatibility for records from the earlier installer.
220
136
  serverOwned: !current || owned && (serverHash(current) === record.serverHash || serverHash(current) === record.pendingServerHash || !!record.pendingAuth && hash(current.headers?.Authorization ?? '') === record.pendingAuth),
@@ -227,6 +143,7 @@ async function inspect(root, scope) {
227
143
  }
228
144
 
229
145
  function conflicts(state) {
146
+ if (state.client === 'pi') return piConflicts(state);
230
147
  const found = [...state.updates.conflicts];
231
148
  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
149
  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 +161,8 @@ function diagnostics(state) {
244
161
  };
245
162
  }
246
163
 
247
- async function credentials() {
248
- const saved = await object(credentialsPath());
164
+ async function credentials(telemetry) {
165
+ const saved = await object(credentialsPath(telemetry));
249
166
  return saved.version === 1 && saved.server === server() && typeof saved.key === 'string' ? saved : null;
250
167
  }
251
168
 
@@ -253,13 +170,13 @@ async function credentials() {
253
170
  // key only on disk. Never prints the key.
254
171
  async function login({ client, scope, open, log, wait, telemetry }) {
255
172
  await telemetry.step('login', 'started');
256
- let pending = await object(pendingPath());
173
+ let pending = await object(pendingPath(telemetry));
257
174
  if (!(pending.server === server() && pending.expiresAt > Date.now() && typeof pending.deviceCode === 'string')) {
258
175
  const deviceName = (process.env.OPENMERIT_DEVICE_NAME ?? hostname()).replace(/[^\x20-\x7e]/g, '').slice(0, 64) || 'this device';
259
176
  const { status, data } = await request('/connect/api/device', { method: 'POST', body: { client, scope, device_name: deviceName }, telemetry });
260
177
  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
178
  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);
179
+ await save(pendingPath(telemetry), pending);
263
180
  }
264
181
  const approvalURL = new URL(pending.url);
265
182
  // Rebuild consent on every resume, so old query parameters cannot override a
@@ -270,7 +187,7 @@ async function login({ client, scope, open, log, wait, telemetry }) {
270
187
  if (!telemetryEnabled(true)) approvalURL.searchParams.set('analytics', 'off');
271
188
  if (process.env.OPENMERIT_ANALYTICS_ACCOUNT === 'off' || !telemetryEnabled(true)) approvalURL.searchParams.set('analytics_account', 'off');
272
189
  pending = { ...pending, url: approvalURL.href, setup_id: telemetry.properties.setup_id, installation_id: telemetry.properties.installation_id };
273
- await save(pendingPath(), pending);
190
+ await save(pendingPath(telemetry), pending);
274
191
  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
192
  await telemetry.step('browser_open', 'started');
276
193
  if (open && !pending.opened && !pending.openFailed) {
@@ -285,7 +202,7 @@ async function login({ client, scope, open, log, wait, telemetry }) {
285
202
  };
286
203
  const opened = await openBrowser(pending.url, { allowed: approvalPage }).catch(() => false);
287
204
  pending = { ...pending, ...(opened ? { opened: true } : { openFailed: true }) };
288
- await save(pendingPath(), pending);
205
+ await save(pendingPath(telemetry), pending);
289
206
  }
290
207
  await telemetry.step('browser_open', pending.opened ? 'ok' : pending.openFailed ? 'error' : 'pending');
291
208
  await telemetry.step('login', 'started');
@@ -297,14 +214,14 @@ async function login({ client, scope, open, log, wait, telemetry }) {
297
214
  for (;;) {
298
215
  const { status, data } = await request('/connect/api/token', { method: 'POST', body: { device_code: pending.deviceCode }, telemetry });
299
216
  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 });
217
+ 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() });
218
+ await rm(pendingPath(telemetry), { force: true });
302
219
  await telemetry.step('login', 'ok');
303
220
  return data.access_token;
304
221
  }
305
222
  if (data.error === 'slow_down') interval += 5000;
306
223
  else if (data.error !== 'authorization_pending') {
307
- await rm(pendingPath(), { force: true });
224
+ await rm(pendingPath(telemetry), { force: true });
308
225
  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
226
  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
227
  }
@@ -328,59 +245,71 @@ async function verify(key, telemetry) {
328
245
  }
329
246
 
330
247
  async function install(root, { client, scope, plan, open, log, wait, telemetry, autoUpdate, json }) {
331
- const state = await inspect(root, scope);
248
+ const state = await inspect(root, scope, client);
332
249
  const enabled = autoUpdate === undefined ? state.updates.saved?.enabled ?? true : autoUpdate === 'on';
333
250
  const found = conflicts(state);
251
+ 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
252
  const changes = [
335
- `${state.current ? 'Update' : 'Add'} MCP server "openmerit" in Claude Code (${state.paths.mcpScope} scope, ${state.paths.mcpFile})`,
253
+ `${state.current ? 'Update' : 'Add'} ${client === 'pi' ? 'native Pi MCP extension' : 'MCP server "openmerit" in Claude Code'} (${scope} scope)`,
336
254
  `${state.installedSkill === null ? 'Create' : 'Update'} ${state.paths.skill}`,
337
255
  `${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}`,
256
+ ...(client === 'pi' ? [] : [
257
+ `Automatic Scout instruction updates ${enabled ? 'on' : 'off'} (SessionStart/Stop background hooks; disable with openmerit update --auto off)`,
258
+ `Write the owned updater at ${state.updates.paths.runner}; merge its hooks in ${state.updates.paths.settings}`,
259
+ ]),
340
260
  ...(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)`]),
261
+ ...(await credentials(telemetry) ? [] : [`Sign this device in through the browser and store its key in ${credentialsPath(telemetry)} (mode 600)`]),
342
262
  ];
343
263
  if (plan) return { action: 'install', applied: false, client, scope, changes, conflicts: found };
344
264
  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);
265
+ if (!json && client !== 'pi') log(changes.find(change => change.startsWith('Automatic Scout')));
266
+ if (client === 'pi') {
267
+ const { stdout } = await exec('pi', ['--version'], { cwd: root, timeout: 10_000 });
268
+ if (stdout.trim() !== piVersion) throw new Stop(`Pi ${piVersion} (latest stable supported) is required; found ${stdout.trim()}. Update Pi first.`, exitCodes.error);
269
+ } else await claude(['--version'], root);
347
270
  if (setupCorrelationEnabled()) telemetry.properties.installation_id ??= randomUUID();
348
271
  await telemetry.step('preflight', 'ok');
349
- let key = (await credentials())?.key;
272
+ let key = (await credentials(telemetry))?.key;
350
273
  // 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.'); }
274
+ 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
275
  key ??= await login({ client, scope, open, log, wait, telemetry });
353
276
  await telemetry.step('configuration', 'started');
354
- const next = serverConfig(key, telemetry);
355
277
  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);
278
+ if (client === 'pi') {
279
+ await save(credentialsPath(telemetry), { ...await credentials(telemetry), headers: setupHeaders(telemetry.properties, 'agent') });
280
+ await configurePi(root, scope, credentialsPath(telemetry), ids);
281
+ telemetry.properties.applied = true;
282
+ } else {
283
+ const next = serverConfig(key, telemetry);
284
+ // Record intent first so an interrupted install is repaired by rerunning.
285
+ 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) });
286
+ telemetry.properties.applied = true;
287
+ if (serverHash(state.current) !== serverHash(next)) {
288
+ if (state.current) await claude(['mcp', 'remove', '--scope', state.paths.mcpScope, 'openmerit'], root, key);
289
+ await claude(['mcp', 'add-json', '--scope', state.paths.mcpScope, 'openmerit', JSON.stringify(next)], root, key);
290
+ }
291
+ await save(state.paths.skill, skill);
292
+ await save(state.paths.rule, rule);
293
+ if (state.legacyRule === 'remove') await rm(state.paths.legacyRule, { force: true });
294
+ // Record what Claude actually wrote, so later edits are detected precisely.
295
+ const written = configuredServer(await object(state.paths.mcpFile), root, scope);
296
+ if (!written) throw new Stop('Claude Code did not save the "openmerit" MCP server.', exitCodes.error, 'Run "openmerit doctor".');
297
+ const updates = await configureUpdates(dirname(state.paths.record), scope, state.record, clientVersion, enabled);
298
+ 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
299
  }
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)));
300
+ await telemetry.step('configuration', 'ok', diagnostics(await inspect(root, scope, client)));
372
301
  const connection = await verify(key, telemetry);
373
302
  if (connection !== 'connected') telemetry.outcome = 'error';
374
303
  if (connection === 'key_rejected') throw new Stop('OpenMerit rejected this device key (expired or revoked).', exitCodes.denied, 'Run "openmerit login", then rerun install.');
375
304
  return { action: 'install', applied: true, client, scope, changes, connection,
376
305
  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".' };
306
+ ? `Scout is ready in new ${clients[client]} sessions. Start a new session to load the extension, instructions and skill.`
307
+ : `Installed, but the connection check failed. Run "openmerit doctor --client ${client} --scope ${scope}".` };
379
308
  }
380
309
 
381
310
  async function status(root, { client, scope, check, telemetry }) {
382
311
  await telemetry.step('status', 'started');
383
- const state = await inspect(root, scope), saved = await credentials();
312
+ const state = await inspect(root, scope, client), saved = await credentials(telemetry);
384
313
  const details = diagnostics(state), found = conflicts(state);
385
314
  const connection = check && saved ? await verify(saved.key, telemetry) : 'not_checked';
386
315
  telemetry.outcome = ['failed', 'key_rejected'].includes(connection) ? 'error' : found.length ? 'conflict' : details.installed ? 'ok' : 'not_installed';
@@ -393,11 +322,11 @@ async function status(root, { client, scope, check, telemetry }) {
393
322
  }
394
323
 
395
324
  async function revokeKey(telemetry) {
396
- const saved = await credentials();
325
+ const saved = await credentials(telemetry);
397
326
  if (!saved) return false;
398
327
  await telemetry.step('remove', 'started');
399
328
  const { status } = await request('/connect/api/keys/self', { method: 'DELETE', key: saved.key, telemetry }).catch(() => ({ status: 0 }));
400
- await rm(credentialsPath(), { force: true });
329
+ await rm(credentialsPath(telemetry), { force: true });
401
330
  if (status !== 200) telemetry.outcome = 'error';
402
331
  await telemetry.step('remove', status === 200 ? 'ok' : 'error', status === 200 ? {} : {
403
332
  code: status === 0 ? 'openmerit_unreachable' : status === 401 || status === 403 ? 'access_denied' : 'command_error',
@@ -407,7 +336,7 @@ async function revokeKey(telemetry) {
407
336
 
408
337
  async function remove(root, { client, scope, plan, telemetry }) {
409
338
  await telemetry.step('remove', 'started');
410
- const state = await inspect(root, scope);
339
+ const state = await inspect(root, scope, client);
411
340
  if (!state.owned) {
412
341
  telemetry.outcome = 'not_installed';
413
342
  await telemetry.step('remove', 'not_installed');
@@ -421,13 +350,16 @@ async function remove(root, { client, scope, plan, telemetry }) {
421
350
  kept.push(...state.updates.conflicts);
422
351
  if (plan) return { action: 'remove', applied: false, client, scope, changes, keeps: kept };
423
352
  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 });
353
+ if (client === 'pi') await removePi(state);
354
+ else {
355
+ if (state.current && state.serverOwned) await claude(['mcp', 'remove', '--scope', state.paths.mcpScope, 'openmerit'], root);
356
+ if (state.installedSkill !== null && state.skillOwned) await rm(dirname(state.paths.skill), { recursive: true, force: true });
357
+ if (state.installedRule !== null && state.ruleOwned) await rm(state.paths.rule, { force: true });
358
+ await removeUpdates(dirname(state.paths.record), scope, state.record);
359
+ await rm(state.paths.record, { force: true });
360
+ }
429
361
  const revoked = await revokeKey(telemetry);
430
- await telemetry.step('remove', telemetry.outcome, diagnostics(await inspect(root, scope)));
362
+ await telemetry.step('remove', telemetry.outcome, diagnostics(await inspect(root, scope, client)));
431
363
  return { action: 'remove', applied: true, client, scope, changes, keeps: kept, keyRevoked: revoked };
432
364
  }
433
365
 
@@ -440,20 +372,21 @@ async function project(root, { policy, ids, plan }) {
440
372
  return { action: 'project', applied: !plan, path: policyPath, policy: value, note: 'Endpoint IDs limit discovery only; they do not prove access or authorize spending.' };
441
373
  }
442
374
 
443
- async function doctor(root, { scope, telemetry }) {
375
+ async function doctor(root, { client, scope, telemetry }) {
444
376
  await telemetry.step('status', 'started');
445
377
  const checks = [];
446
378
  const add = (name, ok, detail) => checks.push({ name, ok, detail });
447
379
  const [major, minor] = process.versions.node.split('.').map(Number);
448
380
  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'); }
381
+ const binary = client === 'pi' ? 'pi' : 'claude';
382
+ try { const { stdout } = await exec(binary, ['--version'], { timeout: 10_000 }); add(binary, client !== 'pi' || stdout.trim() === piVersion, stdout.trim().slice(0, 80)); }
383
+ catch { add(binary, false, `${clients[client]} (${binary}) is not on PATH`); }
451
384
  const reach = await request('/scout/api/status', { telemetry }).catch(error => ({ status: 0, error }));
452
385
  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".');
386
+ const saved = await credentials(telemetry);
387
+ add('key', !!saved, saved ? `Device key ${saved.keyId ?? ''} stored in ${credentialsPath(telemetry)}` : 'Not signed in. Run targeted install or login.');
455
388
  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);
389
+ const state = await inspect(root, scope, client).catch(() => null);
457
390
  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
391
  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
392
  telemetry.outcome = checks.every(check => check.ok) ? 'ok' : 'error';
@@ -507,7 +440,7 @@ async function runMain(args, { root = process.cwd(), print = console.log, printE
507
440
  if (!action || rest.includes('--help') || rest.includes('-h')) { print(help); return exitCodes.ok; }
508
441
  const options = { client: undefined, scope: 'user', plan: false, check: false, json: false, open: true, policy: undefined, ids: [], telemetry };
509
442
  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];
443
+ 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
444
  if (!allowed) throw new Error('Unknown command. Run openmerit --help.');
512
445
  for (let i = 0; i < rest.length; i++) {
513
446
  const option = rest[i];
@@ -523,12 +456,14 @@ async function runMain(args, { root = process.cwd(), print = console.log, printE
523
456
  else options[option.slice(2)] = true;
524
457
  }
525
458
  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.');
459
+ if (action !== 'project') {
460
+ options.client ??= detectClient() ?? (['install', 'status', 'remove'].includes(action) ? undefined : 'claude-code');
461
+ if (!options.client) throw new Error('Choose the coding agent with --client claude-code or --client pi.');
529
462
  if (!clients[options.client]) throw new Error(`Unsupported client "${options.client}". Supported: ${Object.keys(clients).join(', ')}.`);
530
463
  }
531
464
  root = resolve(root);
465
+ telemetry.root = root;
466
+ telemetry.properties.agent = options.client ?? 'claude-code';
532
467
  // Agents run commands with a few-minute timeout; wait well inside it, then resume.
533
468
  const wait = process.env.OPENMERIT_LOGIN_WAIT === undefined ? undefined : Number(process.env.OPENMERIT_LOGIN_WAIT);
534
469
  const log = text => (options.json ? printError : print)(text);
@@ -538,15 +473,15 @@ async function runMain(args, { root = process.cwd(), print = console.log, printE
538
473
  // pending approval owns its setup ID even when the resumed command omits it.
539
474
  if (['install', 'status', 'remove', 'doctor'].includes(action)) {
540
475
  telemetry.stage = 'preflight';
541
- const state = await inspect(root, options.scope);
476
+ const state = await inspect(root, options.scope, options.client);
542
477
  Object.assign(telemetry.properties, diagnostics(state));
543
478
  if (state.owned) for (const key of ['setup_id', 'installation_id']) if (setupUUID(state.record[key])) telemetry.properties[key] = state.record[key];
544
479
  }
545
480
  if (options.setup_id) telemetry.properties.setup_id = options.setup_id;
546
481
  if (['install', 'login'].includes(action)) {
547
- const saved = await credentials();
482
+ const saved = await credentials(telemetry);
548
483
  if (!telemetry.properties.setup_id && setupUUID(saved?.setup_id)) telemetry.properties.setup_id = saved.setup_id;
549
- const pending = await object(pendingPath());
484
+ const pending = await object(pendingPath(telemetry));
550
485
  if (pending.server === server() && pending.expiresAt > Date.now() && typeof pending.deviceCode === 'string') {
551
486
  if (setupUUID(pending.setup_id)) telemetry.properties.setup_id = pending.setup_id;
552
487
  if (!telemetry.properties.installation_id && setupUUID(pending.installation_id)) telemetry.properties.installation_id = pending.installation_id;
@@ -554,8 +489,9 @@ async function runMain(args, { root = process.cwd(), print = console.log, printE
554
489
  }
555
490
  await telemetry.step('started', 'started');
556
491
  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(); });
492
+ const base = dirname((options.client === 'pi' ? piPaths : claudePaths)(root, options.scope).record);
493
+ const maintenance = work => options.plan ? work() : withUpdateLock(base, async () => { if (options.client !== 'pi') await recoverUpdates(base, options.scope); return work(); });
494
+ 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
495
  const result = action === 'install' ? await maintenance(() => install(root, { ...options, log, wait }))
560
496
  : action === 'status' ? await status(root, options)
561
497
  : action === 'remove' ? await maintenance(() => remove(root, options))
@@ -563,7 +499,7 @@ async function runMain(args, { root = process.cwd(), print = console.log, printE
563
499
  : action === 'project' ? await project(root, options)
564
500
  : action === 'doctor' ? await doctor(root, options)
565
501
  : 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.' });
502
+ : (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
503
  if (result.conflicts?.length && telemetry.outcome !== 'error') { telemetry.outcome = 'conflict'; telemetry.properties.code = 'install_conflict'; }
568
504
  if (options.plan) await telemetry.step(telemetry.stage, telemetry.outcome, { applied: false });
569
505
  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
+ }