@illuminis/comprism 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +281 -0
  3. package/out/agent/command.d.ts +86 -0
  4. package/out/agent/command.js +259 -0
  5. package/out/agent/render.d.ts +97 -0
  6. package/out/agent/render.js +255 -0
  7. package/out/agent/session.d.ts +175 -0
  8. package/out/agent/session.js +573 -0
  9. package/out/commands/ask.d.ts +1 -0
  10. package/out/commands/ask.js +146 -0
  11. package/out/commands/codemap.d.ts +2 -0
  12. package/out/commands/codemap.js +151 -0
  13. package/out/commands/commands-thin.d.ts +39 -0
  14. package/out/commands/commands-thin.js +182 -0
  15. package/out/commands/install.d.ts +163 -0
  16. package/out/commands/install.js +543 -0
  17. package/out/commands/keys.d.ts +55 -0
  18. package/out/commands/keys.js +344 -0
  19. package/out/commands/login.d.ts +9 -0
  20. package/out/commands/login.js +384 -0
  21. package/out/commands/repl.d.ts +1 -0
  22. package/out/commands/repl.js +752 -0
  23. package/out/commands/settings.d.ts +21 -0
  24. package/out/commands/settings.js +244 -0
  25. package/out/commands/welcome.d.ts +1 -0
  26. package/out/commands/welcome.js +196 -0
  27. package/out/executor/documents.d.ts +40 -0
  28. package/out/executor/documents.js +170 -0
  29. package/out/executor/files.d.ts +2 -0
  30. package/out/executor/files.js +360 -0
  31. package/out/executor/git.d.ts +48 -0
  32. package/out/executor/git.js +132 -0
  33. package/out/executor/hooks.d.ts +67 -0
  34. package/out/executor/hooks.js +247 -0
  35. package/out/executor/index.d.ts +29 -0
  36. package/out/executor/index.js +221 -0
  37. package/out/executor/notebook.d.ts +2 -0
  38. package/out/executor/notebook.js +147 -0
  39. package/out/executor/paths.d.ts +15 -0
  40. package/out/executor/paths.js +126 -0
  41. package/out/executor/shell.d.ts +41 -0
  42. package/out/executor/shell.js +336 -0
  43. package/out/graph/build.d.ts +45 -0
  44. package/out/graph/build.js +91 -0
  45. package/out/graph/facts.d.ts +47 -0
  46. package/out/graph/facts.js +12 -0
  47. package/out/graph/files.d.ts +45 -0
  48. package/out/graph/files.js +207 -0
  49. package/out/graph/read-locales.d.ts +29 -0
  50. package/out/graph/read-locales.js +246 -0
  51. package/out/graph/read-python.d.ts +11 -0
  52. package/out/graph/read-python.js +115 -0
  53. package/out/graph/read-typescript.d.ts +16 -0
  54. package/out/graph/read-typescript.js +292 -0
  55. package/out/graph/sync.d.ts +66 -0
  56. package/out/graph/sync.js +242 -0
  57. package/out/lib/attach.d.ts +62 -0
  58. package/out/lib/attach.js +228 -0
  59. package/out/lib/config.d.ts +93 -0
  60. package/out/lib/config.js +198 -0
  61. package/out/lib/connection.d.ts +73 -0
  62. package/out/lib/connection.js +188 -0
  63. package/out/lib/gateway.d.ts +239 -0
  64. package/out/lib/gateway.js +171 -0
  65. package/out/lib/prompt.d.ts +34 -0
  66. package/out/lib/prompt.js +108 -0
  67. package/out/lib/types.d.ts +417 -0
  68. package/out/lib/types.js +21 -0
  69. package/out/lib/ui.d.ts +114 -0
  70. package/out/lib/ui.js +265 -0
  71. package/out/lib/version.d.ts +24 -0
  72. package/out/lib/version.js +27 -0
  73. package/out/lib/voice.d.ts +50 -0
  74. package/out/lib/voice.js +218 -0
  75. package/out/postinstall.d.ts +2 -0
  76. package/out/postinstall.js +92 -0
  77. package/out/thin.d.ts +2 -0
  78. package/out/thin.js +259 -0
  79. package/package.json +101 -0
  80. package/scripts/read_python.py +270 -0
@@ -0,0 +1,543 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.WORKSPACE_KEY_HEADER = void 0;
7
+ exports.envScriptPath = envScriptPath;
8
+ exports.pidFilePath = pidFilePath;
9
+ exports.proxyLogPath = proxyLogPath;
10
+ exports.shellProfiles = shellProfiles;
11
+ exports.tenantBaseUrl = tenantBaseUrl;
12
+ exports.envScript = envScript;
13
+ exports.desktopConfigPath = desktopConfigPath;
14
+ exports.installDesktopConnector = installDesktopConnector;
15
+ exports.probeDesktopConnector = probeDesktopConnector;
16
+ exports.uninstallDesktopConnector = uninstallDesktopConnector;
17
+ exports.install = install;
18
+ exports.uninstall = uninstall;
19
+ exports.launchShellString = launchShellString;
20
+ exports.proxyHealthy = proxyHealthy;
21
+ exports.startDaemon = startDaemon;
22
+ exports.stopDaemon = stopDaemon;
23
+ /**
24
+ * Install and interception setup. Rules R1 and R2.
25
+ *
26
+ * R1 says one command installs everything, with nothing for the user to copy or
27
+ * paste. R2 says `claude` then just works, unchanged. Those two together rule
28
+ * out the obvious approach - printing an `export` line and hoping - so this file
29
+ * does the work instead.
30
+ *
31
+ * ── What the base URL points at, and why it is not this machine ───────────
32
+ *
33
+ * The tenant's own hosted endpoint: `https://{tenant}.completionprism.illuminis.ai`.
34
+ * Claude Code and every Anthropic SDK append `/v1/messages` to it themselves.
35
+ *
36
+ * It used to point at a proxy on 127.0.0.1 that this script started, and that
37
+ * was wrong in a way worth writing down, because it looked right for weeks. A
38
+ * local proxy means the pricing runs on the laptop, which means a second
39
+ * implementation of the equation in a second language - and those two drifted
40
+ * inside a day the first time, leaving two surfaces quoting different costs for
41
+ * the same work. It also means nothing is measured on any machine where the
42
+ * daemon is not running, and no receipt link that a colleague can open. One
43
+ * hosted interceptor removes all three at once: the CLI now renders and strips,
44
+ * and computes nothing.
45
+ *
46
+ * ── The shape, and why it is safe ─────────────────────────────────────────
47
+ *
48
+ * The install writes `~/.comprism/env.sh` and adds one sourcing line to the
49
+ * user's shell profile. That script does two things every time a shell opens,
50
+ * in this order, and the order is the whole safety argument:
51
+ *
52
+ * 1. Is the tenant endpoint answering? If yes, export the base URL.
53
+ * 2. **If it is not, export nothing.**
54
+ *
55
+ * Step 2 is the important one. A base URL pointing somewhere unreachable would
56
+ * break every AI tool on the machine, and "our measurement tool broke Claude
57
+ * Code" is the single worst outcome this product can produce - worse by far than
58
+ * missing a day of data. So the failure mode is silence: the user's tools talk
59
+ * straight to the provider exactly as they did before, and we record nothing.
60
+ * That is rule 10 and it is not negotiable.
61
+ *
62
+ * ── Why a shell profile at all ────────────────────────────────────────────
63
+ *
64
+ * Because the requirement is that plain `claude` is instrumented. A wrapper
65
+ * command would be less invasive and would not satisfy that. The line is
66
+ * fenced with markers, `comprism uninstall` removes it exactly, and the install
67
+ * says out loud what it touched.
68
+ */
69
+ const node_fs_1 = __importDefault(require("node:fs"));
70
+ const node_os_1 = __importDefault(require("node:os"));
71
+ const node_path_1 = __importDefault(require("node:path"));
72
+ const node_child_process_1 = require("node:child_process");
73
+ const config_1 = require("../lib/config");
74
+ const connection_1 = require("../lib/connection");
75
+ /** The header the tenant reads to decide whether this machine may be served. */
76
+ exports.WORKSPACE_KEY_HEADER = 'x-comprism-key';
77
+ const MARKER_START = '# >>> comprism >>>';
78
+ const MARKER_END = '# <<< comprism <<<';
79
+ function envScriptPath() {
80
+ return node_path_1.default.join((0, config_1.homeDir)(), 'env.sh');
81
+ }
82
+ function pidFilePath() {
83
+ return node_path_1.default.join((0, config_1.homeDir)(), 'proxy.pid');
84
+ }
85
+ function proxyLogPath() {
86
+ return node_path_1.default.join((0, config_1.homeDir)(), 'proxy.log');
87
+ }
88
+ /** Every profile we should add the line to, that actually exists. */
89
+ function shellProfiles() {
90
+ const home = node_os_1.default.homedir();
91
+ const candidates = ['.zshrc', '.bashrc', '.bash_profile', '.profile'];
92
+ const found = candidates
93
+ .map((f) => node_path_1.default.join(home, f))
94
+ .filter((p) => node_fs_1.default.existsSync(p));
95
+ // A machine with none of them: create the one matching the login shell rather
96
+ // than silently doing nothing.
97
+ if (found.length === 0) {
98
+ const shell = process.env.SHELL || '';
99
+ return [node_path_1.default.join(home, shell.includes('bash') ? '.bashrc' : '.zshrc')];
100
+ }
101
+ return found;
102
+ }
103
+ /**
104
+ * The tenant's own endpoint, and never anything on this machine.
105
+ *
106
+ * Three sources, most specific first: what the caller passed, the connection
107
+ * `comprism login` stored, and the tenant and domain in config. Null when none
108
+ * of them names a tenant, and null is load-bearing - the install then writes a
109
+ * script that exports nothing rather than one that guesses a hostname.
110
+ */
111
+ function tenantBaseUrl(explicit) {
112
+ const clean = (url) => url.replace(/\/+$/, '');
113
+ if (explicit)
114
+ return clean(explicit);
115
+ const connection = (0, connection_1.readConnection)();
116
+ if (connection?.url)
117
+ return clean(connection.url);
118
+ const cfg = (0, config_1.loadConfig)();
119
+ const portal = cfg.footer?.portal || {};
120
+ const slug = (cfg.footer?.tenant || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
121
+ const domain = portal.domain || 'completionprism.illuminis.ai';
122
+ return slug ? `https://${slug}.${domain}` : null;
123
+ }
124
+ /**
125
+ * The script every new shell sources.
126
+ *
127
+ * Written as POSIX shell so it works in bash and zsh alike, and deliberately
128
+ * silent: a shell startup file that prints things is a shell startup file people
129
+ * delete.
130
+ *
131
+ * `OPENAI_BASE_URL` is deliberately NOT set. The hosted interceptor speaks the
132
+ * Anthropic shape today, so pointing an OpenAI client at it would send that
133
+ * client somewhere with no route for it - breaking a working tool in order to
134
+ * measure it, which is the one thing this file exists to prevent. It comes back
135
+ * the day there is an OpenAI-shaped endpoint to point at.
136
+ */
137
+ function envScript(baseUrl, workspaceKey) {
138
+ if (!baseUrl) {
139
+ return `#!/bin/sh
140
+ # Managed by comprism. Edit nothing here; run \`comprism install\` to regenerate.
141
+ #
142
+ # No tenant is connected yet, so this exports nothing and your tools are
143
+ # untouched. Run \`comprism login\` and then \`comprism install\` again.
144
+ `;
145
+ }
146
+ // A base URL with no workspace key points every AI tool on this machine at a
147
+ // door that will refuse it. That is worse than not measuring: it breaks work
148
+ // in order to measure it, which rule 10 forbids outright. So an unenrolled
149
+ // machine exports NOTHING and talks to the provider exactly as before.
150
+ if (!workspaceKey) {
151
+ return `#!/bin/sh
152
+ # Managed by comprism. Edit nothing here; run \`comprism install\` to regenerate.
153
+ #
154
+ # This machine is not enrolled in a CompletionPrism workspace, so it exports
155
+ # nothing and your tools are untouched. Run \`comprism login\` to enroll it.
156
+ `;
157
+ }
158
+ return `#!/bin/sh
159
+ # Managed by comprism. Edit nothing here; run \`comprism install\` to regenerate.
160
+ #
161
+ # Points AI clients at your CompletionPrism tenant so your work is measured.
162
+ # If the tenant is not reachable, this exports NOTHING and your tools talk
163
+ # straight to the provider exactly as they would without us. Measurement is
164
+ # never worth breaking someone's work over.
165
+
166
+ __comprism_alive() {
167
+ curl -s -m 2 -o /dev/null "${baseUrl}/health" 2>/dev/null
168
+ }
169
+
170
+ if __comprism_alive; then
171
+ ANTHROPIC_BASE_URL="${baseUrl}"
172
+ export ANTHROPIC_BASE_URL
173
+
174
+ # Who this machine is, presented on every request. Anthropic clients read
175
+ # ANTHROPIC_CUSTOM_HEADERS as "Name: value" lines, so nothing has to be pasted
176
+ # into a tool's own configuration. An existing value is added to rather than
177
+ # replaced: it may well be carrying a corporate proxy's header, and taking that
178
+ # away to install ourselves would break the very thing we promise not to.
179
+ __comprism_header="${exports.WORKSPACE_KEY_HEADER}: ${workspaceKey}"
180
+ case "$ANTHROPIC_CUSTOM_HEADERS" in
181
+ *"${exports.WORKSPACE_KEY_HEADER}"*) : ;;
182
+ "") ANTHROPIC_CUSTOM_HEADERS="$__comprism_header" ;;
183
+ *) ANTHROPIC_CUSTOM_HEADERS="$ANTHROPIC_CUSTOM_HEADERS
184
+ $__comprism_header" ;;
185
+ esac
186
+ export ANTHROPIC_CUSTOM_HEADERS
187
+ unset __comprism_header
188
+ fi
189
+
190
+ unset -f __comprism_alive 2>/dev/null || true
191
+ `;
192
+ }
193
+ /**
194
+ * Where the desktop client keeps its connector list, per platform.
195
+ *
196
+ * Only the directory the client itself created counts. Creating it ourselves on
197
+ * a machine with no desktop client would leave a config file for an application
198
+ * that is not installed, which is litter.
199
+ */
200
+ function desktopConfigPath() {
201
+ const home = node_os_1.default.homedir();
202
+ if (process.platform === 'darwin') {
203
+ return node_path_1.default.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
204
+ }
205
+ if (process.platform === 'win32') {
206
+ return node_path_1.default.join(process.env.APPDATA || node_path_1.default.join(home, 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json');
207
+ }
208
+ return node_path_1.default.join(home, '.config', 'Claude', 'claude_desktop_config.json');
209
+ }
210
+ /**
211
+ * Take our STDIO entry back out of Claude Desktop's config, and say what to add
212
+ * instead.
213
+ *
214
+ * This used to REGISTER a stdio server here - `node cli.js mcp` - and that was
215
+ * wrong in a way that cost a real afternoon. That server exposes `record_work`,
216
+ * `completion_receipt` and `coverage`: it only ever OBSERVES. It does not route,
217
+ * it does not answer, so there is no receipt for the client to render, and it
218
+ * appends to `~/.comprism/ledger.jsonl` on this laptop rather than to the
219
+ * tenant, so nothing it records can ever appear in the portal. The install then
220
+ * printed "Claude Desktop connector registered and answering", which was true of
221
+ * the handshake and false about the product.
222
+ *
223
+ * The connector that works is the hosted one, `{tenant}/mcp`, which runs the
224
+ * same nine steps as every other surface, returns the answer with the receipt
225
+ * attached, and writes the turn to the tenant. It is added by pasting its
226
+ * address into Claude Desktop's own Connectors dialog, because a remote
227
+ * connector is authorized by an OAuth consent screen that only the person
228
+ * sitting there can approve - there is nothing an installer could write into a
229
+ * file to stand in for that.
230
+ *
231
+ * So this now does the honest thing: removes a stale entry of ours if one is
232
+ * there, never writes one, and returns the address for the caller to print. Two
233
+ * things it still will never do: create the file on a machine where the desktop
234
+ * client is not installed, and overwrite a config it could not parse.
235
+ */
236
+ function installDesktopConnector(_binary) {
237
+ const configPath = desktopConfigPath();
238
+ const dir = node_path_1.default.dirname(configPath);
239
+ if (!node_fs_1.default.existsSync(dir)) {
240
+ return {
241
+ configPath,
242
+ present: false,
243
+ registered: false,
244
+ staleRemoved: false,
245
+ note: 'Claude Desktop is not installed on this machine, so there is nothing to add here.',
246
+ };
247
+ }
248
+ if (!node_fs_1.default.existsSync(configPath)) {
249
+ return { configPath, present: true, registered: false, staleRemoved: false };
250
+ }
251
+ let config;
252
+ try {
253
+ config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf8'));
254
+ }
255
+ catch {
256
+ return {
257
+ configPath,
258
+ present: true,
259
+ registered: false,
260
+ staleRemoved: false,
261
+ note: 'The desktop config could not be read as JSON, so it was left exactly as it is. '
262
+ + 'Nothing was changed.',
263
+ };
264
+ }
265
+ // Only ever REMOVE, and only our own key. An earlier version of this installer
266
+ // wrote a stdio server here that could not produce a receipt or reach the
267
+ // tenant; leaving it behind means the client keeps offering the wrong tools
268
+ // beside the right ones, and the person cannot tell which is which.
269
+ const servers = config.mcpServers;
270
+ if (!servers || !servers.completionprism) {
271
+ return { configPath, present: true, registered: false, staleRemoved: false };
272
+ }
273
+ delete servers.completionprism;
274
+ if (Object.keys(servers).length === 0)
275
+ delete config.mcpServers;
276
+ else
277
+ config.mcpServers = servers;
278
+ node_fs_1.default.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
279
+ return { configPath, present: true, registered: false, staleRemoved: true };
280
+ }
281
+ /**
282
+ * Start the connector exactly as the desktop client will, and see if it answers.
283
+ *
284
+ * Written after a live failure that the installer had already called a success.
285
+ * `npm install -g .` from a checkout leaves the global package as a SYMLINK back
286
+ * into the source folder; on macOS the desktop client is sandboxed out of
287
+ * `~/Documents`, so the connector died on `EPERM` the moment it was launched -
288
+ * and the only trace was a log file nobody thinks to open. The installer said
289
+ * "registered", which was true and useless.
290
+ *
291
+ * So registration is no longer the claim. This spawns the real command, speaks
292
+ * the real handshake and waits for the real reply. A connector that cannot start
293
+ * is reported as broken, with the reason, at install time.
294
+ */
295
+ async function probeDesktopConnector(binary) {
296
+ const { command, args: baseArgs } = binary;
297
+ return new Promise((resolve) => {
298
+ let settled = false;
299
+ let child;
300
+ const done = (ok, detail) => {
301
+ if (settled)
302
+ return;
303
+ settled = true;
304
+ try {
305
+ child?.kill();
306
+ }
307
+ catch {
308
+ /* already gone */
309
+ }
310
+ resolve({ ok, detail });
311
+ };
312
+ try {
313
+ child = (0, node_child_process_1.spawn)(command, [...baseArgs, 'mcp'], {
314
+ stdio: ['pipe', 'pipe', 'pipe'],
315
+ });
316
+ }
317
+ catch (err) {
318
+ resolve({
319
+ ok: false,
320
+ detail: err instanceof Error ? err.message : 'the connector could not be started',
321
+ });
322
+ return;
323
+ }
324
+ let stdout = '';
325
+ let stderr = '';
326
+ child.stdout?.on('data', (d) => {
327
+ stdout += String(d);
328
+ if (stdout.includes('"serverInfo"'))
329
+ done(true, 'answered the handshake');
330
+ });
331
+ child.stderr?.on('data', (d) => {
332
+ stderr += String(d);
333
+ });
334
+ child.on('error', (err) => done(false, err.message));
335
+ child.on('exit', () => {
336
+ // The first meaningful line of a crash is the useful one; the stack is
337
+ // noise in an install report.
338
+ const first = stderr.split('\n').find((l) => /Error|error|EPERM|not found/.test(l));
339
+ done(false, first?.trim() || 'the connector exited without answering');
340
+ });
341
+ child.stdin?.write(`${JSON.stringify({
342
+ jsonrpc: '2.0',
343
+ id: 1,
344
+ method: 'initialize',
345
+ params: {
346
+ protocolVersion: '2025-06-18',
347
+ capabilities: {},
348
+ clientInfo: { name: 'comprism-install-probe', version: '1' },
349
+ },
350
+ })}\n`);
351
+ setTimeout(() => done(false, 'the connector did not answer within three seconds'), 3000);
352
+ });
353
+ }
354
+ /** Take the connector back out, leaving every other server alone. */
355
+ function uninstallDesktopConnector() {
356
+ const configPath = desktopConfigPath();
357
+ if (!node_fs_1.default.existsSync(configPath))
358
+ return false;
359
+ let config;
360
+ try {
361
+ config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf8'));
362
+ }
363
+ catch {
364
+ return false;
365
+ }
366
+ const servers = config.mcpServers;
367
+ if (!servers || !servers.completionprism)
368
+ return false;
369
+ delete servers.completionprism;
370
+ node_fs_1.default.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
371
+ return true;
372
+ }
373
+ /**
374
+ * Do the setup. Idempotent: running it twice changes nothing the second time.
375
+ */
376
+ function install(options = {}) {
377
+ (0, config_1.ensureHome)();
378
+ // Prefer the globally installed binary; fall back to however we were invoked,
379
+ // so this works from a checkout as well as from an npm install.
380
+ const binary = options.binary ?? resolveBinary();
381
+ const baseUrl = tenantBaseUrl(options.baseUrl);
382
+ // Enrollment, not authentication: the tenant refuses interception to a machine
383
+ // that cannot say which workspace it belongs to, so this decides whether we
384
+ // configure anything at all.
385
+ const workspaceKey = options.workspaceKey ?? (0, connection_1.readConnection)()?.workspaceKey ?? null;
386
+ const scriptPath = envScriptPath();
387
+ node_fs_1.default.writeFileSync(scriptPath, envScript(baseUrl, workspaceKey), { mode: 0o700 });
388
+ const line = `${MARKER_START}\n[ -f "${scriptPath}" ] && . "${scriptPath}"\n${MARKER_END}`;
389
+ const touched = [];
390
+ const already = [];
391
+ for (const profile of shellProfiles()) {
392
+ let contents = '';
393
+ try {
394
+ contents = node_fs_1.default.readFileSync(profile, 'utf8');
395
+ }
396
+ catch {
397
+ contents = '';
398
+ }
399
+ if (contents.includes(MARKER_START)) {
400
+ // Replace the fenced block rather than appending a second one.
401
+ const next = contents.replace(new RegExp(`${MARKER_START}[\\s\\S]*?${MARKER_END}`), line);
402
+ if (next !== contents)
403
+ node_fs_1.default.writeFileSync(profile, next);
404
+ already.push(profile);
405
+ continue;
406
+ }
407
+ node_fs_1.default.appendFileSync(profile, `\n${line}\n`);
408
+ touched.push(profile);
409
+ }
410
+ return {
411
+ envScript: scriptPath,
412
+ profilesTouched: touched,
413
+ profilesAlreadyHad: already,
414
+ baseUrl,
415
+ enrolled: Boolean(workspaceKey),
416
+ binary,
417
+ // Absolute, deliberately: the desktop client is launched by launchd and
418
+ // has neither our shim nor node on its PATH. See `resolveDesktopBinary`.
419
+ desktop: installDesktopConnector(resolveDesktopBinary()),
420
+ };
421
+ }
422
+ /** Take the line back out. An install you cannot cleanly undo is a liability. */
423
+ function uninstall() {
424
+ const cleaned = [];
425
+ for (const profile of shellProfiles()) {
426
+ let contents = '';
427
+ try {
428
+ contents = node_fs_1.default.readFileSync(profile, 'utf8');
429
+ }
430
+ catch {
431
+ continue;
432
+ }
433
+ if (!contents.includes(MARKER_START))
434
+ continue;
435
+ const next = contents
436
+ .replace(new RegExp(`\\n?${MARKER_START}[\\s\\S]*?${MARKER_END}\\n?`), '\n')
437
+ .replace(/\n{3,}/g, '\n\n');
438
+ node_fs_1.default.writeFileSync(profile, next);
439
+ cleaned.push(profile);
440
+ }
441
+ uninstallDesktopConnector();
442
+ return { profilesCleaned: cleaned };
443
+ }
444
+ /** The shell form, quoted, for the profile script that must be a shell string. */
445
+ function launchShellString(launch) {
446
+ return [launch.command, ...launch.args]
447
+ .map((part) => (/[\s"'\\]/.test(part) ? `"${part.replace(/(["\\])/g, '\\$1')}"` : part))
448
+ .join(' ');
449
+ }
450
+ function resolveBinary() {
451
+ const compiled = node_path_1.default.join(__dirname, 'cli.js');
452
+ if (!node_fs_1.default.existsSync(compiled)) {
453
+ // Not running from a build - fall back to the name on PATH.
454
+ return { command: 'comprism', args: [] };
455
+ }
456
+ // Prefer the bare name when the shim on PATH is this same package, so the
457
+ // config keeps working if the package moves. Verified rather than assumed:
458
+ // a `comprism` belonging to some other install would be worse than a path.
459
+ for (const dir of (process.env.PATH || '').split(node_path_1.default.delimiter)) {
460
+ const shim = node_path_1.default.join(dir, 'comprism');
461
+ try {
462
+ if (node_fs_1.default.existsSync(shim) && node_fs_1.default.realpathSync(shim) === node_fs_1.default.realpathSync(compiled)) {
463
+ return { command: 'comprism', args: [] };
464
+ }
465
+ }
466
+ catch {
467
+ continue;
468
+ }
469
+ }
470
+ return { command: process.execPath, args: [compiled] };
471
+ }
472
+ /**
473
+ * How to start the CLI from an application launched by the Dock.
474
+ *
475
+ * ALWAYS absolute, never the bare name, and that is the difference between a
476
+ * connector that works and one that says "Server disconnected" forever.
477
+ *
478
+ * A desktop client is started by launchd, not by a shell. It inherits none of
479
+ * `~/.zshrc`, so `PATH` is `/usr/bin:/bin:/usr/sbin:/sbin` and `comprism` is
480
+ * not on it. Neither is `node`, which matters just as much: the shim's shebang
481
+ * is `#!/usr/bin/env node`, so even an absolute path to `cli.js` would fail to
482
+ * start. Naming the interpreter AND the script outright is the only form that
483
+ * does not depend on an environment we do not control.
484
+ *
485
+ * The shell profile keeps using `resolveBinary()`, where the bare name is
486
+ * genuinely better because it survives the package moving.
487
+ */
488
+ function resolveDesktopBinary() {
489
+ const compiled = node_path_1.default.join(__dirname, 'cli.js');
490
+ if (!node_fs_1.default.existsSync(compiled))
491
+ return resolveBinary();
492
+ return { command: process.execPath, args: [compiled] };
493
+ }
494
+ // ── the background service ──────────────────────────────────────────────────
495
+ async function proxyHealthy(port) {
496
+ try {
497
+ const res = await fetch(`http://127.0.0.1:${port}/__comprism/health`, {
498
+ signal: AbortSignal.timeout(1000),
499
+ });
500
+ return res.ok;
501
+ }
502
+ catch {
503
+ return false;
504
+ }
505
+ }
506
+ /**
507
+ * Start the proxy detached, so it outlives the shell that started it.
508
+ *
509
+ * Returns whether it came up. Never throws: a service that fails to start is a
510
+ * missing measurement, and the caller's job is to carry on regardless.
511
+ */
512
+ async function startDaemon(port) {
513
+ if (await proxyHealthy(port))
514
+ return true;
515
+ (0, config_1.ensureHome)();
516
+ const out = node_fs_1.default.openSync(proxyLogPath(), 'a');
517
+ const child = (0, node_child_process_1.spawn)(process.execPath, [process.argv[1], 'proxy', '--port', String(port)], {
518
+ detached: true,
519
+ stdio: ['ignore', out, out],
520
+ });
521
+ child.unref();
522
+ if (child.pid)
523
+ node_fs_1.default.writeFileSync(pidFilePath(), String(child.pid));
524
+ for (let i = 0; i < 25; i++) {
525
+ if (await proxyHealthy(port))
526
+ return true;
527
+ await new Promise((r) => setTimeout(r, 120));
528
+ }
529
+ return false;
530
+ }
531
+ function stopDaemon() {
532
+ try {
533
+ const pid = Number(node_fs_1.default.readFileSync(pidFilePath(), 'utf8').trim());
534
+ if (!pid)
535
+ return false;
536
+ process.kill(pid, 'SIGTERM');
537
+ node_fs_1.default.rmSync(pidFilePath(), { force: true });
538
+ return true;
539
+ }
540
+ catch {
541
+ return false;
542
+ }
543
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * What this workspace can reach, and whether we were able to ask.
3
+ *
4
+ * ── Why this returns three states and not a boolean ───────────────────────
5
+ *
6
+ * It used to return a boolean, and the boolean was the fault reported on
7
+ * 17 September 2026. The workspace could not be reached, the question therefore
8
+ * went unanswered, the local scan found nothing, and `false` came back. The
9
+ * session read `false` as "this account has no provider key" and walked a
10
+ * person with FOUR stored, active, working keys into a form asking them to
11
+ * paste one.
12
+ *
13
+ * "Nobody answered" and "the answer is none" are different facts and they need
14
+ * different words on screen, so they are different values here.
15
+ */
16
+ export type KeyState =
17
+ /** The workspace answered and holds at least one vendor key. */
18
+ {
19
+ reached: true;
20
+ hasKey: true;
21
+ }
22
+ /** The workspace answered and holds none. A real, actionable answer. */
23
+ | {
24
+ reached: true;
25
+ hasKey: false;
26
+ }
27
+ /** We never heard back, so NOTHING is known about the account's keys. */
28
+ | {
29
+ reached: false;
30
+ why: string;
31
+ };
32
+ /**
33
+ * What the workspace says it can reach.
34
+ *
35
+ * **The workspace is the only source of truth.** This function does not look at
36
+ * the machine, and no caller may treat a key found on the machine as an answer
37
+ * to this question. A key sitting in somebody's shell is not a key the account
38
+ * holds: it works for that one shell, it is invisible to the portal, to the
39
+ * editor and to their colleagues, and a product that counts it tells four
40
+ * different people four different things about the same account.
41
+ *
42
+ * The machine is still SEARCHED, but only in `offerLocalImport` below, and only
43
+ * once this has answered `{ reached: true, hasKey: false }`. That is the owner's
44
+ * rule of 17 September 2026: look on the computer when the account has nothing,
45
+ * and when something is found, store it on the server through the one service.
46
+ */
47
+ export declare function keyState(): Promise<KeyState>;
48
+ /**
49
+ * Kept for callers that genuinely only need a yes.
50
+ *
51
+ * Unreachable counts as NO here, and every caller must have already reported
52
+ * the unreachable case in its own words before calling this. Prefer `keyState`.
53
+ */
54
+ export declare function hasAnyKey(): Promise<boolean>;
55
+ export declare function cmdKeys(args: string[]): Promise<void>;