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.
- package/CHANGELOG.md +13 -0
- package/README.md +20 -53
- package/dist/pi/scout-extension.mjs +177 -0
- package/dist/pi/src/index.d.ts +6 -32
- package/dist/pi/src/index.js +49 -787
- package/dist/terminal/scout-update.json +1 -1
- package/dist/terminal/src/install.mjs +102 -166
- package/dist/terminal/src/scout-credentials.d.ts +12 -0
- package/dist/terminal/src/scout-credentials.js +41 -0
- package/dist/terminal/src/scout-credentials.ts +44 -0
- package/dist/terminal/src/scout-instructions.d.ts +5 -0
- package/dist/terminal/src/scout-instructions.js +103 -0
- package/dist/terminal/src/scout-instructions.ts +106 -0
- package/dist/terminal/src/scout-pi.d.ts +44 -0
- package/dist/terminal/src/scout-pi.js +84 -0
- package/dist/terminal/src/scout-pi.ts +76 -0
- package/dist/terminal/src/telemetry.mjs +2 -2
- package/dist/terminal/src/updates.js +2 -1
- package/dist/terminal/src/updates.ts +2 -1
- package/docs/adapter-guide.md +2 -0
- package/docs/architecture.md +7 -5
- package/docs/automation.md +2 -0
- package/docs/budgets.md +2 -0
- package/docs/commands.md +3 -1
- package/docs/demo.md +1 -1
- package/docs/first-trial.md +2 -0
- package/docs/getting-started.md +19 -44
- package/docs/index.md +10 -6
- package/docs/inference-terminal.md +1 -1
- package/docs/lifecycle.md +2 -0
- package/docs/operations.md +2 -0
- package/docs/pi-extension.md +32 -28
- package/docs/roadmap.md +6 -5
- package/docs/scout-data-policy.md +22 -64
- package/docs/scout.md +137 -19
- package/docs/security.md +8 -2
- package/docs/testing.md +2 -0
- package/docs/troubleshooting.md +23 -28
- package/docs/ux-reference.md +1 -1
- package/package.json +11 -7
- package/dist/pi/src/scheduler.d.ts +0 -11
- package/dist/pi/src/scheduler.js +0 -137
- package/dist/pi/src/wakeup.d.ts +0 -2
- package/dist/pi/src/wakeup.js +0 -108
- package/docs/memo.md +0 -126
|
@@ -1 +1 @@
|
|
|
1
|
-
{"format":1,"contract":"scout-recommend-v1","version":"0.1.
|
|
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]
|
|
116
|
-
openmerit logout [--
|
|
26
|
+
openmerit install [--client claude-code|pi] [--scope user|project] [--setup-id UUID] [--auto-update on|off] [--plan] [--json] [--no-open]
|
|
27
|
+
openmerit status [--client claude-code|pi] [--scope user|project] [--check] [--json]
|
|
28
|
+
openmerit remove [--client claude-code|pi] [--scope user|project] [--plan] [--json]
|
|
29
|
+
openmerit login [--client claude-code|pi] [--scope user|project] [--setup-id UUID] [--json] [--no-open]
|
|
30
|
+
openmerit logout [--client claude-code|pi] [--scope user|project] [--json]
|
|
117
31
|
openmerit project --policy existing-only|existing-first [--endpoint-id <id> ...] [--plan]
|
|
118
|
-
openmerit doctor [--json]
|
|
119
|
-
openmerit update [--scope user|project] [--auto on|off] [--json]
|
|
32
|
+
openmerit doctor [--client claude-code|pi] [--scope user|project] [--json]
|
|
33
|
+
openmerit update [--client claude-code|pi] [--scope user|project] [--auto on|off] [--json]
|
|
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
|
-
|
|
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
|
|
141
|
-
const
|
|
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 (${
|
|
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
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
|
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
|
|
378
|
-
:
|
|
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 (
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
450
|
-
|
|
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
|
|
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 (
|
|
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:
|
|
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
|
+
}
|