@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.
- package/LICENSE +15 -0
- package/README.md +281 -0
- package/out/agent/command.d.ts +86 -0
- package/out/agent/command.js +259 -0
- package/out/agent/render.d.ts +97 -0
- package/out/agent/render.js +255 -0
- package/out/agent/session.d.ts +175 -0
- package/out/agent/session.js +573 -0
- package/out/commands/ask.d.ts +1 -0
- package/out/commands/ask.js +146 -0
- package/out/commands/codemap.d.ts +2 -0
- package/out/commands/codemap.js +151 -0
- package/out/commands/commands-thin.d.ts +39 -0
- package/out/commands/commands-thin.js +182 -0
- package/out/commands/install.d.ts +163 -0
- package/out/commands/install.js +543 -0
- package/out/commands/keys.d.ts +55 -0
- package/out/commands/keys.js +344 -0
- package/out/commands/login.d.ts +9 -0
- package/out/commands/login.js +384 -0
- package/out/commands/repl.d.ts +1 -0
- package/out/commands/repl.js +752 -0
- package/out/commands/settings.d.ts +21 -0
- package/out/commands/settings.js +244 -0
- package/out/commands/welcome.d.ts +1 -0
- package/out/commands/welcome.js +196 -0
- package/out/executor/documents.d.ts +40 -0
- package/out/executor/documents.js +170 -0
- package/out/executor/files.d.ts +2 -0
- package/out/executor/files.js +360 -0
- package/out/executor/git.d.ts +48 -0
- package/out/executor/git.js +132 -0
- package/out/executor/hooks.d.ts +67 -0
- package/out/executor/hooks.js +247 -0
- package/out/executor/index.d.ts +29 -0
- package/out/executor/index.js +221 -0
- package/out/executor/notebook.d.ts +2 -0
- package/out/executor/notebook.js +147 -0
- package/out/executor/paths.d.ts +15 -0
- package/out/executor/paths.js +126 -0
- package/out/executor/shell.d.ts +41 -0
- package/out/executor/shell.js +336 -0
- package/out/graph/build.d.ts +45 -0
- package/out/graph/build.js +91 -0
- package/out/graph/facts.d.ts +47 -0
- package/out/graph/facts.js +12 -0
- package/out/graph/files.d.ts +45 -0
- package/out/graph/files.js +207 -0
- package/out/graph/read-locales.d.ts +29 -0
- package/out/graph/read-locales.js +246 -0
- package/out/graph/read-python.d.ts +11 -0
- package/out/graph/read-python.js +115 -0
- package/out/graph/read-typescript.d.ts +16 -0
- package/out/graph/read-typescript.js +292 -0
- package/out/graph/sync.d.ts +66 -0
- package/out/graph/sync.js +242 -0
- package/out/lib/attach.d.ts +62 -0
- package/out/lib/attach.js +228 -0
- package/out/lib/config.d.ts +93 -0
- package/out/lib/config.js +198 -0
- package/out/lib/connection.d.ts +73 -0
- package/out/lib/connection.js +188 -0
- package/out/lib/gateway.d.ts +239 -0
- package/out/lib/gateway.js +171 -0
- package/out/lib/prompt.d.ts +34 -0
- package/out/lib/prompt.js +108 -0
- package/out/lib/types.d.ts +417 -0
- package/out/lib/types.js +21 -0
- package/out/lib/ui.d.ts +114 -0
- package/out/lib/ui.js +265 -0
- package/out/lib/version.d.ts +24 -0
- package/out/lib/version.js +27 -0
- package/out/lib/voice.d.ts +50 -0
- package/out/lib/voice.js +218 -0
- package/out/postinstall.d.ts +2 -0
- package/out/postinstall.js +92 -0
- package/out/thin.d.ts +2 -0
- package/out/thin.js +259 -0
- package/package.json +101 -0
- 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>;
|