clearotron 0.3.0-beta.2 → 0.3.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/INSTALL.md +32 -6
  2. package/bin/connect.mjs +68 -25
  3. package/bin/disconnect.mjs +16 -11
  4. package/bin/onboard.mjs +65 -6
  5. package/bin/start.mjs +7 -1
  6. package/build-info.json +2 -2
  7. package/driver/CHANGELOG.md +38 -0
  8. package/driver/driver.config.mjs +5 -0
  9. package/driver/engine/anthropic-agent.mjs +34 -17
  10. package/driver/gateway.mjs +76 -15
  11. package/driver/package.json +1 -1
  12. package/driver/portal-service.mjs +1 -1
  13. package/driver/profile-service.mjs +31 -3
  14. package/driver/publish/index.mjs +12 -9
  15. package/driver/publish/knockout.mjs +4 -2
  16. package/driver/publish/office-record-links.mjs +56 -21
  17. package/driver/recipe-service.mjs +14 -5
  18. package/driver/record-origins.mjs +14 -0
  19. package/driver/suite-census.json +34 -28
  20. package/mcp-server/CHANGELOG.md +8 -0
  21. package/mcp-server/lib/driver.mjs +2 -0
  22. package/mcp-server/lib/knockout.mjs +2 -2
  23. package/mcp-server/lib/options.mjs +9 -2
  24. package/mcp-server/package.json +1 -1
  25. package/package.json +1 -1
  26. package/portal-ui/dist/assets/{index-CsCuPshD.css → index-Cv-E_agg.css} +199 -86
  27. package/portal-ui/dist/assets/{index-CcFjgM78.js → index-DWYCsOCJ.js} +514 -380
  28. package/portal-ui/dist/index.html +2 -2
  29. package/portal-ui/package.json +1 -1
  30. package/providers/clarivate/src/core.js +5 -0
  31. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  32. package/providers/oauth-mcp-bridge/package.json +1 -1
  33. package/scripts/e2e-unread-terminals.mjs +1 -1
  34. package/scripts/e2e.mjs +114 -9
  35. package/scripts/revisit-render-check.mjs +22 -4
  36. package/scripts/travelling-predicates.mjs +1 -1
  37. package/shared/connect-clients.mjs +282 -321
  38. package/shared/names-in-force.mjs +1 -0
  39. package/shared/stdio-connect.mjs +63 -0
  40. package/shared/store-in-repo.mjs +50 -2
@@ -8,386 +8,347 @@
8
8
  // understand, i can access the UI at 127.0.0.1 but i cant access the MCP server? … i didnt need to mint
9
9
  // a key for the UI or open a tunnel i just ran clearotron start?"* And then: *"AND it might not just be
10
10
  // cowork, it might be chatgpt or perplexity. or [another agent platform]. COME ON MAN. this shouldn't
11
- // be so hard."* (One platform he named is not named back: this product does not require it, and a
12
- // product that lists an integrator by name starts describing itself in terms of what it happens to run.
13
- // Any agent of that shape is served by the "Another agent" row, which is what that row is for.)
11
+ // be so hard."*
14
12
  //
15
- // He is right that it should not be hard, and his second message is what makes it easy. The thing that
16
- // varies is NOT the reader's network. It is WHAT EACH CLIENT CAN ACCEPT — a property of the client,
17
- // which we know and the reader should never have to work out:
13
+ // He is right that it should not be hard. The reader knows two things for certain: which app they use,
14
+ // and whether Clearotron is on the machine that app runs on. Everything else — a command or a settings
15
+ // block, an address, a key — follows from those two answers, so the product works it out and the reader
16
+ // is only ever asked the two questions.
18
17
  //
19
- // accepts "stdio" the client can spawn a local process. It needs a command and NOTHING else: no key,
20
- // no address, no port, no tunnel. This is the whole answer and readers do not
21
- // believe it, having just been told about tunnels — so the copy says it plainly.
22
- // accepts "http" the client speaks to an address and proves itself with a key. That address is
23
- // ALWAYS the publicly reachable one — see below.
24
- // accepts "either" we do not know what the reader's agent can do, so we say both and let them pick.
18
+ // ── TWO ROUTES, AND EVERY APP TAKES BOTH ─────────────────────────────────────────────────────────────
25
19
  //
26
- // ── THE `runsOn` AXIS IS DELETED, AND WAS A LIVE FALSE OFFER ( §3, §9) ─────────
20
+ // disk Clearotron is installed on the machine the app runs on. The app starts the server
21
+ // itself from the copy already there: no key, no address, no network.
22
+ // public-http Clearotron is running elsewhere. The app reaches its public address with a key minted
23
+ // for the person pressing. That address is ALWAYS the publicly reachable one — see
24
+ // below for why no loopback address is ever a true answer.
27
25
  //
28
- // It used to sit beside `accepts` and answer "which address is enough": `readers-machine` got loopback,
29
- // `vendor-cloud` got the public one. The distinction does not exist. From the vendor's own help centre:
26
+ // This table used to give each row ONE of those, as an `accepts` axis, and it was wrong in both
27
+ // directions: Claude Code and Codex were offered only on this computer although both connect to a remote
28
+ // address with a key, and ChatGPT only remotely although its desktop app reads the same settings file as
29
+ // Codex. The owner met the result as a hosted install that "could never work from his laptop". So every
30
+ // row now carries both routes' steps, drawn from the approved design, and the page asks where
31
+ // Clearotron is running instead of guessing.
30
32
  //
31
- // "Claude connects to your remote MCP server from Anthropic's cloud infrastructure, rather than from
32
- // your local device. This is true across every Claude client, including claude.ai, Claude Desktop,
33
- // Cowork, and the mobile apps." … "Your MCP server must be reachable over the public internet from
34
- // Anthropic's IP ranges."
33
+ // ── DATA, NOT BRANCHES ───────────────────────────────────────────────────────────────────────────
35
34
  //
36
- // Cowork RUNS on the reader's machine and CONNECTS from the vendor's cloud; the axis conflated the two
37
- // and classified it `readers-machine`, so `clearotron connect --client cowork` printed a loopback
38
- // address that Cowork rejects — today, in the shipped product. HTTPS only; plain HTTP is refused too.
35
+ // "The list of clients is data, not code branches — adding one is a row." A single `if (id === 'codex')`
36
+ // anywhere downstream is the seed of drift: the branch and the row disagree, both still render, and the
37
+ // reader follows whichever one is wrong. `driver/test/connect-clients-are-data.test.mjs` refuses a client
38
+ // name in a conditional on every surface that renders these rows.
39
39
  //
40
- // So the axis is gone rather than corrected. Correcting it would leave a field that is now fully
41
- // determined by `accepts` — a second name for one fact, and an invitation to branch on it again.
40
+ // A STEP NAMES ITS COPY BY SHAPE, and never spells it. The strings a reader pastes are composed in
41
+ // `shared/stdio-connect.mjs`, once, and a row says which one its first step hands over. That keeps each
42
+ // command to one author (`driver/test/the-connect-route-has-one-author.test.mjs`) and lets two rows that
43
+ // take the same file — ChatGPT's desktop app and Codex both read `~/.codex/config.toml` — hand over the
44
+ // same bytes by construction rather than by care.
42
45
  //
43
- // So the tunnel is not a mode anybody chooses. It is what every `accepts: "http"` row requires, and
44
- // picking Claude Code never mentions it.
46
+ // STEP TEXT CARRIES TWO MARKS and nothing else: `**…**` for the name of a control the reader looks for,
47
+ // and a backtick pair for a literal they type or read back. The page draws them; the terminal prints the
48
+ // literal and drops the emphasis. Anything richer would be markup in data, which a surface then has to
49
+ // trust.
45
50
  //
46
- // ── WHY THIS TABLE REPLACED THE BROWSER'S OWN ───────────────────────────────
51
+ // ── THESE SENTENCES ARE READ BY A LAWYER, NOT AN ENGINEER ────────────────────────────────────────
47
52
  //
48
- // There were TWO tables. This one, and `portal-ui/src/contract/assistants.ts`, which carried its own
49
- // axis — `door: browser|key|either` × `reach: remote|local` — and its own offered/withheld derivation.
50
- // Two tables partitioning the same clients on different axes do not merely risk drifting; they had
51
- // already drifted before either was finished. The page said Codex needs a key address. This table says
52
- // Codex needs no key at all. On a local install the page's answer resolved to `null`, so the page named
53
- // a one-line command in its own instructions and then rendered no command — which is that very
54
- // defect, sitting inside the page written to answer it.
53
+ // The owner quoted two earlier refusals back as fails: "this installation is not running yet, so there
54
+ // is nothing for an assistant to connect to" ("very confusing") and "connects from its vendor's servers,
55
+ // so it cannot reach a machine that is not published to the internet … under a name that resolves".
56
+ // Every refusal here says the fact as WHAT HAPPENS NEXT and WHO DOES IT.
55
57
  //
56
- // So the browser no longer derives any of this. It is handed resolved rows and renders them. That is not
57
- // a preference for server-side logic: the install's own filesystem path is not a browser fact, and any
58
- // derivation that needs it must happen where it is known. `assistantsFor`, `addressFor` and the `reach`
59
- // axis are DELETED rather than kept in step, because a second author kept in step by hand is the thing
60
- // that broke.
58
+ // NO REFUSAL MAY SAY "address" OR "key". A refusal renders where an arriving reader can see it, and
59
+ // `scripts/ai-page-render-check.mjs` refuses six words on every line of the arriving page. The steps may
60
+ // use them: they appear only after the reader has picked an app, which is the moment those words start
61
+ // meaning something to them.
61
62
  //
62
- // ── DATA, NOT BRANCHES ───────────────────────────────────────────────────────────────────────────
63
+ // ── THE LAUNCH ROUTE ( settled 8; owner: "fastest possible way to reach 'chat about my report'") ──
63
64
  //
64
- // "The list of clients is data, not code branches — adding one is a row." A single `if (id === 'codex')`
65
- // anywhere downstream is the seed of the same drift: the branch and the row disagree, both still render,
66
- // and the reader follows whichever one is wrong. `driver/test/connect-clients-are-data.test.mjs` refuses
67
- // a client name in a conditional on every surface that renders these rows.
65
+ // A row MAY carry `launch: { url, verifiedOn, by }` — a page a press can open so the reader lands in
66
+ // their assistant with the connector in front of them. NO ROW CARRIES ONE TODAY, and that is a statement
67
+ // rather than an omission: which vendors allow it is a fact somebody has to DRIVE, and a URL written here
68
+ // from memory would be a button that looks like it works and does not. `connect-clients-are-data`
69
+ // REFUSES a launch URL that carries no date and no name.
68
70
 
69
- /**
70
- * Every client we can speak to, and what it can accept. Adding one is a row.
71
- *
72
- * `steps` is a function of what the deployment resolved, not a fixed list, because the instruction for
73
- * a browser-door assistant names an email and the instruction for a stdio assistant names a command.
74
- * Interpolating them here keeps the recipe and the address that recipe refers to in one place.
75
- */
76
- /* ── item 5 — THESE SENTENCES ARE READ BY A LAWYER, NOT AN ENGINEER ─────────
77
- The owner quoted two of them back as fails: "this installation is not running yet, so there is
78
- nothing for an assistant to connect to" ("very confusing") and "connects from its vendor's servers,
79
- so it cannot reach a machine that is not published to the internet … under a name that resolves"
80
- ("who cares about vendors servers etc if you are a UI user … resolves, vendors severs, wtf?").
81
-
82
- Every one of them now says the same fact as WHAT HAPPENS NEXT and WHO DOES IT. No vendor's servers,
83
- no name resolution, no processes, no checkouts — the four things a reader cannot act on.
84
-
85
- AND NONE OF THEM MAY SAY "address" OR "key". These rows render on the ARRIVING page, before any
86
- press, and `scripts/ai-page-render-check.mjs` refuses six words on every line an arriving reader
87
- sees. That constraint is why the old sentence reached for "a name that resolves" instead of the
88
- obvious word, and it is worth knowing before rewriting one of these: the plain word is "on the web".
71
+ import { KEY_SLOT, STDIO_SERVER_NAME, remoteConnectFor } from "./stdio-connect.mjs";
89
72
 
90
- WHICH of these states should exist at all is a different question and not this issue's — it is open
91
- with the owner as Q1, and his Settled 4 may delete the
92
- local one entirely. Wording them honestly costs nothing if it does. */
93
- /* ── THE LAUNCH ROUTE ( settled 8; owner: "fastest possible way to reach 'chat about
94
- my report'") ────────────────────────────────────────────────────────────────────────────────────────
73
+ /** The two places Clearotron can be, relative to the reader's app. The page asks; the terminal flags. */
74
+ export const ROUTES = Object.freeze(["disk", "public-http"]);
95
75
 
96
- A row MAY carry `launch: { url, verifiedOn, by }` — a page a press can open so the reader lands in
97
- their assistant with the connector in front of them, instead of being told where to click.
76
+ /** The terminal's spelling of the same question: `--where here|elsewhere`, from the reader's side. */
77
+ export const WHERE_FLAG = Object.freeze({ here: "disk", elsewhere: "public-http" });
98
78
 
99
- NO ROW CARRIES ONE TODAY, and that is a statement rather than an omission. The ruling is "where a
100
- vendor allows launching directly, launch; otherwise the shortest possible paste", and which vendors
101
- allow it is a fact about their product that somebody has to DRIVE before we can claim it. A URL
102
- written here from memory would be a button that looks like it works and does not — the exact class
103
- this file exists to prevent, and the one the owner has already met twice.
79
+ // Hints more than one row uses. One spelling each, so two apps cannot describe one fact two ways.
80
+ const KEY_HINT = "The key is made for you when you press, and is not shown again.";
81
+ const CHECK_HINT = `To check: \`claude mcp list\` shows \`${STDIO_SERVER_NAME} ✓ Connected\`.`;
82
+ const BRIEF = "ask it to brief you on your clearances.";
104
83
 
105
- So the mechanism is built and the data is empty. Populating it is one row plus the evidence:
106
- `verifiedOn` is the date it was driven and `by` is who drove it, and `connect-clients-are-data`
107
- REFUSES a launch URL that carries neither. Nobody has to touch the page. */
84
+ /**
85
+ * Every app we can speak to, and the steps for each route. Adding one is a row.
86
+ *
87
+ * `routes[route]` is `{ steps(ctx) }`: the reader's steps, in order, as `{ text, copy?, hint? }`. Step 1
88
+ * is always the copy — the one thing the reader takes away — and `copy` names a shape in
89
+ * `shared/stdio-connect.mjs` (a stdio shape on disk, a remote shape on the web). `ctx` carries what the
90
+ * deployment resolved that a sentence may name: today, the operator's sign-in identity.
91
+ *
92
+ * `lead` is the route a caller gets when it names none — the terminal's `--client` without `--where`.
93
+ * It is the route each row was served on before both existed, so a scripted invocation keeps doing what
94
+ * it did. `aliases` are ids a row answered to before rows merged, each with the route it meant; old
95
+ * scripts and old muscle memory keep working, and nothing else reads them.
96
+ */
108
97
  export const CONNECT_CLIENTS = Object.freeze([
109
- // ── Runs on the reader's own machine and can spawn a process: needs NOTHING but a command. ──────
110
98
  {
111
- id: "claude-code", name: "Claude Code", accepts: "stdio", stdioShape: "claude-cli",
112
- steps: ({ command }) => [
113
- "Paste it into a terminal on this machine and run it",
114
- "Then ask it to brief you on this service — it reads its own instructions",
115
- ...(command ? [] : ["(this copy of the software is incomplete — whoever installed it will need to install it again)"]),
116
- ],
99
+ id: "claude", name: "Claude", lead: "public-http",
100
+ // ONE ROW, BECAUSE IT IS ONE APP ("you know its just ONE APP on a laptop which has cowork and code in
101
+ // it and claude is what its called and there is no such thing as desktop"). `cowork` and
102
+ // `claude-desktop` were rows once; they answer here now, each on the route it used to mean.
103
+ aliases: { cowork: "public-http", "claude-desktop": "disk" },
104
+ routes: {
105
+ disk: {
106
+ steps: () => [
107
+ { text: "Copy this.", copy: "desktop-json" },
108
+ { text: "In the Claude desktop app, open **Settings → Developer → Edit Config** and paste it in.",
109
+ hint: `Other servers already there? Add just the \`${STDIO_SERVER_NAME}\` entry inside \`mcpServers\`.` },
110
+ { text: `Restart the Claude app. \`${STDIO_SERVER_NAME}\` appears in its tools.`,
111
+ hint: "Desktop app only — Claude on the web or your phone can’t reach this machine." },
112
+ ],
113
+ },
114
+ "public-http": {
115
+ // DRIVEN, NOT RECALLED: the owner connected on 2026-09-04 by pasting the address, setting
116
+ // Authentication to None, and adding an `Authorization: Bearer <key>` request header. The warning
117
+ // travels with the steps and is not optional — Claude probes, infers sign-in, and shows an
118
+ // authentication warning even when None is right; a reader who is not told to ignore it will
119
+ // assume they have done it wrong.
120
+ verifiedOn: "2026-09-04", by: "owner",
121
+ steps: () => [
122
+ { text: "Copy your address and key.", copy: "address-and-key", hint: KEY_HINT },
123
+ { text: "In Claude, open **Settings → Connectors → Add custom connector**." },
124
+ { text: "Paste the address — the first line." },
125
+ { text: "Set **Authentication** to **None**." },
126
+ { text: "Add a request header: **Authorization** = `Bearer`, then the key — the second line." },
127
+ { text: "Press **Add**. If Claude shows an authentication warning, ignore it." },
128
+ ],
129
+ },
130
+ },
117
131
  },
118
132
  {
119
- id: "codex", name: "Codex CLI", accepts: "stdio", stdioShape: "codex-toml",
120
- steps: () => [
121
- "Paste it into a terminal on this machine and run it",
122
- "Then ask it to brief you on this service — it reads its own instructions",
123
- ],
133
+ id: "claude-code", name: "Claude Code", lead: "disk",
134
+ routes: {
135
+ disk: {
136
+ steps: () => [
137
+ { text: "Copy this.", copy: "claude-cli" },
138
+ { text: "Paste it into a terminal on this computer and press Enter." },
139
+ { text: `Start Claude Code and ${BRIEF}`, hint: CHECK_HINT },
140
+ ],
141
+ },
142
+ // From the vendor's documentation, 2026-09-11; not yet driven against a hosted install.
143
+ "public-http": {
144
+ steps: () => [
145
+ { text: "Copy this command.", copy: "claude-cli-http", hint: KEY_HINT },
146
+ { text: "Paste it into a terminal and press Enter." },
147
+ { text: `Start Claude Code and ${BRIEF}`, hint: CHECK_HINT },
148
+ ],
149
+ },
150
+ },
124
151
  },
125
152
  {
126
- // NOT A SEPARATE PRODUCT (decided: there is no such thing as desktop). This is
127
- // Claude reached the way that runs on the reader's own machine, so it carries Claude's name and says
128
- // which way it is in the sub-label. The `desktop-json` stdio shape is unchanged — what moved is what
129
- // a reader is told this is, not how it connects.
130
- id: "claude-desktop", name: "Claude", sub: "app, on this computer", accepts: "stdio", stdioShape: "desktop-json",
131
- steps: () => [
132
- "Paste it into Claude Desktop's own settings file — Advanced, under these steps, names the file",
133
- "Restart Claude Desktop, and this service appears in its tools",
134
- ],
135
- },
136
-
137
- // ── Speaks HTTP. Connects from the vendor's own servers. ────────────────────────────────────────
138
- //
139
- // ONE ROW, BECAUSE IT IS ONE APP (ruling in session: "you know its just ONE
140
- // APP on a laptop which has cowork and code in it and claude is what its called"). `cowork` was a
141
- // separate row here and is merged in; the sub-label carries where it is met, which is a fact about the
142
- // reader's screen rather than about our software.
143
- //
144
- // THE STEPS BELOW WERE DRIVEN, NOT RECALLED, and the merge is what settles which of two contradictory
145
- // sequences survives. This row previously said "Connect, then sign in when the browser opens" — nobody
146
- // ever drove that. The cowork row said something different and somebody had: the owner connected on
147
- // 2026-09-04 by pasting the address, setting Authentication to None, and adding an
148
- // `Authorization: Bearer <key>` request header. Both rows described the same app reaching the same
149
- // door — `accepts: "http"`, one `public-http` offer, same address, same press — so they were never two
150
- // routes to keep apart. They were one app described twice, and only one description was observed.
151
- //
152
- // The earlier defect in the same class, kept here because it is the reason the rule exists: the row
153
- // used to end "Choose API key, and paste the second line we copied", and there is no API key control
154
- // in that dialog. A client following it went looking for a box that is not the way in, on the page
155
- // whose entire job is to get them connected.
156
- //
157
- // The warning travels with the steps and is not optional: Claude tags the server "Always required ·
158
- // Detected" because it probes and infers OAuth. `None` is still correct despite the orange box, and a
159
- // reader who is not told that will assume they have done it wrong.
160
- {
161
- id: "claude", name: "Claude", sub: "app, web, and Cowork", accepts: "http",
162
- verifiedOn: "2026-09-04", by: "owner",
163
- steps: ({ address }) => [
164
- "Settings → Connectors → Add custom connector",
165
- `Paste ${address ?? "the first of the two lines we copied"}`,
166
- "Set Authentication to None",
167
- "Add a request header: Authorization = Bearer, then the second line we copied",
168
- "Add. If it warns that authentication is required, that is its own guess — None is correct here",
169
- ],
153
+ id: "chatgpt", name: "ChatGPT", lead: "public-http",
154
+ routes: {
155
+ // The ChatGPT desktop app reads Codex's settings file (the vendor's documentation, 2026-09-11), so
156
+ // it takes Codex's shape and the same bytes. ChatGPT on the web cannot start a local server.
157
+ disk: {
158
+ steps: () => [
159
+ { text: "Copy this.", copy: "codex-toml" },
160
+ { text: "Open `~/.codex/config.toml` and paste it at the end.",
161
+ hint: "The ChatGPT desktop app reads the same settings file as Codex." },
162
+ { text: "Restart the ChatGPT app.",
163
+ hint: "Desktop app only — ChatGPT on the web or your phone can’t reach this machine." },
164
+ ],
165
+ },
166
+ // The address and no key: ChatGPT signs its reader in through the browser. The Developer-mode path
167
+ // is the one the vendor documents today; the older "Connectors → Advanced" path was stale.
168
+ "public-http": {
169
+ steps: ({ operator }) => [
170
+ { text: "Copy the address.", copy: "address" },
171
+ { text: "In ChatGPT on the web, turn on **Settings → Security and login → Developer mode**.",
172
+ hint: "Needs a Plus, Pro, Business, Enterprise or Edu plan. On a company plan, your admin may have to allow it." },
173
+ { text: "Add a custom connector and paste the address." },
174
+ { text: `Sign in when the browser opens — use ${operator ?? "your work email"}.` },
175
+ ],
176
+ },
177
+ },
170
178
  },
171
179
  {
172
- id: "chatgpt", name: "ChatGPT", accepts: "http",
173
- steps: ({ address, operator }) => [
174
- "Settings → Connectors → Advanced → Developer mode",
175
- `Add MCP server, paste ${address ?? "the first of the two lines we copied"}`,
176
- `Sign in when the browser opens (${operator} email)`,
177
- ],
180
+ id: "codex", name: "Codex", lead: "disk",
181
+ routes: {
182
+ // A SETTINGS BLOCK, NOT A COMMAND. This row once said "Paste it into a terminal on this machine and
183
+ // run it" over a TOML block whose home is a file — an instruction that ends in a shell error.
184
+ disk: {
185
+ steps: () => [
186
+ { text: "Copy this.", copy: "codex-toml" },
187
+ { text: "Open `~/.codex/config.toml` and paste it at the end." },
188
+ { text: `Restart Codex and ${BRIEF}`,
189
+ hint: "Codex in the terminal, your code editor and the ChatGPT desktop app all read this file." },
190
+ ],
191
+ },
192
+ // The key goes in the shell profile and the file names the variable, because Codex does not forward
193
+ // the environment and a key written into a settings file outlives the moment it was needed.
194
+ "public-http": {
195
+ steps: () => [
196
+ { text: "Copy this.", copy: "codex-toml-http" },
197
+ { text: "Open `~/.codex/config.toml` and paste it at the end." },
198
+ { text: "Copy your key and add the line to your shell profile.", copy: "codex-key-line",
199
+ hint: "Codex reads the key from there, so it never sits in the settings file." },
200
+ { text: `Restart Codex and ${BRIEF}` },
201
+ ],
202
+ },
203
+ },
178
204
  },
179
205
  {
180
- // UNDRIVEN, AND WORDED LIKE IT (the owner drives this vendor himself this
181
- // week and the dated stamp appears then). The old second step named "API Key" as the control to
182
- // choose — the same assertion-from-no-observation that made the cowork row send clients hunting
183
- // for a box that is not the way in. Two lines and a place to put each is what we actually know.
184
- id: "perplexity", name: "Perplexity", accepts: "http",
185
- steps: ({ address }) => [
186
- "Settings → Connectors → Add connector, choose a custom MCP server",
187
- `Paste ${address ?? "the first line we copied"} as the server address`,
188
- "Give it the second line we copied as the credential, wherever it asks for one",
189
- ],
190
- },
191
-
192
- // ── Anything else. We do not know what it can do, so we do not pretend to. ──────────────────────
193
- {
194
- id: "other", name: "Another agent", accepts: "either", stdioShape: "generic-json",
195
- // THE PASTE SENTENCE, ON THE ROW, because it cannot be composed from the name here.
196
- //
197
- // Every other row's name is a proper noun and `Paste it into {name}` reads: Claude, ChatGPT,
198
- // Perplexity. This row's name is a DESCRIPTION, and "Paste it into Another agent" is not English. It
199
- // passes every gate on that page — not mechanism vocabulary, no banned word, and the label is right
200
- // where it stands alone — so only the composed sentence stumbles, on a page whose whole subject is
201
- // being read by somebody who is not us. Found by driving the four decks; neither instrument could
202
- // see it, because both ask whether the right row rendered and neither asks whether the sentence reads.
203
- //
204
- // Owner's ruling 2026-09-06, option B: this row gets its own line and the approved
205
- // sentence is left untouched for the three named ones. Option A — renaming the row to "your
206
- // assistant" — was rejected because it edits a line he approved to repair a line he did not.
207
- //
208
- // HERE RATHER THAN IN THE SCREEN, and that is this table's own rule enforced by
209
- // `connect-clients-are-data.test.mjs`: no surface may branch on a client's identity, because a branch
210
- // in a screen drifts from the row silently and both keep rendering while the reader follows whichever
211
- // one is wrong. A fifth row that needs its own sentence writes it here and the page needs no edit.
212
- pasteAs: "Paste it wherever your assistant takes it.",
213
- steps: () => [
214
- "If your agent can run a local command, paste what we copied and run it — it needs nothing else",
215
- "If it can only reach a web link, use Advanced under these steps, which carries both lines it wants",
216
- ],
206
+ // ANYTHING ELSE. We do not know what the app is, so the steps name what any of them takes. The sub
207
+ // line names two it covers, because a reader scanning for their app's name should find somewhere to
208
+ // land; Perplexity had a row of its own with steps nobody had driven, and is folded in here.
209
+ id: "other", name: "Another agent", sub: "Perplexity, OpenClaw and others", lead: "disk",
210
+ aliases: { perplexity: "public-http" },
211
+ routes: {
212
+ disk: {
213
+ steps: () => [
214
+ { text: "Copy this.", copy: "generic-json" },
215
+ { text: "Paste it wherever your app adds an MCP server." },
216
+ { text: "Restart the app if it asks you to." },
217
+ ],
218
+ },
219
+ "public-http": {
220
+ steps: () => [
221
+ { text: "Copy your address and key.", copy: "address-and-key", hint: KEY_HINT },
222
+ { text: "Paste the address and the key wherever your app adds a custom MCP server.",
223
+ hint: "It may call them “server URL” and “bearer token”." },
224
+ ],
225
+ },
226
+ },
217
227
  },
218
228
  ]);
219
229
 
230
+ /** A step's text for a surface that draws no emphasis: the terminal. The literals keep their marks. */
231
+ export const plainStep = (text) => String(text ?? "").replace(/\*\*/g, "");
232
+
220
233
  /**
221
234
  * The offers as they go ON THE WIRE, composed once for every caller that puts them there.
222
235
  *
223
- * ── WHY THIS IS A FUNCTION ────────────────────────────────────────────────
224
- *
225
236
  * There were two hand-written copies of this mapping: the portal route's, and the browser check's stub
226
237
  * of the portal route. They agreed until the day the shape changed, and then the check went red about
227
- * the page rather than about itself — the stub was still dropping a field the route had started to
228
- * send, so the real page rendered nothing and the arm reported that as the page's fault.
238
+ * the page rather than about itself. A stub that restates a wire is a second author for one shape. This
239
+ * is the shape; both callers ask.
229
240
  *
230
- * A stub that restates a wire is a second author for one shape. This is the shape; both callers ask.
241
+ * A copy goes out as what the page needs to hand it over and nothing more: a block's text, or a secret's
242
+ * button label and the template the minted key is put into. The stdio route object stays server-side —
243
+ * the page has no use for where a block goes, because the step text already says.
231
244
  */
232
- // `sub`, `verifiedOn` and `by` ride only where the ROW carries them, and absent means absent rather than
233
- // null. Two of the three are load-bearing on the page:
234
- //
235
- // • `sub` is how one app can appear once per route without two rows claiming to be two products —
236
- // "Claude · app, web, and Cowork" and "Claude · app, on this computer" are one product met two ways.
237
- // • `verifiedOn`/`by` are what let the page show "✓ Checked <date>" on a row somebody actually drove
238
- // and NO stamp on one nobody did. A stamp defaulted onto an undriven row would be the file's own
239
- // defect class — asserting vendor behaviour from no observation — dressed up as evidence.
240
- //
241
- // So a row without them sends no key at all, and the page has nothing to render rather than something
242
- // empty to render badly.
243
245
  export const offersForWire = (offers) =>
244
246
  offers.map(({ client, steps, ...rest }) => ({
245
247
  id: client.id,
246
248
  name: client.name,
247
249
  ...(client.sub ? { sub: client.sub } : {}),
248
- ...(client.verifiedOn ? { verifiedOn: client.verifiedOn } : {}),
249
- ...(client.by ? { by: client.by } : {}),
250
- steps: Array.isArray(steps) ? steps : [],
250
+ steps: (Array.isArray(steps) ? steps : []).map((s) => ({
251
+ text: s.text,
252
+ ...(s.hint ? { hint: s.hint } : {}),
253
+ ...(s.copy ? { copy: s.copy.kind === "secret"
254
+ ? { kind: "secret", label: s.copy.label, template: s.copy.template, slot: s.copy.slot }
255
+ : { kind: "block", text: s.copy.text } } : {}),
256
+ })),
251
257
  ...rest,
252
258
  }));
253
259
 
254
- /** The client by id, or null. */
255
- export const clientById = (id) => CONNECT_CLIENTS.find((c) => c.id === String(id ?? "").trim()) ?? null;
260
+ const ALIAS_ROUTE = new Map(CONNECT_CLIENTS.flatMap((c) =>
261
+ Object.entries(c.aliases ?? {}).map(([alias, route]) => [alias, { client: c, route }])));
262
+
263
+ /** The client by id — or by an id it answered to before rows merged — or null. */
264
+ export const clientById = (id) => {
265
+ const key = String(id ?? "").trim();
266
+ return CONNECT_CLIENTS.find((c) => c.id === key) ?? ALIAS_ROUTE.get(key)?.client ?? null;
267
+ };
256
268
 
257
269
  /**
258
- * What THIS client needs from THIS deployment. PURE — the caller supplies what the deployment has.
259
- *
260
- * THREE SHAPES, and the middle one is the owner's ruling (2026-08-31, "On demand is fine"):
270
+ * The route a caller gets for this id when it names none: an alias's own route, else the row's `lead`.
271
+ * An alias says which route it meant — `cowork` was the web, `claude-desktop` the disk — and answering
272
+ * `--client cowork` with a settings block would be handing that reader the other half of a merged row.
273
+ */
274
+ export const leadRouteFor = (id) => {
275
+ const key = String(id ?? "").trim();
276
+ return ALIAS_ROUTE.get(key)?.route ?? CONNECT_CLIENTS.find((c) => c.id === key)?.lead ?? null;
277
+ };
278
+
279
+ /**
280
+ * What THIS client needs from THIS deployment on ONE route. PURE — the caller supplies what the
281
+ * deployment has.
261
282
  *
262
- * served: true ready now — a command, or an address and a key.
263
- * served: true, enables: {...} ready as soon as the reader says so. The connector door is not
264
- * standing and turning it on is a real change to who can reach this
265
- * install, so the row carries WHAT WOULD BE TURNED ON in words, and
266
- * the caller states it before doing it. Never silently.
267
- * served: false cannot be served here, with the reason and what would change it.
283
+ * served: true ready now — every copy the steps name resolved against this deployment.
284
+ * served: false cannot be served here, with the reason and what would change it.
268
285
  *
269
- * `served: false` always carries `reason` and `fix`. An absence with no reason reads as breakage — the
270
- * defect `` closed on the knockout's Export menu, and the
271
- * defect this page had for every self-hosted reader.
286
+ * `served: false` always carries `reason` and `fix`. An absence with no reason reads as breakage.
272
287
  *
273
288
  * @param {object} client a row of CONNECT_CLIENTS
274
- * @param {{ stdioCommand?: string|null, publicAddress?: string|null, operator?: string|null }} have
289
+ * @param {{ stdioRoutes?: object, publicAddress?: string|null, operator?: string|null }} have
290
+ * @param {"disk"|"public-http"} [route] defaults to the row's `lead`
275
291
  */
276
- export function whatItNeeds(client, have = {}) {
292
+ export function whatItNeeds(client, have = {}, route = client?.lead) {
277
293
  if (!client) return null;
278
- const {
279
- stdioRoutes = {}, publicAddress = null, operator = null,
280
- } = have;
281
- // THE ROUTE FOR THIS HOST'S OWN SHAPE, never a fallback to another host's. Handing a Codex user
282
- // `claude mcp add` is a command their machine does not have, delivered with confidence — the same
283
- // false-offer class as pointing Cowork at the door that refuses its key.
284
- const route = Object.hasOwn(stdioRoutes, client.stdioShape ?? "") ? stdioRoutes[client.stdioShape] : null;
294
+ const author = client.routes?.[route];
295
+ if (!author) return null;
296
+ const { stdioRoutes = {}, publicAddress = null, operator = null } = have;
285
297
 
286
- const withSteps = (offer) => ({
287
- ...offer,
288
- // The launch page, when this vendor has a driven one. Null everywhere today — see the note above
289
- // the table. Carried only on an offer that is actually served: opening a vendor's connector screen
290
- // for a deployment that has nothing to connect to is a worse answer than the refusal.
291
- launch: offer.served ? (client.launch ?? null) : null,
292
- steps: client.steps({ command: offer.command ?? null, address: offer.address ?? null, operator: operator ?? "your" }),
293
- });
298
+ // EACH COPY RESOLVES TO ITS OWN SHAPE, never to another's. Handing a Codex user `claude mcp add` is a
299
+ // command their machine does not have, delivered with confidence — so a shape this deployment cannot
300
+ // produce is an unserved route, not a fallback to one it can.
301
+ const resolve = route === "disk"
302
+ ? (shape) => {
303
+ const r = Object.hasOwn(stdioRoutes, shape ?? "") ? stdioRoutes[shape] : null;
304
+ return r ? { kind: "block", text: r.text, stdio: r } : null;
305
+ }
306
+ : (shape) => {
307
+ const r = remoteConnectFor(shape, { address: publicAddress });
308
+ if (!r) return null;
309
+ return r.secret ? { kind: "secret", label: r.label, template: r.text, slot: KEY_SLOT } : { kind: "block", text: r.text };
310
+ };
294
311
 
295
- // ── stdio: the route that needs nothing, and the reason this issue has a happy answer at all. ───
296
- const stdioOffer = () => route
297
- ? withSteps({ client, served: true, route: "disk", command: route.text, stdio: route, address: null, key: null, enables: null,
298
- note: "Nothing to sign up for and nothing to open up — this assistant runs the software itself, from the copy already on this machine." })
299
- : withSteps({ client, served: false, enables: null,
300
- reason: "this copy of the software is incomplete, so there is nothing to hand your assistant",
301
- fix: "whoever installed it will need to install it again" });
312
+ const asked = author.steps({ operator });
313
+ const steps = asked.map((s) => (s.copy ? { ...s, copy: resolve(s.copy) } : { ...s }));
314
+ const resolved = steps.every((s, i) => !asked[i].copy || s.copy);
315
+ const evidence = { ...(author.verifiedOn ? { verifiedOn: author.verifiedOn } : {}), ...(author.by ? { by: author.by } : {}) };
302
316
 
303
- if (client.accepts === "stdio") return stdioOffer();
304
-
305
- // ── THE WEB DOOR — for every assistant that is not spawning the software itself ─────────────────
306
- //
307
- // ── THIS USED TO BE TWO BRANCHES AND THE FIRST ONE WAS A FALSE OFFER ( §3) ────
308
- //
309
- // There was a `localOffer()` here for assistants classified `runsOn: "readers-machine"` — Cowork and
310
- // "Another agent" — which served them a LOOPBACK address with the note "nothing to publish, nothing
311
- // to open up". It is refuted at source, by the vendor:
312
- //
313
- // "Claude connects to your remote MCP server from Anthropic's cloud infrastructure, rather than
314
- // from your local device. This is true across every Claude client, including claude.ai, Claude
315
- // Desktop, Cowork, and the mobile apps." … "Your MCP server must be reachable over the public
316
- // internet from Anthropic's IP ranges."
317
- //
318
- // Cowork RUNS on the reader's machine and CONNECTS from the vendor's cloud, and the axis conflated
319
- // those. `clearotron connect --client cowork` therefore printed an address Cowork rejects — a live
320
- // wrong answer, of exactly the class this file's own comments exist to prevent, and the reason the
321
- // owner's testing kept failing. HTTPS only; HTTP is refused as well.
322
- //
323
- // So there is ONE address now, the publicly reachable one, and no surface serves loopback to anybody.
324
- // Two rows in the whole model: an assistant that can launch a local process needs a command and
325
- // nothing else, and everything else — wherever it appears to run — needs the public address and a key.
326
- const webOffer = () => publicAddress
327
- ? withSteps({ client, served: true, route: "public-http", address: publicAddress, key: "issued",
328
- command: null, enables: null,
329
- note: "This assistant connects through its maker's service, so it reaches this installation at its web address rather than from your machine." })
330
- // ── THE ONE HONEST UNAVAILABLE ( §5) ────────────────────────────────────────
331
- // "Not available" is honest in exactly one case: this deployment has no public web address. It has
332
- // nothing to do with who is reading, and it is never bare — the copy names who enables it. The
333
- // state it replaces ("this service is not set up to take assistants yet") described the client door
334
- // not running, which under settled point 2 cannot happen: the door auto-starts with the product and
335
- // the key is the gate.
336
- // NOT "address", and not "key" either. These rows render on the ARRIVING page, before any press,
337
- // where `scripts/ai-page-render-check.mjs` refuses six words on every line a reader sees. The plain
338
- // word for a reader is "the internet", and it happens to be the truer one: what is missing is not a
339
- // string somebody forgot to type, it is that nothing outside this machine can reach the service.
340
- : withSteps({ client, served: false, enables: null,
341
- reason: `${client.name} reaches this service over the internet, and this installation is not on the internet yet`,
342
- // ── NAME THE ACTOR *AND* WHAT RESOLVES IT ( — F30) ─────────────────
343
- //
344
- // "whoever installed it can put it online" is written for a client looking at somebody else's
345
- // deployment. The person reading this in a terminal IS whoever installed it, and the sentence
346
- // named no command, no file and no document — while INSTALL.md §7 covers exactly this.
347
- // `connect` cannot do it itself either: the address comes from the installer's question or from
348
- // CLEAROTRON_CLIENT_MCP_URL, and neither was named.
349
- //
350
- // KEEPING THE ACTOR IS NOT A REGRESSION, and dropping it was a near-miss caught in review. This
351
- // row renders on TWO surfaces with two audiences: a terminal, where the reader is the operator
352
- // and "whoever installed it" names them uselessly, and the portal's own page, where a CLIENT
353
- // reads it and genuinely cannot do this themselves. For that reader, WHO resolves it is part of
354
- // what resolves it. So the sentence carries both — the actor, and the thing to set — which is
355
- // what the theme asked for and what neither wording alone delivered.
356
- fix: "whoever installed it can put it online — it takes about a minute and needs no account",
357
- // THE OPERATOR'S HALF, WHICH THE ARRIVING PAGE MUST NEVER RENDER ( — F30).
358
- //
359
- // Two audiences, two incompatible constraints, and one string cannot serve both. `fix` is read
360
- // by a lawyer on the arriving page, where `scripts/ai-page-render-check.mjs` refuses six words —
361
- // MCP, connector, token, scope, address, key — so it cannot name the variable OR the thing the
362
- // variable sets. The operator reading this in a terminal needs exactly those.
363
- //
364
- // I learned that by breaking it: F30's first fix put the variable name and "public address" into
365
- // `fix`, and the browser check caught both words on the page a client sees. The comment above
366
- // this table warns about it in as many words, and I had read it. So the row carries BOTH
367
- // registers as separate fields and each surface takes the one its reader can act on — which is
368
- // this file's own rule, data rather than a branch downstream.
369
- operatorFix: "put it online and set CLEAROTRON_CLIENT_MCP_URL to the public URL of this install — INSTALL.md §7 walks the tunnel" });
370
-
371
- if (client.accepts === "either") {
372
- // Both routes, because we do not know which one this agent can walk. The stdio answer LEADS: it is
373
- // the one that needs nothing, and an agent that can take it must not be sent to mint a key. Settled
374
- // point 6 makes that the rule for the whole page and not just this row.
375
- const stdio = stdioOffer();
376
- if (stdio.served) {
377
- // `stdio` CARRIES THROUGH. Without it the caller cannot tell a command from a config block, and
378
- // renders "run this once" over four lines of JSON — an instruction that reads as a shell command
379
- // and is not one. The shape is part of the answer, not decoration on it.
380
- return withSteps({ client, served: true, route: "either", command: stdio.command, stdio: stdio.stdio,
381
- address: null, key: null, enables: null,
382
- note: "Two ways in. Most agents take the configuration above and need nothing else. "
383
- + "If yours can only reach a web link, connect it that way instead." });
317
+ if (route === "disk") {
318
+ if (!resolved) {
319
+ return { client, served: false, route, steps: [], launch: null, enables: null, command: null, address: null, key: null,
320
+ reason: "this copy of the software is incomplete, so there is nothing to hand your assistant",
321
+ fix: "whoever installed it will need to install it again" };
384
322
  }
385
- return webOffer();
323
+ const first = steps.find((s) => s.copy)?.copy;
324
+ // `command` and `stdio` ride for the terminal, which prints a disk offer's copy by its shape.
325
+ return { client, served: true, route, steps, launch: client.launch ?? null, enables: null, ...evidence,
326
+ command: first?.text ?? null, stdio: first?.stdio ?? null, address: null, key: null,
327
+ note: "Nothing to sign up for and nothing to open up — this assistant runs the software itself, from the copy already on this machine." };
386
328
  }
387
329
 
388
- // Everything else, wherever it appears to run.
389
- return webOffer();
330
+ // ── THE WEB ROUTE ─────────────────────────────────────────────────────────────────────────────────
331
+ //
332
+ // ONE ADDRESS, and it is the publicly reachable one. There used to be a loopback offer for apps that
333
+ // run on the reader's machine, and it is refuted at source, by the vendor: "Claude connects to your
334
+ // remote MCP server from Anthropic's cloud infrastructure, rather than from your local device. This is
335
+ // true across every Claude client, including claude.ai, Claude Desktop, Cowork, and the mobile apps."
336
+ // An app on this machine that wants no network takes the disk route; nothing is served loopback.
337
+ if (!resolved || !publicAddress) {
338
+ // THE ONE HONEST UNAVAILABLE. "Not available" is true in exactly one case — this deployment has no
339
+ // public web address — and it is never bare: it names who resolves it. `fix` renders where a client
340
+ // reads it, so it names the actor and no banned word; `operatorFix` is the terminal's, read by the
341
+ // person who IS that actor, and carries the variable and the document they need.
342
+ return { client, served: false, route, steps: [], launch: null, enables: null, command: null, address: null, key: null,
343
+ reason: `${client.name} reaches this service over the internet, and this installation is not on the internet yet`,
344
+ fix: "whoever installed it can put it online — it takes about a minute and needs no account",
345
+ operatorFix: "put it online and set CLEAROTRON_CLIENT_MCP_URL to the public URL of this install — INSTALL.md §7 walks the tunnel" };
346
+ }
347
+ return { client, served: true, route, steps, launch: client.launch ?? null, enables: null, ...evidence,
348
+ command: null, stdio: null, address: publicAddress, key: "issued",
349
+ note: "This assistant connects through its maker's service, so it reaches this installation at its web address rather than from your machine." };
390
350
  }
391
351
 
392
- /** Every client, resolved against one deployment. The page and the verb both render this. */
393
- export const connectOffers = (have = {}) => CONNECT_CLIENTS.map((c) => whatItNeeds(c, have));
352
+ /** Every client on every route, resolved against one deployment. The page and the verb both render this. */
353
+ export const connectOffers = (have = {}) =>
354
+ CONNECT_CLIENTS.flatMap((c) => ROUTES.map((route) => whatItNeeds(c, have, route)));