clearotron 0.3.2-beta.6 → 0.3.2-beta.8
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/.env.example +24 -23
- package/INSTALL.md +142 -75
- package/README.md +3 -3
- package/bin/onboard.mjs +637 -216
- package/bin/start.mjs +133 -23
- package/bin/update.mjs +82 -11
- package/build-info.json +2 -2
- package/docs/architecture/04-configuration-reference.md +26 -11
- package/docs/architecture/05-config-governance.md +17 -7
- package/driver/CHANGELOG.md +83 -0
- package/driver/band-size.mjs +59 -0
- package/driver/config-inventory.mjs +112 -9
- package/driver/connotation-search.mjs +45 -0
- package/driver/contract-arm2-baseline.json +1 -3
- package/driver/contract-e3-backlog.mjs +29 -29
- package/driver/contract-vocabulary.mjs +59 -25
- package/driver/door-gates.mjs +41 -7
- package/driver/driver.config.mjs +272 -59
- package/driver/engine/CONTRACT.md +10 -3
- package/driver/engine/README.md +2 -2
- package/driver/engine/anthropic-agent.mjs +77 -21
- package/driver/engine/auth.mjs +129 -10
- package/driver/engine/jx-turn.mjs +7 -6
- package/driver/engine/mcp/recording-server.mjs +13 -0
- package/driver/engine/openai-agent.mjs +4 -2
- package/driver/engine/probe.mjs +110 -23
- package/driver/findings-model.mjs +1 -1
- package/driver/flag-snapshot.mjs +28 -5
- package/driver/gateway.mjs +30 -21
- package/driver/jx-lanes.mjs +21 -2
- package/driver/jx-units.mjs +6 -3
- package/driver/jx.mjs +4 -2
- package/driver/matter-frame-record.mjs +90 -1
- package/driver/named-band.mjs +34 -2
- package/driver/package.json +1 -1
- package/driver/pipeline.mjs +391 -26
- package/driver/portal-config-view.mjs +30 -1
- package/driver/portal-report.mjs +15 -1
- package/driver/portal-service.mjs +46 -6
- package/driver/predelivery-lint.mjs +12 -2
- package/driver/publish/index.mjs +46 -5
- package/driver/publish/knockout.mjs +10 -1
- package/driver/publish/render-knockout.mjs +69 -7
- package/driver/publish/render.mjs +170 -59
- package/driver/publish/report-data.mjs +4 -1
- package/driver/publish/report-topbar.mjs +58 -0
- package/driver/publish/templates/report.css +28 -2
- package/driver/publish/xlsx.mjs +13 -1
- package/driver/register-availability.mjs +2 -2
- package/driver/register-coverage.mjs +94 -1
- package/driver/register-digest-record.mjs +236 -11
- package/driver/register-plan.mjs +170 -0
- package/driver/result-noun-fields.mjs +7 -4
- package/driver/run-economics.mjs +41 -10
- package/driver/run-requirements.mjs +173 -9
- package/driver/runner.mjs +3 -3
- package/driver/stages.mjs +12 -8
- package/driver/suite-census.json +162 -72
- package/driver/systemd/README.md +7 -4
- package/driver/terminal-clamp.mjs +107 -1
- package/driver/tokens.mjs +169 -3
- package/driver/unit-environment.mjs +42 -15
- package/driver/unit-inventory.mjs +19 -2
- package/driver/verify.mjs +50 -5
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/http-server.mjs +4 -0
- package/mcp-server/lib/audit.mjs +11 -1
- package/mcp-server/lib/http-handler.mjs +6 -2
- package/mcp-server/package.json +1 -1
- package/mcp-server/server.mjs +16 -2
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-5UyqAyNM.js → index-6jzO9HiX.js} +155 -79
- package/portal-ui/dist/index.html +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/clarivate/src/capabilities.js +5 -5
- package/providers/clarivate/src/core.js +1 -1
- package/providers/corsearch/src/core.js +2 -2
- package/providers/jx/README.md +2 -1
- package/providers/jx/src/turn-envelope.mjs +8 -3
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/perplexity/src/core.js +3 -3
- package/providers/signa/src/capabilities.js +5 -6
- package/providers/signa/src/core.js +1 -1
- package/providers/uspto-local/README.md +1 -1
- package/scripts/authority-boundary-probe.mjs +4 -2
- package/scripts/env-audit.mjs +12 -6
- package/scripts/freeze-example-run.mjs +49 -16
- package/scripts/generated-files-are-current.mjs +69 -4
- package/scripts/release-duplicate-notes.mjs +246 -0
- package/scripts/release-publish-guard.mjs +64 -6
- package/scripts/report-print-check.mjs +194 -0
- package/scripts/settings-render-check.mjs +75 -2
- package/scripts/test-full.mjs +96 -3
- package/scripts/test-run.mjs +10 -0
- package/shared/deployment-box.mjs +7 -2
- package/shared/driver-dir.mjs +1 -1
- package/shared/names-in-force.mjs +1 -1
package/bin/onboard.mjs
CHANGED
|
@@ -97,8 +97,9 @@ import {
|
|
|
97
97
|
// does nothing at import time, so this is inert — the ONE sharp edge it carries (a module-level
|
|
98
98
|
// REGISTER_PROVIDER frozen at first import) is the one `preflightCandidate` below already cache-busts
|
|
99
99
|
// around, and it is cache-busted whether or not this static import happened first.
|
|
100
|
-
import { config, ENGINE_BINARIES, DEFAULT_ENGINE_ID, RESEARCH_PROVIDERS, SERP_PROVIDERS
|
|
101
|
-
|
|
100
|
+
import { config, ENGINE_BINARIES, DEFAULT_ENGINE_ID, RESEARCH_PROVIDERS, SERP_PROVIDERS, resolveEngineProgram, ON_A_WINDOWS_DRIVE,
|
|
101
|
+
enginesFolder, engineInstallArgs, engineInstallCommand } from "../driver/driver.config.mjs";
|
|
102
|
+
import { resolveAuthMode, CLOUD_SWITCH, CLOUD_SETTINGS, CLOUD_SECRETS, cloudsSwitchedOn } from "../driver/engine/auth.mjs";
|
|
102
103
|
import { isInsideCheckout } from "../shared/inside-checkout.mjs"; // — one copy of the rule, and it is testable
|
|
103
104
|
import { packagedBuild as sharedPackagedBuild } from "../shared/packaged-build.mjs"; // — one reader of build-info.json, reachable from the driver
|
|
104
105
|
import { processTable } from "../shared/process-table.mjs"; // — /proc is not the only box
|
|
@@ -107,7 +108,8 @@ import { entrypointOf } from "../driver/systemd/install-census.mjs"; //
|
|
|
107
108
|
import { overlayReport, renderOverlayReport } from "../shared/doctrine-overlay.mjs"; // — the doctor reports the overlay
|
|
108
109
|
import { whereSavesGo, storeCommitRefusal, storeInRepo, storeOutsideRepoMessage, resolveStoreRepoRoot } from "../shared/store-in-repo.mjs"; // — doctor says where a portal save goes once it is committed, and why saved searches are off
|
|
109
110
|
import { engineInventory, engineMode, ENGINE_MODES } from "../driver/config-inventory.mjs"; //
|
|
110
|
-
import { probeEngineTurn, probeFailureText, PROBE_TIMEOUT_SEC, engineEnvKeys } from "../driver/engine/probe.mjs";
|
|
111
|
+
import { probeEngineTurn, probeFailureText, PROBE_TIMEOUT_SEC, engineEnvKeys, namingProgram } from "../driver/engine/probe.mjs";
|
|
112
|
+
import { probeCliVersion } from "../driver/engine/cli-version.mjs"; // the engine question reads each program's version the way a run records it
|
|
111
113
|
|
|
112
114
|
// THE PROVING SENTENCES NAME NO MODEL. They printed the driver's tier word, which is an Anthropic model's
|
|
113
115
|
// name, on both engines, so a codex user was told setup was about to spend on a model family they do not
|
|
@@ -116,7 +118,116 @@ export const probingLine = (engineId) =>
|
|
|
116
118
|
`Probing ${engineId} with one turn on its cheapest model (this SPENDS; ${PROBE_TIMEOUT_SEC}s ceiling)…`;
|
|
117
119
|
export const proveQuestion = ({ engineId, lane }) =>
|
|
118
120
|
`Prove ${engineId} on the ${lane} lane now with one turn on its cheapest model (a few tokens, ${PROBE_TIMEOUT_SEC}s ceiling)?`;
|
|
119
|
-
|
|
121
|
+
|
|
122
|
+
// HOW CLAUDE IS PAID FOR, IN THE OWNER'S WORDS (2026-09-14): one question, three answers, the third a cloud
|
|
123
|
+
// account. Codex keeps the question it had: a cloud account bills Claude only, and the resolver refuses
|
|
124
|
+
// `cloud` on Codex, so offering it there would offer an answer that cannot run.
|
|
125
|
+
export const CLAUDE_PAY_QUESTION = "How is Claude paid for on this machine?";
|
|
126
|
+
const CLAUDE_PAY_ANSWERS = Object.freeze([
|
|
127
|
+
{ id: "subscription", label: "A Claude subscription (Pro, Max or Team): you sign in once" },
|
|
128
|
+
{ id: "api-key", label: "An Anthropic API key: pay per use, paste the key" },
|
|
129
|
+
{ id: "cloud", label: "Through your Google, Microsoft or Amazon cloud account: pay per use on that cloud's bill" },
|
|
130
|
+
]);
|
|
131
|
+
// SETUP'S FIRST QUESTION, IN THE OWNER'S WORDS (2026-09-15). It asked which program does the reasoning,
|
|
132
|
+
// which is how this code thinks of an engine and not how a reader choosing one does.
|
|
133
|
+
export const ENGINE_QUESTION = "Which AI should run your searches?";
|
|
134
|
+
/**
|
|
135
|
+
* What setup says under the engine question's rows, about the question after it. Every answer
|
|
136
|
+
* `payQuestion` offers, for any engine, is named here, and the cloud answer as Claude's alone, so on a
|
|
137
|
+
* machine that has only Codex it still reads true. The chooser prints it after the rows and before the
|
|
138
|
+
* prompt, so it is the last thing read before answering.
|
|
139
|
+
*/
|
|
140
|
+
export const PAY_PREAMBLE = Object.freeze([
|
|
141
|
+
" Next, setup asks how you pay for it: a subscription you sign in with, an API key,",
|
|
142
|
+
" or, for Claude, your own cloud account.",
|
|
143
|
+
]);
|
|
144
|
+
/**
|
|
145
|
+
* What setup says when no engine is chosen, by the menu's last row or by any route out of the engine step
|
|
146
|
+
* (sayNoEngine): what still works, what does not, and what to do about it, in one sentence.
|
|
147
|
+
*/
|
|
148
|
+
export const NO_AI_CHOSEN = "No AI chosen. The demo works without one; a real search needs one, so run setup again when you're ready.";
|
|
149
|
+
/**
|
|
150
|
+
* The screen setup's chooser prints for one question: the question, one numbered line per answer with the
|
|
151
|
+
* default marked, and any lines that belong under the answers (`after`). A function of its arguments alone,
|
|
152
|
+
* so the screen a reader sees can be asserted whole.
|
|
153
|
+
*/
|
|
154
|
+
export function menuScreen(question, options, def = 0, after = []) {
|
|
155
|
+
return ["", ` ${question}`,
|
|
156
|
+
...options.map((o, i) => ` ${i + 1}) ${o.label}${i === def ? " (default)" : ""}`),
|
|
157
|
+
...(after.length ? ["", ...after] : [])];
|
|
158
|
+
}
|
|
159
|
+
/** The pay question setup asks for an engine, and its answers; each answer's id is a billing word. */
|
|
160
|
+
export function payQuestion({ engineId, eng, bin }) {
|
|
161
|
+
if (engineId === "anthropic-agent") return { question: CLAUDE_PAY_QUESTION, answers: CLAUDE_PAY_ANSWERS };
|
|
162
|
+
return {
|
|
163
|
+
question: `How is ${eng.product ?? engineId} paid for on this machine?`,
|
|
164
|
+
answers: [
|
|
165
|
+
{ id: "subscription", label: `Subscription — ${namingProgram(eng.subscriptionHow, eng, bin)}` },
|
|
166
|
+
{ id: "api-key", label: `API key — metered per token, from ${eng.apiKeyEnv}` },
|
|
167
|
+
],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// THE THREE CLOUDS, what each is called where a reader sees it, and the least setup asks for each. The rest
|
|
172
|
+
// is a sign-in the machine already has (gcloud's, Azure's, AWS's), which the program finds by itself. Every
|
|
173
|
+
// name asked for is on auth.mjs's CLOUD_SETTINGS, so the proof turn and doctor carry it.
|
|
174
|
+
export const CLOUD_CHOICES = Object.freeze([
|
|
175
|
+
{ id: "vertex", label: "Google Cloud (Vertex AI)", account: "your Google Cloud account (Vertex AI)",
|
|
176
|
+
note: "It uses the Google sign-in on this machine: gcloud's, or the service-account key GOOGLE_APPLICATION_CREDENTIALS names.",
|
|
177
|
+
asks: [
|
|
178
|
+
{ env: "ANTHROPIC_VERTEX_PROJECT_ID", q: "Google Cloud project id:" },
|
|
179
|
+
{ env: "CLOUD_ML_REGION", q: "Region your Claude quota is in:", def: "global" },
|
|
180
|
+
] },
|
|
181
|
+
{ id: "foundry", label: "Microsoft Azure (Foundry)", account: "your Microsoft Azure account (Foundry)",
|
|
182
|
+
note: "Foundry calls each model by the name of its deployment, so give the names you deployed them under.",
|
|
183
|
+
asks: [
|
|
184
|
+
{ env: "ANTHROPIC_FOUNDRY_RESOURCE", q: "Foundry resource name:" },
|
|
185
|
+
{ env: "ANTHROPIC_FOUNDRY_API_KEY", q: "Its key:", secret: true, skippable: true, skipped: "No key: the Azure sign-in on this machine is used." },
|
|
186
|
+
// SKIPPING IS SAFE ONLY UNDER ONE CONDITION, and the line says which. With no pin the program asks
|
|
187
|
+
// Foundry for a deployment named after the model, which resolves only if the reader deployed it under
|
|
188
|
+
// exactly that name; otherwise every turn of that tier is refused (the proof turn catches it).
|
|
189
|
+
{ env: "ANTHROPIC_DEFAULT_OPUS_MODEL", q: "Your Opus deployment name:", skippable: true,
|
|
190
|
+
skipped: "Not set: the program asks for a deployment named after the model, which works only if you deployed it under that name." },
|
|
191
|
+
{ env: "ANTHROPIC_DEFAULT_SONNET_MODEL", q: "Your Sonnet deployment name:", skippable: true,
|
|
192
|
+
skipped: "Not set: the program asks for a deployment named after the model, which works only if you deployed it under that name." },
|
|
193
|
+
{ env: "ANTHROPIC_DEFAULT_HAIKU_MODEL", q: "Your Haiku deployment name:", skippable: true,
|
|
194
|
+
skipped: "Not set: the program asks for a deployment named after the model, which works only if you deployed it under that name." },
|
|
195
|
+
] },
|
|
196
|
+
// AMAZON IS OFFERED AND MARKED, because nobody has run Claude through a Bedrock account with it yet. The
|
|
197
|
+
// mark is on the menu row only: doctor's account wording (`account`) names the account, not our testing.
|
|
198
|
+
{ id: "bedrock", label: "Amazon Bedrock (not yet tested)", account: "your Amazon Bedrock account",
|
|
199
|
+
note: "It uses the AWS credentials on this machine: a profile, an instance role or the standard AWS variables.",
|
|
200
|
+
asks: [
|
|
201
|
+
{ env: "AWS_REGION", q: "AWS region your Claude models are enabled in:" },
|
|
202
|
+
] },
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The settings a cloud answer writes, as a run reads them: the billing word, that cloud's switch, and each
|
|
207
|
+
* answer given. A blank answer writes nothing, so the program falls back to what the machine has. Pure, so a
|
|
208
|
+
* test can drive it through the resolver.
|
|
209
|
+
*/
|
|
210
|
+
export function cloudSettings(cloud, answers = {}) {
|
|
211
|
+
const out = { CLEAROTRON_AI_BILLING: "cloud", [CLOUD_SWITCH[cloud]]: "1" };
|
|
212
|
+
for (const [k, v] of Object.entries(answers)) {
|
|
213
|
+
const t = String(v ?? "").trim();
|
|
214
|
+
if (t) out[k] = t;
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** A cloud setting as setup shows it after writing it: a secret (auth.mjs's CLOUD_SECRETS) as set, never its value. */
|
|
220
|
+
export const shownSetting = (k, v) => `${k}=${CLOUD_SECRETS.includes(k) ? "…" : v}`;
|
|
221
|
+
|
|
222
|
+
/** Whose bill a cloud billing mode charges, as doctor says it. */
|
|
223
|
+
export const cloudAccount = (cloud) =>
|
|
224
|
+
cloud === "gateway" ? "the gateway at ANTHROPIC_BASE_URL" : (CLOUD_CHOICES.find((c) => c.id === cloud)?.account ?? `the ${cloud} account`);
|
|
225
|
+
|
|
226
|
+
/** What a completed probe turn says served it, as the program reported; null when it named nothing. */
|
|
227
|
+
export const servedLine = (v) => (v?.served || v?.provider)
|
|
228
|
+
? `served by ${v.served ?? "a model the program did not name"}${v.provider ? `; the program names its provider "${v.provider}"` : ""}.`
|
|
229
|
+
: null;
|
|
230
|
+
import { runRequiredNames, missingRequirements, billingRefusalWords, REGISTER_ENV, ENGINE_ENV } from "../driver/run-requirements.mjs"; // the order-time gate's own question, asked here rather than restated
|
|
120
231
|
import { pinEnv, envFrom } from "../shared/env-aliases.mjs";
|
|
121
232
|
import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-point test, all spellings
|
|
122
233
|
// one synopsis reader for every verb that prints one.
|
|
@@ -679,44 +790,125 @@ export async function askSignIn(io, { localAccount = "user", existing = null } =
|
|
|
679
790
|
* Was `resolveClaudeBin`. The body never had anything claude-specific in it; the NAME was the last place
|
|
680
791
|
* this file still assumed one engine, and a name that lies is how the second adapter stayed invisible.
|
|
681
792
|
*/
|
|
682
|
-
export function resolveEngineBin(bin, { env = process.env, wsl = null, onWindowsDrive = null } = {}) {
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
|
|
687
|
-
// driver/engine/anthropic-agent.mjs — `CLEAROTRON_CLAUDE_PATH || "claude"`; openai-agent.mjs — the same
|
|
688
|
-
// shape on `CLEAROTRON_CODEX_PATH || "codex"`. A RELATIVE path is the trap for BOTH: stage subprocesses are
|
|
689
|
-
// spawned with cwd set to the RUN DIRECTORY (driver/engine/common.mjs resolveSpawnCwd, shared by the
|
|
690
|
-
// two adapters), so a relative binary resolves against a directory that did not exist at setup time.
|
|
691
|
-
const underWsl = wsl ?? isWsl({ env });
|
|
692
|
-
if (bin.includes("/")) {
|
|
693
|
-
const abs = resolve(bin);
|
|
694
|
-
// A PATH SOMEBODY TYPED IS NOT OVERRULED, only reported. The reader stated this one, and silently
|
|
695
|
-
// resolving somewhere else would be the launcher moving a door off a port that was asked for.
|
|
696
|
-
return { path: abs, executable: isExec(abs), relative: !isAbsolute(bin), windowsShim: underWsl && onDrive(abs), skipped: [] };
|
|
697
|
-
}
|
|
698
|
-
// ── UNDER WSL, THE WINDOWS PATH IS APPENDED TO THIS ONE ────────────────────────────────────────
|
|
699
|
-
//
|
|
700
|
-
// So `claude` on a fresh WSL2 Ubuntu resolves to /mnt/c/…/claude — the WINDOWS shim — before any
|
|
701
|
-
// Linux install is reached, and it is executable by every test this makes. It then fails the proof
|
|
702
|
-
// turn as "not signed in", because the credential it is looking for is the Linux one, and a reader
|
|
703
|
-
// is sent to fix a sign-in that was never the problem. Reported from a real WSL2 attempt.
|
|
793
|
+
export function resolveEngineBin(bin, { env = process.env, wsl = null, onWindowsDrive = null, engine = null, enginesDir = undefined } = {}) {
|
|
794
|
+
// A VIEW OVER THE DRIVER'S ONE RESOLVER (driver.config.mjs resolveEngineProgram), which every other
|
|
795
|
+
// reader asks too: the run door, the inventory the portal reads, and both adapters. This used to be a
|
|
796
|
+
// second PATH walk of its own, and it was the only one that knew to pass over a Windows copy under WSL;
|
|
797
|
+
// that skip now lives in the one resolver, so the run door and this command cannot disagree about it.
|
|
704
798
|
//
|
|
705
|
-
//
|
|
706
|
-
//
|
|
707
|
-
//
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
799
|
+
// `bin` is the candidate the wizard wants checked, a path or a bare name, and it is asked under the
|
|
800
|
+
// engine's own setting, so "the setting names this" and "this is what the engine would find" are one
|
|
801
|
+
// question. The engine's fallback word is the default, so `claude` asks PATH and then the copy
|
|
802
|
+
// Clearotron installed. `wsl` and `onWindowsDrive` stay injectable: /mnt/c cannot be created on a
|
|
803
|
+
// Linux runner without root, so an arm that could only supply a PATH could never drive the skip.
|
|
804
|
+
const id = engine ?? Object.keys(ENGINE_BINARIES).find((k) => ENGINE_BINARIES[k].fallback === bin) ?? DEFAULT_ENGINE_ID;
|
|
805
|
+
const spec = ENGINE_BINARIES[id];
|
|
806
|
+
const r = resolveEngineProgram(id, { env: { ...env, [spec.env]: bin }, wsl, onWindowsDrive, enginesDir });
|
|
807
|
+
const found = { source: r.source, version: r.version, rejected: r.rejected, skipped: r.skipped, windowsShim: r.windowsShim };
|
|
808
|
+
// A RELATIVE path is the trap for both adapters: stage subprocesses run with cwd set to the RUN
|
|
809
|
+
// DIRECTORY (driver/engine/common.mjs resolveSpawnCwd), so it resolves against a directory that did not
|
|
810
|
+
// exist at setup time. Reported with its absolute form from here, which is what the wizard then uses.
|
|
811
|
+
if (r.relative) { const abs = resolve(bin); return { path: abs, executable: isExec(abs), relative: true, ...found }; }
|
|
812
|
+
// A PATH SOMEBODY TYPED IS NOT OVERRULED, only reported: the resolver never falls through from it.
|
|
813
|
+
if (!r.resolved && r.explicit && bin.includes("/")) return { path: resolve(bin), executable: false, relative: false, ...found };
|
|
814
|
+
return { path: r.resolved, executable: Boolean(r.resolved), relative: false, ...found };
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** A path on a Windows drive as WSL mounts it: the driver's one definition. */
|
|
818
|
+
export { ON_A_WINDOWS_DRIVE };
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* An engine instruction rewritten to name the copy Clearotron installed, which is not on PATH. One copy of
|
|
822
|
+
* it, in engine/probe.mjs, because the probe's sign-in advice names that copy too; re-exported here.
|
|
823
|
+
*/
|
|
824
|
+
export { namingProgram };
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* What setup says after a failed proof turn about the step it cannot take for the reader, by how the turn
|
|
828
|
+
* is paid for (`billing`, the billing word). On a subscription that is the sign-in, naming the copy that runs,
|
|
829
|
+
* and the route for a machine with no browser; `captureToken` says whether that route's token is offered for
|
|
830
|
+
* pasting. Under an API key or a cloud account there is nothing to sign in to, and the verdict above names
|
|
831
|
+
* what to check, so it says how to change what the turn ran on instead. A sign-in the subscription uses
|
|
832
|
+
* would not be used under either, so no token is offered there.
|
|
833
|
+
*/
|
|
834
|
+
export function signInHandOff(eng, bin, billing) {
|
|
835
|
+
if (billing === "cloud") return { lines: ["if you fixed the cloud's sign-in on this machine, answer yes below; to change the answers "
|
|
836
|
+
+ "above, answer no and pick the engine again."], captureToken: false };
|
|
837
|
+
if (billing === "api-key") return { lines: ["to give a different key, answer no below and pick the engine again."], captureToken: false };
|
|
838
|
+
const lines = [`if it is signed out: ${namingProgram(eng.signIn, eng, bin)}, then answer yes below.`];
|
|
839
|
+
// Codex's headless sign-in runs HERE, so it names the copy that runs here. Claude's token can be made on
|
|
840
|
+
// any machine, so its command keeps the bare word, and this machine's copy is named beside it.
|
|
841
|
+
if (eng.headless) {
|
|
842
|
+
lines.push(`on a machine you cannot complete a sign-in on: run \`${eng.headless.tokenEnv ? eng.headless.cmd : namingProgram(eng.headless.cmd, eng, bin)}\``
|
|
843
|
+
+ `${eng.headless.tokenEnv ? ` (from any machine you can sign in on${bin?.source === "installed" ? `; on this one the program is ${bin.path}` : ""})` : " here"}.`);
|
|
714
844
|
}
|
|
715
|
-
return {
|
|
845
|
+
return { lines, captureToken: Boolean(eng.headless?.tokenEnv) };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* What setup's proof turn is handed: the environment it runs in, with the program setting pinned to the
|
|
850
|
+
* absolute path of the copy found, so the turn runs exactly that copy; and that copy as setup found it, so
|
|
851
|
+
* the probe's advice names it as what it is. Pinned by path, the copy setup installed reads to the probe as
|
|
852
|
+
* a path the reader set, and a failed turn told the reader to run a bare `claude` or `codex login`, which
|
|
853
|
+
* that copy does not answer to, above lines naming the copy itself.
|
|
854
|
+
*
|
|
855
|
+
* THE CLOUD SETTINGS IN THE SETTINGS FILE RIDE WITH IT, as a run reads them. `settings` is what the file a
|
|
856
|
+
* search reads holds now (settingsInForce, below). The turn was built from the shell and the answers alone, so
|
|
857
|
+
* on an Amazon machine whose keys live only in that file it ran without them and failed while searches
|
|
858
|
+
* worked. Only the names on auth.mjs's CLOUD_SETTINGS are taken, in a run's order: a name the shell holds wins
|
|
859
|
+
* over the file, even when it is empty, because that is what the loader does (shared/env-local.mjs,
|
|
860
|
+
* loadEnvLocal); an answer given in setup wins over both, because it is written over the file.
|
|
861
|
+
*/
|
|
862
|
+
export function proofTurn({ engineId, eng, bin, authEnv = {}, env = process.env, settings = {} }) {
|
|
863
|
+
const fromFile = Object.fromEntries(CLOUD_SETTINGS.filter((k) => settings[k] !== undefined).map((k) => [k, settings[k]]));
|
|
864
|
+
return { env: { ...fromFile, ...env, CLEAROTRON_AI: engineId, [eng.env]: bin.path, ...authEnv },
|
|
865
|
+
program: { source: bin.source ?? null, path: bin.path } };
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* What the settings file a search reads holds now: the file the loader resolves (activeEnvPath), which is
|
|
870
|
+
* the one at the old location on an install configured before the move, and not always the one setup writes.
|
|
871
|
+
*
|
|
872
|
+
* THE PROOF TURN READS THIS, NOT THE FILE SETUP WRITES. It read `ENV_PATH`, so on an install still configured
|
|
873
|
+
* at the old location the keys every search and doctor were using never reached setup's test turn. The write
|
|
874
|
+
* stays on `ENV_PATH` (see READ_ENV_PATH), and it carries only that file's lines: on such an install the file
|
|
875
|
+
* setup writes starts without the old one's, which a run then reads instead. That is the write's behaviour,
|
|
876
|
+
* not this read's. `repoRoot` and `home` are here so a test can drive an install at the old location without
|
|
877
|
+
* touching this checkout or the home it runs in.
|
|
878
|
+
*/
|
|
879
|
+
export function settingsInForce({ repoRoot = REPO, home = homedir() } = {}) {
|
|
880
|
+
return readEnvFile(activeEnvPath({ repoRoot, home }), { home });
|
|
716
881
|
}
|
|
717
882
|
|
|
718
|
-
/**
|
|
719
|
-
|
|
883
|
+
/**
|
|
884
|
+
* What setup writes into an engine's program setting for the copy it just proved: the absolute path of a
|
|
885
|
+
* copy found on PATH or given by path, because a service's PATH is not the shell's, and for the copy
|
|
886
|
+
* Clearotron installed the engine's own fallback word, which means exactly what unset means.
|
|
887
|
+
*
|
|
888
|
+
* WRITTEN, NOT LEFT OUT. The installed copy's path must never be written: it would become the explicit
|
|
889
|
+
* setting, and a copy the reader installs on this machine later would never be used. Leaving the setting
|
|
890
|
+
* out of the file is not enough either. Setup never reads the file it rewrites (it is on the NO_DOTFILE
|
|
891
|
+
* list), and composeEnvBody keeps every setting it did not collect, so a path an earlier setup wrote would
|
|
892
|
+
* survive the rewrite, and the run door, which never overrules a named path, would refuse every run on a
|
|
893
|
+
* program that has since gone. The fallback word replaces it.
|
|
894
|
+
*/
|
|
895
|
+
export function engineProgramSetting(eng, bin) {
|
|
896
|
+
return bin?.source === "installed" ? eng.fallback : bin.path;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Why no copy of an engine's program can run, in one clause, for a wizard line that would otherwise
|
|
901
|
+
* report an absence: each copy found and refused with its reason, the setting that names a program that
|
|
902
|
+
* is not there, or that there is none. The vendor's placeholder is a copy that IS installed and cannot
|
|
903
|
+
* run, and its fix is a reinstall, not the vendor install this step offers next.
|
|
904
|
+
*/
|
|
905
|
+
export function unusableEngineWords(eng, bin, setting = "") {
|
|
906
|
+
const set = String(setting ?? "").trim();
|
|
907
|
+
const named = set && set !== eng.fallback ? `${eng.env}="${set}"` : "";
|
|
908
|
+
if (bin?.rejected?.length) return `${named ? `${named} → ` : ""}${bin.rejected.map((x) => `${x.path} is ${x.why}`).join("; ")}`;
|
|
909
|
+
if (named) return `${named} names nothing on PATH that can run`;
|
|
910
|
+
return `no \`${eng.fallback}\` on PATH, and Clearotron has not installed one`;
|
|
911
|
+
}
|
|
720
912
|
|
|
721
913
|
/** Whether this is a Linux running under Windows: the one answer, from shared/wsl.mjs. */
|
|
722
914
|
export { isWsl };
|
|
@@ -757,40 +949,212 @@ export function platformEngineRefusal({ platform = process.platform } = {}) {
|
|
|
757
949
|
* On the platform the run door refuses, the standard advice is a loop: install a CLI the reader may
|
|
758
950
|
* already have, then restart a service — neither of which can change the answer, because the refusal
|
|
759
951
|
* is about the platform rather than the program. Naming WSL2 is the only instruction that ends it.
|
|
952
|
+
*
|
|
953
|
+
* ELSEWHERE IT NAMES SETUP, WHICH DOES THE WORK NOW. This used to say to install the CLI by hand with
|
|
954
|
+
* `npm install -g`, then run `claude` to sign in, and to restart the engine service so it re-read its PATH.
|
|
955
|
+
* Setup offers to install the program itself, into a folder that is not on PATH, so the bare `claude` it
|
|
956
|
+
* named was a command the reader's shell does not have; and a run finds that copy without re-reading PATH.
|
|
957
|
+
* What a restart is still for is the settings setup writes: a running service reads them when it starts,
|
|
958
|
+
* and the portal reports what the engine saw then. No sign-in command is named here: no copy can run in
|
|
959
|
+
* demo mode, so none can be named, and setup names the one that signs in the copy it proves. `command` is
|
|
960
|
+
* the setup command as the reader can type it from here.
|
|
760
961
|
*/
|
|
761
|
-
export function leaveDemoAdvice(engSpec, { platform = process.platform
|
|
962
|
+
export function leaveDemoAdvice(engSpec, { platform = process.platform, command = reachableCommand("install"),
|
|
963
|
+
startCommand = reachableCommand("start") } = {}) {
|
|
762
964
|
if (platform === "win32") {
|
|
763
965
|
return [`To leave demo on Windows: run the product under WSL2, or in the devcontainer. Installing `
|
|
764
966
|
+ `${engSpec.vendor}'s CLI natively will not change this — the run door refuses on the platform, `
|
|
765
967
|
+ "not on the program."];
|
|
766
968
|
}
|
|
969
|
+
// BACKGROUND SERVICES ARE THE EXCEPTION to "restart them": they read `~/.env`, which setup does not write,
|
|
970
|
+
// and `start --background` only adds lines to it (see programDisagreement below, which says the same).
|
|
767
971
|
return [
|
|
768
|
-
`To leave demo: install ${engSpec.vendor}'s CLI
|
|
769
|
-
+
|
|
770
|
-
"
|
|
771
|
-
+ "
|
|
972
|
+
`To leave demo: run \`${command}\`. It offers to install ${engSpec.vendor}'s CLI if this machine has none, `
|
|
973
|
+
+ "asks how it is paid for, and proves it with one turn; if the CLI is not signed in, it names the command that signs it in.",
|
|
974
|
+
"If Clearotron's services are already running, restart them afterwards: they read the settings setup writes "
|
|
975
|
+
+ "when they start, and the portal reports what the engine saw when it last started. Background services "
|
|
976
|
+
+ `read \`~/.env\` instead, which setup does not write, and \`${startCommand} --background\` adds to it only the `
|
|
977
|
+
+ "lines it lacks, so change there any setting it already has.",
|
|
772
978
|
];
|
|
773
979
|
}
|
|
774
980
|
|
|
981
|
+
/**
|
|
982
|
+
* What doctor says when the engine's capture and this machine disagree about whether the engine's program
|
|
983
|
+
* can be found. `capture` and `live` are the comparison's words, "found" or "not found" (flag-snapshot.mjs,
|
|
984
|
+
* postureDisagreement); `command` is the setup command as the reader can type it from here; `hosted` says
|
|
985
|
+
* whether the services are systemd units, `setting` names the engine's program setting, and `file` is the
|
|
986
|
+
* settings file the services read (doctor's serviceEnvFile).
|
|
987
|
+
*
|
|
988
|
+
* THE CAPTURE IS WRITTEN WHEN THE SERVICES START and at no other time: by `clearotron start`, whose children
|
|
989
|
+
* they are, and by the worker unit's ExecStartPost. So the sentence opens with what they recorded then, and
|
|
990
|
+
* "not found" means a restart makes them look again.
|
|
991
|
+
*
|
|
992
|
+
* A PROGRAM THIS MACHINE FINDS AND THEY STILL DO NOT is not on the PATH they run with, and what gets it to
|
|
993
|
+
* them depends on which file they read. Without units they are the children of `clearotron start`, which reads
|
|
994
|
+
* Clearotron's settings file when it starts, so setup is the remedy: it writes the full path of the program
|
|
995
|
+
* this shell finds there, or, when this shell finds none, offers to install a copy, which is found without
|
|
996
|
+
* PATH (resolveEngineProgram: the path setting, then PATH, then that copy). It does NOT install over a program
|
|
997
|
+
* it finds, so the install is not promised on its own. Units read `~/.env` instead, which setup does not
|
|
998
|
+
* write and `clearotron start --background` only adds names to, so there the remedy is the setting in that file.
|
|
999
|
+
*
|
|
1000
|
+
* This used to tell the reader to restart the engine service so it re-read its PATH, or to install the CLI
|
|
1001
|
+
* where the service could see it: words from before setup installed the program, given for both directions,
|
|
1002
|
+
* and one of them is a machine the services found the program on.
|
|
1003
|
+
*/
|
|
1004
|
+
export function programDisagreement({ capture, live }, { command = reachableCommand("install"), hosted = false,
|
|
1005
|
+
setting = null, file = null } = {}) {
|
|
1006
|
+
const head = `When the services last started they recorded the engine program as ${capture}; this machine `
|
|
1007
|
+
+ `reads it as ${live}. A NEW search will refuse while that is true.`;
|
|
1008
|
+
if (capture === "not found") {
|
|
1009
|
+
const remedy = hosted
|
|
1010
|
+
? `set ${setting ?? "the engine's program setting"} to its full path in ${file ?? "the file they read"}, `
|
|
1011
|
+
+ "which they read when they start, then restart them."
|
|
1012
|
+
: `run \`${command}\`: it writes the full path of the program this shell finds into Clearotron's settings, `
|
|
1013
|
+
+ "or offers to install a copy found without PATH if this shell finds none. Then restart them.";
|
|
1014
|
+
return `${head} Restart them so they look again. If they still cannot find it, it is not on the PATH they `
|
|
1015
|
+
+ `run with: ${remedy}`;
|
|
1016
|
+
}
|
|
1017
|
+
return `${head} If the program was removed, run \`${command}\` to install it again (the copy setup installs is `
|
|
1018
|
+
+ "found without PATH), then restart the services so they look again.";
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* What an engine's install takes on disk, and how to take it back: the line setup's install offer says
|
|
1023
|
+
* right after it names the folder, and before it asks. A reader deciding whether to let a program onto
|
|
1024
|
+
* their machine is owed its size and its way off, and neither was said. The size is the registry's
|
|
1025
|
+
* measured figure (driver.config.mjs, `installMB`), kept beside the package it measures rather than in
|
|
1026
|
+
* this file. The removal is the folder, because the install is an npm project inside it and puts nothing
|
|
1027
|
+
* on PATH.
|
|
1028
|
+
*/
|
|
1029
|
+
export function installSizeLine(eng) {
|
|
1030
|
+
return `${Number.isFinite(eng?.installMB) ? `It takes about ${eng.installMB} MB. ` : ""}To remove it, delete that folder.`;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
775
1033
|
/**
|
|
776
1034
|
* The engine menu, built from the driver's registry so the wizard cannot offer an adapter that does not
|
|
777
1035
|
* exist — or hide one that does. Same guarantee the register-provider list has.
|
|
778
1036
|
*
|
|
779
1037
|
* The last row is deliberately NOT an engine, and that is what makes the refusal above workable: a
|
|
780
|
-
* reader whose CLI is signed out, or who only wants
|
|
781
|
-
*
|
|
1038
|
+
* reader whose CLI is signed out, or who only wants the demo, has a stated route through setup that does
|
|
1039
|
+
* not end in a `.env` naming an engine nobody proved. `id: null` is that row, "None for now"; what still
|
|
1040
|
+
* works without an engine is said after that choice (sayNoEngine), not crammed into the row. Anything
|
|
782
1041
|
* asserting this list against the adapter registry must drop it first.
|
|
1042
|
+
*
|
|
1043
|
+
* EACH ROW NAMES THE AI AND ITS MAKER AND SAYS WHAT SETUP FOUND, in the words the owner approved on
|
|
1044
|
+
* 2026-09-15 (foundWords). `found` is engineMenuState's answer; without it the rows carry the names alone.
|
|
783
1045
|
*/
|
|
784
|
-
export function engineOptions() {
|
|
1046
|
+
export function engineOptions(found = {}) {
|
|
1047
|
+
// The AI and its MAKER, not `label`: the labels are mechanism sentences (`claude -p`, `codex exec`) and
|
|
1048
|
+
// the menu is the first question a lawyer reads. The mechanism still appears in the confirmation lines
|
|
1049
|
+
// after a choice, where it belongs.
|
|
1050
|
+
const rows = Object.entries(ENGINE_BINARIES).map(([id, s]) => ({ id, name: `${s.product}, by ${s.vendor}`, state: foundWords(s, found[id]) }));
|
|
1051
|
+
const width = Math.max(...rows.map((r) => r.name.length));
|
|
785
1052
|
return [
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
// mechanism still appears in the confirmation lines after a choice, where it belongs.
|
|
789
|
-
...Object.entries(ENGINE_BINARIES).map(([id, s]) => ({ id, label: `${s.vendor} — uses its \`${s.fallback}\` program on this machine` })),
|
|
790
|
-
{ id: null, label: "Neither yet — configure no engine (`npm run example` needs none; a real run will refuse)" },
|
|
1053
|
+
...rows.map(({ id, name, state }) => ({ id, label: state ? `${name.padEnd(width)} ${state}` : name })),
|
|
1054
|
+
{ id: null, label: "None for now" },
|
|
791
1055
|
];
|
|
792
1056
|
}
|
|
793
1057
|
|
|
1058
|
+
// A COPY REFUSED AS THE VENDOR'S PLACEHOLDER, told apart by the resolver's own reason for refusing it
|
|
1059
|
+
// (driver.config.mjs engineCandidate). setup-asks-which-ai-runs-your-searches.test.mjs creates a real
|
|
1060
|
+
// placeholder and reads the row, so a reworded reason turns that test red instead of this row wrong.
|
|
1061
|
+
const isPlaceholder = (x) => /^the placeholder /.test(String(x?.why ?? ""));
|
|
1062
|
+
|
|
1063
|
+
/** An engine's program setting when it names a program; "" when it is unset or the default word. */
|
|
1064
|
+
function namedSetting(eng, setting) {
|
|
1065
|
+
const set = String(setting ?? "").trim();
|
|
1066
|
+
return set && set !== eng.fallback ? set : "";
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Why no copy of an engine's program can run, as one of three kinds, or null when nothing was refused and
|
|
1071
|
+
* no setting names a program. The menu row and the line after a pick both ask this, so they cannot
|
|
1072
|
+
* disagree. THE PLACEHOLDER COMES FIRST, even where a setting names it: that copy is there, so "isn't
|
|
1073
|
+
* there" would be false, and what mends it is a working copy.
|
|
1074
|
+
*/
|
|
1075
|
+
function programProblem(bin, named) {
|
|
1076
|
+
const placeholder = bin?.rejected?.find(isPlaceholder);
|
|
1077
|
+
if (placeholder) return { kind: "incomplete", path: placeholder.path };
|
|
1078
|
+
if (named || bin?.relative) return { kind: "setting" };
|
|
1079
|
+
if (bin?.rejected?.length) return { kind: "refused", path: bin.rejected[0].path };
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* What setup found of one engine's program, as its menu row says it; "" when nothing was looked for. A copy
|
|
1085
|
+
* that cannot run is a problem, not an absence, and installing another is not always the fix, so the row
|
|
1086
|
+
* says which problem and that choosing it shows the fix. Setup offers an install only where the engine has
|
|
1087
|
+
* a package to install.
|
|
1088
|
+
*/
|
|
1089
|
+
export function foundWords(eng, bin) {
|
|
1090
|
+
if (!bin) return "";
|
|
1091
|
+
if (bin.executable && !bin.relative) return bin.version ? `found on this computer (version ${bin.version})` : "found on this computer";
|
|
1092
|
+
const p = programProblem(bin, bin.explicit);
|
|
1093
|
+
if (p?.kind === "incomplete") return `problem: the copy of ${eng.product} here is incomplete and won't run — choose it to see the fix`;
|
|
1094
|
+
if (p?.kind === "setting") return `problem: this computer is set to use a copy of ${eng.product} that isn't there — choose it to see the fix`;
|
|
1095
|
+
if (p?.kind === "refused") return `problem: the copy of ${eng.product} here won't run — choose it to see the fix`;
|
|
1096
|
+
return eng.package ? "not on this computer — setup can install it" : "not on this computer";
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* What setup says once an engine whose copy cannot run is chosen, in the words approved with its row. The
|
|
1101
|
+
* path in the setting case is the one the setting names, as it names it. A copy refused for another reason,
|
|
1102
|
+
* or nothing found and nothing named, has no approved sentence and keeps unusableEngineWords' clause.
|
|
1103
|
+
* NO SETTING IS NAMED HERE: the one line that names it is ownCopyLine, said if the install is declined.
|
|
1104
|
+
*/
|
|
1105
|
+
export function cannotRunLine(eng, bin, setting = "") {
|
|
1106
|
+
const set = namedSetting(eng, setting);
|
|
1107
|
+
const p = programProblem(bin, set);
|
|
1108
|
+
if (p?.kind === "incomplete") return `The copy of ${eng.product} at ${p.path} is incomplete: its installation stopped before the program was added. Setup can install a working copy.`;
|
|
1109
|
+
if (p?.kind === "setting") return `This computer is set to use ${eng.product} at ${set}, and nothing there can run. Setup can install ${eng.product} and use that instead.`;
|
|
1110
|
+
return `${unusableEngineWords(eng, bin, setting)}.`;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/** What setup says when its install offer is declined with a setting in force: how to use one's own copy. */
|
|
1114
|
+
export function ownCopyLine(eng) {
|
|
1115
|
+
return `To use your own copy of ${eng.product} instead, change ${eng.env} to its full path, or give that path at the next question.`;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* What this machine has of each engine's program, for the engine question: resolved the way a run
|
|
1120
|
+
* resolves it (the explicit path setting, then PATH, then the copy setup installed), and the version of
|
|
1121
|
+
* whatever was found. The copy setup installed says its version in its own package.json, and so does one
|
|
1122
|
+
* npm put on PATH; anything else is asked with `--version`, the same short, time-limited call a run makes
|
|
1123
|
+
* to record the tool that served it (driver/engine/cli-version.mjs). That call starts no session and
|
|
1124
|
+
* needs no network; one that fails or times out leaves the version null, and the row then says "found
|
|
1125
|
+
* on this computer". `readVersion` is injectable so a test can drive the unreadable branch.
|
|
1126
|
+
*
|
|
1127
|
+
* SHORTER THAN A RUN'S CALL, AND ONLY A VERSION IS SHOWN. The question waits on these calls, one program
|
|
1128
|
+
* after another, before it prints, and a run's five-second limit let two programs that hang hold the
|
|
1129
|
+
* screen for ten; two seconds is a judgement, ample for these programs to print a version and short
|
|
1130
|
+
* enough to wait on. The answers are kept apart from the run's own record of the tool, so a call cut
|
|
1131
|
+
* short here is not what a run reads. And a program that answers in prose, which a run records as said,
|
|
1132
|
+
* put its whole first line in the menu row; the row shows a version only when the answer is shaped like
|
|
1133
|
+
* one, and otherwise says "found on this computer".
|
|
1134
|
+
*
|
|
1135
|
+
* A SETTING IN FORCE IS ASKED THE WAY THE LINE AFTER A PICK ASKS IT (namedSetting): trimmed, and the
|
|
1136
|
+
* engine's default word counts as unset, as the resolver counts it. A setting of spaces alone made the
|
|
1137
|
+
* row say the setting named a copy that isn't there while the resolver and that line treated it as unset.
|
|
1138
|
+
*/
|
|
1139
|
+
const MENU_VERSION_TIMEOUT_MS = 2000;
|
|
1140
|
+
const MENU_VERSIONS = new Map();
|
|
1141
|
+
const menuVersion = (p) => probeCliVersion(p, { timeoutMs: MENU_VERSION_TIMEOUT_MS, cache: MENU_VERSIONS }).version;
|
|
1142
|
+
export function engineMenuState({ env = process.env, enginesDir = undefined, readVersion = menuVersion } = {}) {
|
|
1143
|
+
const out = {};
|
|
1144
|
+
for (const [id, e] of Object.entries(ENGINE_BINARIES)) {
|
|
1145
|
+
const set = env[e.env];
|
|
1146
|
+
const bin = resolveEngineBin(set || e.fallback, { env, engine: id, enginesDir });
|
|
1147
|
+
const usable = bin.executable && !bin.relative;
|
|
1148
|
+
let version = bin.version;
|
|
1149
|
+
if (usable && !version) {
|
|
1150
|
+
try { version = readVersion(bin.path) ?? null; } catch { version = null; }
|
|
1151
|
+
if (!/^\d+\.\d+/.test(String(version ?? ""))) version = null;
|
|
1152
|
+
}
|
|
1153
|
+
out[id] = { ...bin, version, explicit: Boolean(namedSetting(e, set)) };
|
|
1154
|
+
}
|
|
1155
|
+
return out;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
794
1158
|
/**
|
|
795
1159
|
* What `<repo>/.env` would give a run, read THROUGH THE ENGINE'S OWN LOADER.
|
|
796
1160
|
*
|
|
@@ -1333,6 +1697,15 @@ export async function runCheck() {
|
|
|
1333
1697
|
}
|
|
1334
1698
|
return null;
|
|
1335
1699
|
};
|
|
1700
|
+
// WHAT THE SERVICES THEMSELVES READ, for the two judgements that decide whether a search runs: how they
|
|
1701
|
+
// pay, and "Will a search run?". On a machine with units, that is their file and nothing else: they run
|
|
1702
|
+
// with CLEAROTRON_NO_ENV_FILE=1, so doctor's shell never reaches them. Layered as effectiveForService
|
|
1703
|
+
// layers it, a cloud switch this shell exported for its own use was judged the services' own, and doctor
|
|
1704
|
+
// reported "a search is refused" on services that ran; a billing word here asked them for a key they did
|
|
1705
|
+
// not need. With no units, the services are the children of `clearotron start` and inherit its shell, so
|
|
1706
|
+
// effectiveForService is the true answer there.
|
|
1707
|
+
const servicesRead = (k) => (!hosted ? effectiveForService(k)
|
|
1708
|
+
: serviceFileEnv && present(serviceFileEnv[k]) ? { v: serviceFileEnv[k], from: serviceEnvLabel, name: k } : null);
|
|
1336
1709
|
|
|
1337
1710
|
say("\n Engine");
|
|
1338
1711
|
// This block used to read CLEAROTRON_CLAUDE_PATH unconditionally, under the heading "Engine binary", on a
|
|
@@ -1356,9 +1729,17 @@ export async function runCheck() {
|
|
|
1356
1729
|
// — through `effective`, so the engine binary is found under whichever spelling is set. Read
|
|
1357
1730
|
// literally, this reported a configured binary as missing on any install the wizard had written.
|
|
1358
1731
|
const binEff = effective(engSpec.env, [engSpec.env]);
|
|
1359
|
-
|
|
1732
|
+
// The engine's own fallback word is the default spelled out (resolveEngineProgram), so a setting of
|
|
1733
|
+
// `claude` is not a configuration that can be wrong: it asks PATH, then the copy Clearotron installed.
|
|
1734
|
+
const binSet = !!binEff && String(binEff.v).trim() !== engSpec.fallback;
|
|
1360
1735
|
const binSetting = binEff?.v || engSpec.fallback;
|
|
1361
|
-
const bin = resolveEngineBin(binSetting);
|
|
1736
|
+
const bin = resolveEngineBin(binSetting, { engine: engineId });
|
|
1737
|
+
// WHICH COPY, AND ITS VERSION, because the machine's own install and the one Clearotron installed
|
|
1738
|
+
// are both legitimate and behave differently: the first updates itself, the second moves with
|
|
1739
|
+
// `clearotron update`. The version comes from the copy's own package.json when npm installed it;
|
|
1740
|
+
// doctor spawns nothing to ask (a vendor's own native installer leaves no package.json to read).
|
|
1741
|
+
const copyWords = (b) => `${b.source === "installed" ? "the copy Clearotron installed"
|
|
1742
|
+
: b.source === "explicit" ? `set in ${engSpec.env}` : "on PATH"}${b.version ? `, version ${b.version}` : ", version not read"}`;
|
|
1362
1743
|
// ── NATIVE WINDOWS IS ANSWERED HERE, BEFORE ANY PATH IS RESOLVED OR REPORTED ──────────────────
|
|
1363
1744
|
//
|
|
1364
1745
|
// `resolveEngineBin` tests a candidate with `accessSync(X_OK)` and `isFile()`. Windows has no
|
|
@@ -1380,17 +1761,22 @@ export async function runCheck() {
|
|
|
1380
1761
|
// and needs no engine, which is why four reports published on that same Windows box.
|
|
1381
1762
|
const platformRefusal = platformEngineRefusal();
|
|
1382
1763
|
if (platformRefusal) problem(platformRefusal);
|
|
1383
|
-
else if (bin.executable && !bin.relative) ok(`${bin.path}`);
|
|
1764
|
+
else if (bin.executable && !bin.relative) ok(`${bin.path} — ${copyWords(bin)}`);
|
|
1765
|
+
// A copy that is there and cannot run is a broken install, not an absence: the vendor's placeholder
|
|
1766
|
+
// left by an install that skipped its step, most often. Named with the reason and the fix.
|
|
1767
|
+
else if (!binSet && bin.rejected?.length) problem(`no usable \`${engSpec.fallback}\`: ${bin.rejected.map((x) => `${x.path} is ${x.why}`).join("; ")}`);
|
|
1384
1768
|
// The FACT only. It used to carry "install it for a real run (`npm run example` needs no engine)",
|
|
1385
1769
|
// which is the absence framing was filed about — and it now says half of what the MODE line
|
|
1386
1770
|
// below says, in worse words. One statement of a state, in the place that states states.
|
|
1387
|
-
else if (!binSet) info(`no \`${engSpec.fallback}\` on PATH`);
|
|
1771
|
+
else if (!binSet) info(`no \`${engSpec.fallback}\` on PATH, and Clearotron has not installed one`);
|
|
1388
1772
|
// Relative is reported BEFORE not-executable. A relative path that also does not resolve from the
|
|
1389
1773
|
// current directory would otherwise be reported as a missing file, sending the reader to check the
|
|
1390
1774
|
// file — and the file is usually fine. The relativity is the defect.
|
|
1391
1775
|
else if (bin.relative) problem(`${engSpec.env}="${binSetting}" is RELATIVE — stage subprocesses run with cwd set to the run directory, so it will not resolve there. Use an absolute path (${bin.path} from here)`);
|
|
1392
1776
|
else if (!bin.path) problem(`${engSpec.env}="${binSetting}" resolves to nothing on PATH`);
|
|
1393
|
-
|
|
1777
|
+
// The resolver's reason, not a fixed phrase: the vendor's placeholder IS an executable file, and "not an
|
|
1778
|
+
// executable file" sent the reader to check a permission that was fine.
|
|
1779
|
+
else problem(`${engSpec.env}="${binSetting}" → ${bin.path} is ${bin.rejected?.[0]?.why ?? "not an executable file"}`);
|
|
1394
1780
|
// SAID AFTER THE CHAIN ABOVE, AND OUTSIDE IT. This block is one if/else-if ladder, so a statement
|
|
1395
1781
|
// placed between two of its clauses re-parents every clause below onto the new `if` — measured:
|
|
1396
1782
|
// it made doctor report an executable mock binary as "not an executable file", because the ladder's
|
|
@@ -1440,8 +1826,11 @@ export async function runCheck() {
|
|
|
1440
1826
|
// platform rather than the binary. Naming WSL2 is the only instruction that ends this state.
|
|
1441
1827
|
for (const line of leaveDemoAdvice(engSpec)) info(line);
|
|
1442
1828
|
} else {
|
|
1829
|
+
// THE COMMAND THE READER CAN TYPE FROM HERE, as the demo line above names its own. A bare
|
|
1830
|
+
// `clearotron` is not on PATH for an install run from npx, and now that the engine program comes
|
|
1831
|
+
// with the install this is the line most installs see first.
|
|
1443
1832
|
info("The engine program is installed. Whether it is signed in cannot be read from disk: run "
|
|
1444
|
-
+ "
|
|
1833
|
+
+ `\`${reachableCommand("doctor --probe-engine")}\` to find out.`);
|
|
1445
1834
|
}
|
|
1446
1835
|
|
|
1447
1836
|
// AND WHETHER THE ENGINE AGREES, which is a different question from the one above and the reason an
|
|
@@ -1461,12 +1850,7 @@ export async function runCheck() {
|
|
|
1461
1850
|
// NULL IS NOT AGREEMENT and neither is an empty pool — a box with no capture has nothing to
|
|
1462
1851
|
// disagree with, and saying so beats printing a clean bill nobody measured.
|
|
1463
1852
|
const clash = (rows ?? []).find((r) => r.what === "engine program");
|
|
1464
|
-
if (clash) {
|
|
1465
|
-
problem(`The engine that last ran and this machine disagree about the engine program: the last run `
|
|
1466
|
-
+ `recorded it as ${clash.capture}, this machine reads it as ${clash.live}. A NEW search will `
|
|
1467
|
-
+ `refuse while that is true. Restart the engine service so it re-reads its PATH, or install the `
|
|
1468
|
-
+ `CLI where the service can see it.`);
|
|
1469
|
-
}
|
|
1853
|
+
if (clash) problem(programDisagreement(clash, { hosted, setting: engSpec?.env, file: serviceEnvFile }));
|
|
1470
1854
|
} catch (e) {
|
|
1471
1855
|
// WHAT ACTUALLY REACHES THIS CATCH, established by driving it rather than by reading it.
|
|
1472
1856
|
//
|
|
@@ -1494,16 +1878,49 @@ export async function runCheck() {
|
|
|
1494
1878
|
// here as the problem it is: that .env cannot run a stage, and finding out at `--check` is the
|
|
1495
1879
|
// entire point of the command.
|
|
1496
1880
|
try {
|
|
1497
|
-
//
|
|
1498
|
-
// values overlaid for
|
|
1881
|
+
// The same construction as the probe env below, from the same list: the environment as a RUN would
|
|
1882
|
+
// see it, with the .env's values overlaid for the keys an engine spawn is made of. The cloud's names
|
|
1883
|
+
// are on it, because with the billing word read from the file and a cloud's switch left to the shell,
|
|
1884
|
+
// a cloud file that runs would read as refused.
|
|
1499
1885
|
const envForResolve = { ...process.env };
|
|
1500
|
-
for (const k of
|
|
1501
|
-
const e =
|
|
1886
|
+
for (const k of engineEnvKeys()) {
|
|
1887
|
+
const e = effective(k);
|
|
1502
1888
|
if (e) envForResolve[k] = e.v;
|
|
1503
1889
|
}
|
|
1504
|
-
const
|
|
1505
|
-
|
|
1506
|
-
|
|
1890
|
+
const billingOf = (eng, env) => {
|
|
1891
|
+
try {
|
|
1892
|
+
const auth = resolveAuthMode({ engineName: eng, env });
|
|
1893
|
+
if (auth.mode === "unknown") return { say: info, text: `billing: no policy for ${eng} — this engine declares no sign-in modes` };
|
|
1894
|
+
if (auth.mode === "cloud") return { say: ok, text: `billing: cloud — charged per use ${auth.cloud === "gateway" ? "through" : "to"} ${cloudAccount(auth.cloud)}` };
|
|
1895
|
+
return { say: ok, text: `billing: ${auth.mode}${auth.apiBilled ? ` — charged per token against ${ENGINE_BINARIES[eng]?.apiKeyEnv ?? engSpec.apiKeyEnv}` : " — charged to the signed-in subscription, not per token"}` };
|
|
1896
|
+
// A word that is not a billing mode is quoted by the run door, and a key pasted there by mistake is
|
|
1897
|
+
// that word: said by name here, as the order-time reason says it.
|
|
1898
|
+
} catch (e) { return { say: problem, text: billingRefusalWords(String(e?.message ?? e)) }; }
|
|
1899
|
+
};
|
|
1900
|
+
const here = billingOf(engineId, envForResolve);
|
|
1901
|
+
here.say(here.text);
|
|
1902
|
+
// AND AS THE SERVICES READ IT, when this machine runs them and that reading differs. The line above is
|
|
1903
|
+
// this command's configuration; the services read their own file, and a start never replaces a line
|
|
1904
|
+
// in it. So a machine whose services pay through a cloud account printed "billing: subscription" here,
|
|
1905
|
+
// two sections above "Will a search run?" reading the services' file. Both are said when they differ.
|
|
1906
|
+
// Read as the services read it (servicesRead), never through this shell.
|
|
1907
|
+
//
|
|
1908
|
+
// A CAUTION ONLY WHEN THIS COMMAND SAYS OTHERWISE. With no billing word in this command's configuration,
|
|
1909
|
+
// the line above is the default and contradicts nothing, and a working api-key or cloud install whose
|
|
1910
|
+
// billing lives in the services' file alone was cautioned on every run from a bare shell. Then it is
|
|
1911
|
+
// information. Never a problem: the services' refusal, if there is one, is reported under that section.
|
|
1912
|
+
if (hosted && serviceKnown) {
|
|
1913
|
+
const envForService = {};
|
|
1914
|
+
for (const k of engineEnvKeys()) {
|
|
1915
|
+
const e = servicesRead(k);
|
|
1916
|
+
if (e) envForService[k] = e.v;
|
|
1917
|
+
}
|
|
1918
|
+
const svc = billingOf(String(envForService.CLEAROTRON_AI ?? DEFAULT_ENGINE_ID).trim().toLowerCase(), envForService);
|
|
1919
|
+
if (svc.text !== here.text) {
|
|
1920
|
+
if (effective(engSpec?.authEnv ?? "CLEAROTRON_AI_BILLING")) warn(`the services read how they pay from ${serviceEnvLabel}, and it says otherwise — ${svc.text}`);
|
|
1921
|
+
else info(`the services pay as ${serviceEnvLabel} says — ${svc.text}`);
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1507
1924
|
} catch (e) {
|
|
1508
1925
|
problem(String(e?.message ?? e));
|
|
1509
1926
|
}
|
|
@@ -1541,6 +1958,8 @@ export async function runCheck() {
|
|
|
1541
1958
|
const v = await probeEngineTurn({ env: probeEnv });
|
|
1542
1959
|
if (v.ok) {
|
|
1543
1960
|
ok(`${engineId} completed a turn — binary, credential and model access all work`);
|
|
1961
|
+
const served = servedLine(v);
|
|
1962
|
+
if (served) info(served);
|
|
1544
1963
|
// The ONLY route to READY. Everything else in this block reads the filesystem, and a signed-out
|
|
1545
1964
|
// CLI passes every filesystem test there is — which is why this line is here and not above.
|
|
1546
1965
|
ok("MODE: engine ready — proven by the turn just spent, not inferred from a file being executable.");
|
|
@@ -2076,12 +2495,28 @@ export async function runCheck() {
|
|
|
2076
2495
|
if (!serviceKnown) {
|
|
2077
2496
|
info("the units' environment could not be read, so what a search would be refused for is NOT checked here — a failure to look is not a clean result");
|
|
2078
2497
|
} else {
|
|
2079
|
-
const tables = { registers: PROVIDERS, engines: ENGINE_BINARIES, defaultEngine: DEFAULT_ENGINE_ID };
|
|
2080
|
-
//
|
|
2498
|
+
const tables = { registers: PROVIDERS, engines: ENGINE_BINARIES, defaultEngine: DEFAULT_ENGINE_ID, resolveEngine: resolveEngineProgram };
|
|
2499
|
+
// UNTIL NOTHING NEW IS NAMED: which names a run needs depends on values read in the pass before. The
|
|
2500
|
+
// register and the engine name their credentials, their program and the billing word; a billing word of
|
|
2501
|
+
// `cloud` names the cloud switches and the gateway address; a switch found becomes the blocking row, and
|
|
2502
|
+
// the cloud's other settings are named only where the view already holds them. Two passes stopped before
|
|
2503
|
+
// the billing word was read, so a machine that pays through a cloud account was checked as a subscription
|
|
2504
|
+
// one and its missing switch went unreported. It stops at the first pass that finds no new value: the names
|
|
2505
|
+
// asked for depend only on the values found, so the next pass would ask for the names this one just read.
|
|
2081
2506
|
const view = {};
|
|
2082
|
-
|
|
2507
|
+
// Read as the services read it (servicesRead): a value in this shell never reaches a unit.
|
|
2508
|
+
const fill = (names) => { for (const n of names) { const e = servicesRead(n); if (e) view[n] = e.v; } };
|
|
2083
2509
|
fill([REGISTER_ENV, ENGINE_ENV]);
|
|
2084
|
-
|
|
2510
|
+
let before;
|
|
2511
|
+
do { before = Object.keys(view).length; fill(runRequiredNames(view, tables)); } while (Object.keys(view).length > before);
|
|
2512
|
+
// AND THE PATH THE SERVICES SEARCH, so the engine's program is looked for where a run looks for it.
|
|
2513
|
+
// The view held the settings and no PATH, so a `claude` on the services' PATH, with no path setting and
|
|
2514
|
+
// no copy installed by setup, was reported as a search refused while the run found it and ran. On a
|
|
2515
|
+
// machine with units that PATH is the units' own (every unit sets it, with `%h` for the home, which the
|
|
2516
|
+
// unit reader expands); never this shell's, which the units do not inherit. With no units, the
|
|
2517
|
+
// services are the children of `clearotron start`, which inherit the PATH of the shell it runs in.
|
|
2518
|
+
const servicesPath = hosted ? unitValue(unitEnv, "PATH").value : process.env.PATH;
|
|
2519
|
+
if (servicesPath) view.PATH = servicesPath;
|
|
2085
2520
|
const { atOrder } = missingRequirements(view, tables);
|
|
2086
2521
|
if (atOrder.length) {
|
|
2087
2522
|
blocking(`a search is refused until ${atOrder.length === 1 ? "this is" : "these are"} set in ${serviceEnvLabel}: ${atOrder.map((r) => r.name).join(", ")}`);
|
|
@@ -3223,12 +3658,10 @@ const askValue = async (q, { def = "", secret = false, skippable = false, skippe
|
|
|
3223
3658
|
* land here, and two copies would drift the moment one of them was reworded.
|
|
3224
3659
|
*/
|
|
3225
3660
|
const sayNoEngine = () => {
|
|
3226
|
-
info(
|
|
3227
|
-
info(`\`${invoke("demo")}\` needs none. A real run refuses at its own door until one is set — re-run setup then.`);
|
|
3661
|
+
info(NO_AI_CHOSEN);
|
|
3228
3662
|
};
|
|
3229
|
-
const choose = async (q, options, def = 0) => {
|
|
3230
|
-
|
|
3231
|
-
options.forEach((o, i) => say(` ${i + 1}) ${o.label}${i === def ? " (default)" : ""}`));
|
|
3663
|
+
const choose = async (q, options, def = 0, after = []) => {
|
|
3664
|
+
for (const line of menuScreen(q, options, def, after)) say(line);
|
|
3232
3665
|
for (;;) {
|
|
3233
3666
|
const a = await askRaw(` 1-${options.length} [${def + 1}] `);
|
|
3234
3667
|
const n = a === "" ? def + 1 : Number(a);
|
|
@@ -3260,10 +3693,11 @@ try {
|
|
|
3260
3693
|
mark: bracketAsciiCells(), columns: process.stdout.columns }));
|
|
3261
3694
|
say("");
|
|
3262
3695
|
say(` ${style.bold("Before you start")} — what this setup can take, so nothing here surprises you:`);
|
|
3263
|
-
// `
|
|
3264
|
-
// lawyer reads may not (the first rule).
|
|
3265
|
-
say(` · Which AI runs the searches (${Object.values(ENGINE_BINARIES).map((e) => e.
|
|
3266
|
-
say(" and how it
|
|
3696
|
+
// `product`, not `label`: the labels are engineer sentences carrying flag names, and a question a
|
|
3697
|
+
// lawyer reads may not (the first rule). The AI by the name the engine question gives it.
|
|
3698
|
+
say(` · Which AI runs the searches (${Object.values(ENGINE_BINARIES).map((e) => e.product).join(" or ")}),`);
|
|
3699
|
+
say(" and how it is paid for: a subscription you sign in with, an API key, or, for Claude,");
|
|
3700
|
+
say(" your own cloud account.");
|
|
3267
3701
|
say(" · Your trademark register vendor's credential, if you have one (a register can be chosen later).");
|
|
3268
3702
|
for (const table of [RESEARCH_PROVIDERS, SERP_PROVIDERS]) {
|
|
3269
3703
|
for (const a of Object.values(table)) {
|
|
@@ -3287,26 +3721,24 @@ try {
|
|
|
3287
3721
|
// This step used to resolve `claude` and nothing else, then write CLEAROTRON_AI=anthropic-agent five
|
|
3288
3722
|
// steps later without ever asking. The driver has shipped a second adapter the whole time.
|
|
3289
3723
|
say("\n Engine");
|
|
3290
|
-
say("
|
|
3291
|
-
say("
|
|
3724
|
+
say(" Clearotron runs each step of a search as a short, unattended session of an AI. One AI serves");
|
|
3725
|
+
say(" every search on this computer, so it is not chosen per search.");
|
|
3292
3726
|
engine: for (;;) {
|
|
3293
3727
|
// ── THE STATE FIRST, THE QUESTIONS OFF IT ──────────────────────────────
|
|
3294
3728
|
//
|
|
3295
3729
|
// The wizard used to ask which engine and how it bills, and only then discover the box could not
|
|
3296
3730
|
// complete a sign-in — headless over SSH, the owner's own dead end. What is detectable is said
|
|
3297
|
-
// before anything is asked
|
|
3298
|
-
//
|
|
3299
|
-
//
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
const pick = await choose("Which engine runs the reasoning stages?", engineOptions(), 0);
|
|
3731
|
+
// before anything is asked, ON THE QUESTION'S OWN ROWS: each says what setup found of that program
|
|
3732
|
+
// (foundWords). A block above the question used to say it a second time, with each program's path,
|
|
3733
|
+
// where it was found and any API key already set; the path is said once a program is chosen, and a
|
|
3734
|
+
// key already set is said at the pay question, where it makes the key the default and is either
|
|
3735
|
+
// adopted or named as unused. A sign-in token already set, which that block named and never adopted,
|
|
3736
|
+
// is no longer said before the proof turn, which is still handed it with the rest of the shell
|
|
3737
|
+
// (proofTurn). Sign-in state itself is deliberately NOT guessed — the proof turn is the
|
|
3738
|
+
// only honest answer to it, and a guessed "signed in" that the turn then contradicts costs more than
|
|
3739
|
+
// no claim. Resolved once per pass, so a reader who mends something and comes back sees it mended.
|
|
3740
|
+
const found = engineMenuState();
|
|
3741
|
+
const pick = await choose(ENGINE_QUESTION, engineOptions(found), 0, PAY_PREAMBLE);
|
|
3310
3742
|
if (!pick.id) { sayNoEngine(); break; }
|
|
3311
3743
|
const eng = ENGINE_BINARIES[pick.id];
|
|
3312
3744
|
|
|
@@ -3330,108 +3762,63 @@ try {
|
|
|
3330
3762
|
continue;
|
|
3331
3763
|
}
|
|
3332
3764
|
|
|
3333
|
-
let bin = resolveEngineBin(process.env[eng.env] || eng.fallback);
|
|
3765
|
+
let bin = resolveEngineBin(process.env[eng.env] || eng.fallback, { engine: pick.id });
|
|
3334
3766
|
// Under WSL the Windows build on the appended PATH has been passed over. Said before the install
|
|
3335
3767
|
// offer below, because otherwise a reader whose Linux install is genuinely missing is asked to
|
|
3336
3768
|
// install a binary their own `which` already prints — and would decline for the wrong reason.
|
|
3337
3769
|
const shimNote = windowsShimNote(bin.skipped, eng.fallback);
|
|
3338
3770
|
if (shimNote) info(shimNote);
|
|
3339
|
-
if (!(bin.executable && !bin.relative) && eng.
|
|
3340
|
-
// ──
|
|
3771
|
+
if (!(bin.executable && !bin.relative) && eng.package) {
|
|
3772
|
+
// ── SETUP INSTALLS THE ONE PROGRAM THIS ENGINE RUNS ─────────────────────────────────────────────
|
|
3773
|
+
//
|
|
3774
|
+
// Signing in is a browser round-trip nobody here can perform for someone. Installing the program
|
|
3775
|
+
// is not, and the whole sequence (install, hand off to the vendor's own login, prove it with a
|
|
3776
|
+
// turn) is work this file already does either side of the gap.
|
|
3341
3777
|
//
|
|
3342
|
-
//
|
|
3343
|
-
//
|
|
3344
|
-
//
|
|
3778
|
+
// NOTHING IS BUNDLED INTO THE PACKAGE. Installing every engine for everyone, before anyone has
|
|
3779
|
+
// chosen one, downloaded both programs, and on npm 10 every platform's binaries too. So the program
|
|
3780
|
+
// is installed here, for the engine just picked and this platform only, into the engines folder
|
|
3781
|
+
// Clearotron owns under the reader's home (driver.config.mjs enginesFolder). That folder needs no
|
|
3782
|
+
// root, so there is one route and no prefix to probe, and it is not on PATH, so a copy this machine
|
|
3783
|
+
// installs itself later is still found first.
|
|
3345
3784
|
//
|
|
3346
|
-
// THE COMMAND IS SHOWN IN FULL AND THE DEFAULT IS NO. It runs as this user
|
|
3347
|
-
//
|
|
3348
|
-
//
|
|
3349
|
-
//
|
|
3350
|
-
//
|
|
3351
|
-
|
|
3352
|
-
warn(`no \`${eng.fallback}\` binary on PATH.`);
|
|
3785
|
+
// THE COMMAND IS SHOWN IN FULL AND THE DEFAULT IS NO. It runs as this user and installs
|
|
3786
|
+
// THIRD-PARTY SOFTWARE governed by that vendor's terms rather than by this repository's licence
|
|
3787
|
+
// (README §Licence, INSTALL §1), so a reader has to be able to read it before answering. It is an
|
|
3788
|
+
// npm install, never the vendor's `curl … | bash`, because a piped remote script cannot be read
|
|
3789
|
+
// before it runs, and it is spawned as argv, never through a shell.
|
|
3790
|
+
warn(cannotRunLine(eng, bin, process.env[eng.env]));
|
|
3353
3791
|
say(` ${eng.label}`);
|
|
3354
3792
|
say("");
|
|
3355
|
-
say(` ${eng.vendor}'s CLI is
|
|
3793
|
+
say(` ${eng.vendor}'s CLI is ${eng.licence}. Installing it accepts ${eng.vendor}'s`);
|
|
3356
3794
|
say(" terms, not this product's licence, and this product redistributes no part of it.");
|
|
3795
|
+
const dir = enginesFolder();
|
|
3796
|
+
say(` It goes into ${dir}, for this user and this platform only, and not`);
|
|
3797
|
+
say(" onto PATH, so a copy this machine installs itself later is used first.");
|
|
3798
|
+
say(` ${installSizeLine(eng)}`);
|
|
3357
3799
|
say("");
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
// dead-ending at a path prompt for a file that does not exist.
|
|
3366
|
-
const npmPrefix = (() => {
|
|
3367
|
-
const q = spawnSync("npm", ["prefix", "-g"], { encoding: "utf8" });
|
|
3368
|
-
return !q.error && q.status === 0 ? String(q.stdout).trim() : null;
|
|
3369
|
-
})();
|
|
3370
|
-
const prefixWritable = (() => {
|
|
3371
|
-
if (!npmPrefix) return null; // could not ask npm — not a verdict either way
|
|
3372
|
-
try { accessSync(join(npmPrefix, "lib"), constants.W_OK); return true; } catch { /* fall through */ }
|
|
3373
|
-
try { accessSync(npmPrefix, constants.W_OK); return true; } catch { return false; }
|
|
3374
|
-
})();
|
|
3375
|
-
if (prefixWritable === false) {
|
|
3376
|
-
say(` npm's global prefix here is ${npmPrefix}, and this user cannot write to it — the npm`);
|
|
3377
|
-
say(" route needs root on this box, so it is not offered first.");
|
|
3378
|
-
if (eng.installNoRoot) {
|
|
3379
|
-
say(` ${eng.vendor}'s no-root installer lands in ${eng.installNoRoot.lands} and needs no sudo:`);
|
|
3380
|
-
say("");
|
|
3381
|
-
say(` ${eng.installNoRoot.cmd}`);
|
|
3382
|
-
say("");
|
|
3383
|
-
say(" Run it YOURSELF in another terminal — it is the vendor's script, and this setup will");
|
|
3384
|
-
say(" not pipe a remote script into a shell for you. Come back and continue here.");
|
|
3385
|
-
} else {
|
|
3386
|
-
say(" The no-root way is to point npm at a prefix you own, then install:");
|
|
3387
|
-
say("");
|
|
3388
|
-
say(` npm config set prefix ~/.local && ${eng.install}`);
|
|
3389
|
-
say("");
|
|
3390
|
-
say(" (~/.local/bin must be on PATH.) Run that yourself in another terminal, then continue.");
|
|
3391
|
-
}
|
|
3392
|
-
if (await confirm("Done (or already installed elsewhere)? Check this box again", true)) {
|
|
3393
|
-
bin = resolveEngineBin(process.env[eng.env] || eng.fallback);
|
|
3394
|
-
if (!(bin.executable && !bin.relative)) {
|
|
3395
|
-
const home = process.env.HOME || homedir();
|
|
3396
|
-
const local = join(home, ".local", "bin", eng.fallback);
|
|
3397
|
-
if (isExec(local)) { ok(`found it at ${local} — not on this shell's PATH yet`); bin = resolveEngineBin(local); }
|
|
3398
|
-
else info("still not found — the path prompt below takes the absolute location if it landed somewhere else.");
|
|
3399
|
-
} else ok(`installed: ${bin.path}`);
|
|
3400
|
-
}
|
|
3401
|
-
} else if (await confirm(`Run \`${eng.install}\` now?`, false)) {
|
|
3402
|
-
say(` $ ${eng.install}`);
|
|
3403
|
-
const [cmd, ...args] = eng.install.split(" ");
|
|
3404
|
-
const r = spawnSync(cmd, args, { stdio: "inherit" });
|
|
3405
|
-
// THE EXIT CODE IS NOT THE ANSWER, and this is the issue's own rule. A package manager that
|
|
3406
|
-
// exits 0 having installed to a prefix outside this shell's PATH has succeeded at its job and
|
|
3407
|
-
// left us exactly where we started; one that exits non-zero may still have left a usable
|
|
3408
|
-
// binary. So what decides is the same resolution the ENGINE resolves with, and after that, a
|
|
3409
|
-
// turn.
|
|
3800
|
+
if (await confirm(`Run \`${engineInstallCommand(eng, dir)}\` now?`, false)) {
|
|
3801
|
+
say(` $ ${engineInstallCommand(eng, dir)}`);
|
|
3802
|
+
const r = spawnSync("npm", engineInstallArgs(eng, dir), { stdio: "inherit" });
|
|
3803
|
+
// THE EXIT CODE IS NOT THE ANSWER, and this is the issue's own rule. An install that exits 0 may
|
|
3804
|
+
// have left the vendor's placeholder (npm told to skip install scripts), and one that exits
|
|
3805
|
+
// non-zero may still have left a usable program. So what decides is the same resolution the
|
|
3806
|
+
// ENGINE resolves with, and after that, a turn.
|
|
3410
3807
|
if (r.error) problem(`could not run it: ${r.error.message}`);
|
|
3411
3808
|
else if (r.status !== 0) warn(`that command exited ${r.status ?? "on a signal"} — checking anyway, since its exit code is not what settles this.`);
|
|
3412
|
-
bin = resolveEngineBin(
|
|
3413
|
-
if (
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
warn(`no \`${eng.fallback}\` on PATH after that command.`);
|
|
3426
|
-
if (prefix) info(`npm installs global binaries under ${join(prefix, "bin")} — add that to PATH, or give the absolute path below.`);
|
|
3427
|
-
// The other route, re-offered rather than a dead end: what happened is
|
|
3428
|
-
// reported above; what to do next must not be only a path prompt at a file that never landed.
|
|
3429
|
-
if (eng.installNoRoot) info(`the vendor's no-root installer is \`${eng.installNoRoot.cmd}\` — run it yourself in another terminal (lands in ${eng.installNoRoot.lands}), then give the path below or re-run setup.`);
|
|
3430
|
-
}
|
|
3431
|
-
} else {
|
|
3432
|
-
ok(`installed: ${bin.path}`);
|
|
3433
|
-
}
|
|
3434
|
-
}
|
|
3809
|
+
bin = resolveEngineBin(eng.fallback, { engine: pick.id });
|
|
3810
|
+
if (bin.executable && !bin.relative) { if (bin.source === "installed") ok(`installed: ${bin.path}`); }
|
|
3811
|
+
else warn(cannotRunLine(eng, bin, ""));
|
|
3812
|
+
// RESOLVED WITH THE ENGINE'S DEFAULT WORD, NOT THE SETTING IN FORCE. The line above the offer promised
|
|
3813
|
+
// to install the program "and use that instead", and it is said only when a setting names a copy that
|
|
3814
|
+
// cannot run. A setting that names a program never falls through to the copy setup installed, so
|
|
3815
|
+
// resolving with it here found nothing, whatever npm had put in place, and setup asked for a path.
|
|
3816
|
+
// The default word is what a run reads once the proof turn passes (engineProgramSetting writes it for
|
|
3817
|
+
// the copy setup installed), and it replaces the setting in the file setup writes. So what is found
|
|
3818
|
+
// here is what a run will use: the machine's own copy on PATH if it has one, and otherwise the copy
|
|
3819
|
+
// just installed, and "installed" is said only of the latter. A failure is said without the
|
|
3820
|
+
// setting's name, which only the declined branch below says.
|
|
3821
|
+
} else if (namedSetting(eng, process.env[eng.env])) info(ownCopyLine(eng));
|
|
3435
3822
|
}
|
|
3436
3823
|
if (!(bin.executable && !bin.relative)) {
|
|
3437
3824
|
warn(`no usable \`${eng.fallback}\` binary.`);
|
|
@@ -3455,11 +3842,18 @@ try {
|
|
|
3455
3842
|
continue;
|
|
3456
3843
|
}
|
|
3457
3844
|
const p = await askValue("Absolute path:");
|
|
3458
|
-
bin = resolveEngineBin(p);
|
|
3845
|
+
bin = resolveEngineBin(p, { engine: pick.id });
|
|
3459
3846
|
if (bin.relative) warn("that path is relative. Stage subprocesses run with cwd set to the run directory, so it will not resolve — using the absolute form.");
|
|
3460
|
-
if (!bin.executable) { problem(`${bin.path ?? resolve(p)} is not an executable file.`); continue; }
|
|
3847
|
+
if (!bin.executable) { problem(`${bin.path ?? resolve(p)} is ${bin.rejected?.[0]?.why ?? "not an executable file"}.`); continue; }
|
|
3848
|
+
}
|
|
3849
|
+
ok(`found ${bin.path}${bin.source === "installed" ? `, the copy Clearotron installed${bin.version ? ` (${bin.version})` : ""}` : ""}`);
|
|
3850
|
+
// THE TERMS SENTENCE TRAVELS WITH THE PROGRAM, NOT WITH THE INSTALL OFFER. It was said only when this
|
|
3851
|
+
// step offered to install the CLI, and a copy Clearotron installed on an earlier run skips that offer, so it is
|
|
3852
|
+
// said here too. Using it, rather than installing it, is what accepts the vendor's terms.
|
|
3853
|
+
if (bin.source === "installed") {
|
|
3854
|
+
say(` ${eng.vendor}'s CLI is ${eng.licence}. Using it accepts ${eng.vendor}'s`);
|
|
3855
|
+
say(" terms, not this product's licence, and this product redistributes no part of it.");
|
|
3461
3856
|
}
|
|
3462
|
-
ok(`found ${bin.path}`);
|
|
3463
3857
|
|
|
3464
3858
|
// ── item 5 — HOW THIS BOX PAYS, asked BEFORE the proof ────────────────────────────────────
|
|
3465
3859
|
//
|
|
@@ -3479,11 +3873,16 @@ try {
|
|
|
3479
3873
|
// adopting OPENAI_API_KEY instead would write a .env that `auth.mjs` refuses — the same defect this
|
|
3480
3874
|
// item exists to remove, wearing the other engine.
|
|
3481
3875
|
const ambientKeyPresent = present(process.env[eng.apiKeyEnv]);
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3876
|
+
// A cloud switched on in this shell is the reader's own setup saying how Claude is paid, so it makes the
|
|
3877
|
+
// cloud answer the default, as a key in the environment makes the key answer the default.
|
|
3878
|
+
const ambientClouds = pick.id === "anthropic-agent" ? cloudsSwitchedOn(process.env) : [];
|
|
3879
|
+
const ambientCloud = ambientClouds.length === 1 ? CLOUD_CHOICES.findIndex((c) => c.id === ambientClouds[0]) : -1;
|
|
3880
|
+
const pay = payQuestion({ engineId: pick.id, eng, bin });
|
|
3881
|
+
const authPick = await choose(pay.question, pay.answers, ambientCloud >= 0 ? 2 : ambientKeyPresent ? 1 : 0);
|
|
3486
3882
|
let apiKey = null;
|
|
3883
|
+
// A cloud answer's settings: the billing word, the cloud's switch and each answer given. They are the
|
|
3884
|
+
// probe's environment below and, once it passes, lines in the .env, so a run bills the account proved.
|
|
3885
|
+
let cloudEnv = null;
|
|
3487
3886
|
if (authPick.id === "api-key") {
|
|
3488
3887
|
if (ambientKeyPresent) {
|
|
3489
3888
|
apiKey = process.env[eng.apiKeyEnv];
|
|
@@ -3491,13 +3890,26 @@ try {
|
|
|
3491
3890
|
} else {
|
|
3492
3891
|
apiKey = await askValue(`${eng.apiKeyEnv}:`, { secret: true });
|
|
3493
3892
|
}
|
|
3494
|
-
} else if (
|
|
3893
|
+
} else if (authPick.id === "cloud") {
|
|
3894
|
+
const cloud = await choose("Which cloud account pays?", CLOUD_CHOICES, Math.max(ambientCloud, 0));
|
|
3895
|
+
info(cloud.note);
|
|
3896
|
+
const answers = {};
|
|
3897
|
+
for (const a of cloud.asks) {
|
|
3898
|
+
// A value already in this shell is the default, except a secret: that is adopted and named, and never
|
|
3899
|
+
// shown in a prompt.
|
|
3900
|
+
const have = process.env[a.env];
|
|
3901
|
+
if (a.secret && present(have)) { answers[a.env] = have; info(`${a.env} is already in your environment — adopting it.`); continue; }
|
|
3902
|
+
answers[a.env] = await askValue(a.q, { def: present(have) ? have : (a.def ?? ""), secret: a.secret === true, skippable: a.skippable === true, skipped: a.skipped ?? null });
|
|
3903
|
+
}
|
|
3904
|
+
cloudEnv = cloudSettings(cloud.id, answers);
|
|
3905
|
+
}
|
|
3906
|
+
if (authPick.id !== "api-key" && ambientKeyPresent) {
|
|
3495
3907
|
// Not a warning: it is the resolved behaviour, stated once, because the opposite guess is the
|
|
3496
|
-
// expensive one. The adapter strips the key under subscription, so the probe below
|
|
3497
|
-
// exercise
|
|
3498
|
-
info(`${eng.apiKeyEnv} is set in your environment and will NOT be used —
|
|
3908
|
+
// expensive one. The adapter strips the key under subscription and under cloud, so the probe below
|
|
3909
|
+
// really does exercise that lane and the key sitting in the environment changes nothing.
|
|
3910
|
+
info(`${eng.apiKeyEnv} is set in your environment and will NOT be used — ${authPick.id} mode strips it from every stage.`);
|
|
3499
3911
|
}
|
|
3500
|
-
const authEnv = { [eng.authEnv]: authPick.id, ...(apiKey ? { [eng.apiKeyEnv]: apiKey } : {}) };
|
|
3912
|
+
const authEnv = cloudEnv ?? { [eng.authEnv]: authPick.id, ...(apiKey ? { [eng.apiKeyEnv]: apiKey } : {}) };
|
|
3501
3913
|
|
|
3502
3914
|
// THE PROOF. An executable file is not a working engine: a signed-out CLI, an expired credential, an
|
|
3503
3915
|
// unreachable tier and a spent quota all pass every check above and surface as a stage failure after
|
|
@@ -3520,17 +3932,27 @@ try {
|
|
|
3520
3932
|
}
|
|
3521
3933
|
for (;;) {
|
|
3522
3934
|
say(" Running one turn…");
|
|
3523
|
-
|
|
3935
|
+
// Read on every try, so a setting the reader fixes in the file between tries is the one the next turn uses.
|
|
3936
|
+
const v = await probeEngineTurn(proofTurn({ engineId: pick.id, eng, bin, authEnv, settings: settingsInForce() }));
|
|
3524
3937
|
if (v.ok) {
|
|
3525
3938
|
ok(`${pick.id} completed a turn on the ${authPick.id} lane — binary, credential, billing mode and model access all work.`);
|
|
3939
|
+
const served = servedLine(v);
|
|
3940
|
+
if (served) info(served);
|
|
3526
3941
|
candidate.CLEAROTRON_AI = pick.id;
|
|
3527
|
-
|
|
3942
|
+
// ALWAYS WRITTEN, and for the copy Clearotron installed it is the engine's default word: see
|
|
3943
|
+
// engineProgramSetting for why leaving it out was not enough.
|
|
3944
|
+
candidate[eng.env] = engineProgramSetting(eng, bin);
|
|
3528
3945
|
candidate[eng.authEnv] = authPick.id;
|
|
3529
3946
|
if (apiKey) candidate[eng.apiKeyEnv] = apiKey;
|
|
3947
|
+
// A cloud answer writes the settings the turn above ran on, every one, so a run bills that account.
|
|
3948
|
+
const cloudLines = Object.entries(cloudEnv ?? {}).filter(([k]) => k !== eng.authEnv);
|
|
3949
|
+
for (const [k, val] of cloudLines) candidate[k] = val;
|
|
3530
3950
|
info(`CLEAROTRON_AI=${pick.id}`);
|
|
3531
3951
|
info(`${eng.authEnv}=${authPick.id} — the lane the turn above actually ran on.`);
|
|
3532
3952
|
if (apiKey) info(`${eng.apiKeyEnv}=… — adopted, so a run bills the way you just proved.`);
|
|
3533
|
-
|
|
3953
|
+
for (const [k, val] of cloudLines) info(shownSetting(k, val));
|
|
3954
|
+
if (bin.source === "installed") info(`${eng.env}=${eng.fallback} — the engine's default: this machine's own \`${eng.fallback}\` once it has one, and the copy Clearotron installed until then.`);
|
|
3955
|
+
else info(`${eng.env}=${bin.path} — the absolute form, because a service's PATH is not your shell's.`);
|
|
3534
3956
|
break engine;
|
|
3535
3957
|
}
|
|
3536
3958
|
problem(probeFailureText(v));
|
|
@@ -3551,8 +3973,10 @@ try {
|
|
|
3551
3973
|
// — THE HAND-OFF. Signing in is the one step of this sequence nobody here can perform for
|
|
3552
3974
|
// someone, so the wizard names the command, waits, and re-probes rather than ending at a
|
|
3553
3975
|
// description of what is wrong. The text comes from ENGINE_BINARIES so the two adapters cannot
|
|
3554
|
-
// drift into one set of instructions.
|
|
3555
|
-
|
|
3976
|
+
// drift into one set of instructions. BY HOW THE TURN IS PAID FOR (signInHandOff): the sign-in is a
|
|
3977
|
+
// subscription's step, and on a cloud or under a key the verdict above has named what to check.
|
|
3978
|
+
const handOff = signInHandOff(eng, bin, authPick.id);
|
|
3979
|
+
for (const line of handOff.lines) info(line);
|
|
3556
3980
|
// ── THE HEADLESS ENDING ────────────────────────────────────────────────
|
|
3557
3981
|
//
|
|
3558
3982
|
// On a box with no browser the interactive sign-in cannot complete, and the documented route had
|
|
@@ -3562,18 +3986,15 @@ try {
|
|
|
3562
3986
|
// stream layout is not ours to guess at, and a paste works whatever it prints where. Codex's
|
|
3563
3987
|
// headless ending writes its own auth file and there is nothing to capture — the command is
|
|
3564
3988
|
// named, and the re-probe is the proof either way.
|
|
3565
|
-
if (
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
authEnv[eng.headless.tokenEnv] = tok; // the re-probe below must prove the lane WITH it
|
|
3575
|
-
info(`${eng.headless.tokenEnv} captured — the turn below proves it before anything is written.`);
|
|
3576
|
-
}
|
|
3989
|
+
if (handOff.captureToken) {
|
|
3990
|
+
// A TOKEN PASTED AT THE YES/NO IS THE ANSWER (confirmOrKey): taken, never shown, not asked for twice.
|
|
3991
|
+
const answer = await confirmOrKey(`Did that give you a token to paste? Capture it into ${eng.headless.tokenEnv} now`, false, { what: "token" });
|
|
3992
|
+
const tok = answer.value ?? (answer.yes
|
|
3993
|
+
? await askValue(`${eng.headless.tokenEnv}:`, { secret: true, skippable: true, skipped: "Nothing captured." }) : null);
|
|
3994
|
+
if (tok !== null) {
|
|
3995
|
+
candidate[eng.headless.tokenEnv] = tok;
|
|
3996
|
+
authEnv[eng.headless.tokenEnv] = tok; // the re-probe below must prove the sign-in WITH it
|
|
3997
|
+
info(`${eng.headless.tokenEnv} captured — the turn below proves it before anything is written.`);
|
|
3577
3998
|
}
|
|
3578
3999
|
}
|
|
3579
4000
|
// — found in review, and the same trap closed one prompt over. The default
|