flowsprite 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -0
- package/dist/bin.js +128 -0
- package/dist/cli.js +974 -0
- package/dist/credentials.js +195 -0
- package/dist/doctor.js +39 -0
- package/dist/endpoint.js +72 -0
- package/dist/login.js +154 -0
- package/dist/mcpClient.js +109 -0
- package/dist/mcpConfig.js +266 -0
- package/dist/mcpInstall.js +225 -0
- package/dist/messages.js +47 -0
- package/dist/output.js +21 -0
- package/dist/redact.js +47 -0
- package/dist/setup.js +216 -0
- package/dist/status.js +92 -0
- package/dist/usage.js +54 -0
- package/dist/version.js +10 -0
- package/package.json +21 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { lstatSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { writeFileAtomic } from "./credentials.js";
|
|
4
|
+
/**
|
|
5
|
+
* MUTATE ONE ENTRY IN SOMEONE ELSE'S FILE, OR REFUSE.
|
|
6
|
+
*
|
|
7
|
+
* The files this writes are not Flowsprite's. `~/.claude.json` on this machine is
|
|
8
|
+
* 348 KB of live state — projects, history, onboarding flags — and a client's MCP
|
|
9
|
+
* config is the only copy the person has. So the contract is preservation first:
|
|
10
|
+
* parse, set exactly `mcpServers.flowsprite`, write the whole object back, and
|
|
11
|
+
* REFUSE anything that cannot be parsed back out (an unparsable file, a non-object
|
|
12
|
+
* root, a symlink) rather than replacing it with a file this CLI understands.
|
|
13
|
+
* A refusal leaves a person with a config they can fix; an overwrite does not.
|
|
14
|
+
*
|
|
15
|
+
* The write goes through `writeFileAtomic` from `credentials.ts` — the same
|
|
16
|
+
* unique-name + `O_EXCL` + rename + chmod sequence that file argues for at length.
|
|
17
|
+
* It is imported rather than restated so the two writers cannot drift.
|
|
18
|
+
*/
|
|
19
|
+
/** The server name this CLI owns in every client's config. */
|
|
20
|
+
export const SERVER_NAME = "flowsprite";
|
|
21
|
+
const FILE_MODE = 0o600;
|
|
22
|
+
/** The suffix a backup's name carries, between the config name and the epoch. */
|
|
23
|
+
export const BACKUP_MARKER = ".flowsprite-backup-";
|
|
24
|
+
export class McpConfigError extends Error {
|
|
25
|
+
code;
|
|
26
|
+
path;
|
|
27
|
+
constructor(code, path, message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "McpConfigError";
|
|
30
|
+
this.code = code;
|
|
31
|
+
this.path = path;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function isPlainObject(value) {
|
|
35
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Read a config for mutation, or throw the typed refusal.
|
|
39
|
+
*
|
|
40
|
+
* Returns `null` for a file that is simply not there — the one tolerated absence,
|
|
41
|
+
* because creating a client's first config is the normal case.
|
|
42
|
+
*/
|
|
43
|
+
function readForMutation(path) {
|
|
44
|
+
let stat;
|
|
45
|
+
try {
|
|
46
|
+
// `lstatSync`, never `statSync`: the question is what is AT this path, and a
|
|
47
|
+
// `statSync` on a symlink answers about its target.
|
|
48
|
+
stat = lstatSync(path);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
if (stat.isSymbolicLink()) {
|
|
54
|
+
// THE LEAF ONLY. A symlinked DIRECTORY on the way here is FOLLOWED, by design:
|
|
55
|
+
// a dotfiles setup that symlinks `~/.cursor` or the whole Application Support
|
|
56
|
+
// folder is an ordinary arrangement, and refusing it would make this CLI
|
|
57
|
+
// unusable for the people most likely to have four AI clients configured. What
|
|
58
|
+
// is refused is a symlinked CONFIG FILE, where writing through the link would
|
|
59
|
+
// put a bearer token into a file the person did not name.
|
|
60
|
+
throw new McpConfigError("symlink", path, `${path} is a symbolic link; refusing to write through it.`);
|
|
61
|
+
}
|
|
62
|
+
let raw;
|
|
63
|
+
try {
|
|
64
|
+
raw = readFileSync(path, "utf8");
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
// A config that `lstat` can see but this process cannot READ. Without this the
|
|
68
|
+
// EACCES escapes as a raw node error to `bin.ts`'s last-resort handler, which
|
|
69
|
+
// prints one redacted line with no remedy in it. The person needs to be told
|
|
70
|
+
// WHICH file and that the problem is permission, not content.
|
|
71
|
+
throw new McpConfigError("unreadable", path, `${path} could not be read (${err.code ?? "read failed"}); refusing to replace a file this CLI cannot preserve.`);
|
|
72
|
+
}
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(raw);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new McpConfigError("unparsable", path, `${path} is not valid JSON; refusing to overwrite it.`);
|
|
79
|
+
}
|
|
80
|
+
if (!isPlainObject(parsed)) {
|
|
81
|
+
throw new McpConfigError("not_object", path, `${path} is not a JSON object; refusing to overwrite it.`);
|
|
82
|
+
}
|
|
83
|
+
// A `mcpServers` that is not an object is the same problem one level down: the
|
|
84
|
+
// only way to add an entry would be to discard whatever is there.
|
|
85
|
+
if (parsed.mcpServers !== undefined && !isPlainObject(parsed.mcpServers)) {
|
|
86
|
+
throw new McpConfigError("not_object", path, `${path} has an mcpServers value that is not an object; refusing to overwrite it.`);
|
|
87
|
+
}
|
|
88
|
+
return { raw, root: parsed };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Every `<name>.flowsprite-backup-*` already sitting beside `path` that is a
|
|
92
|
+
* REGULAR FILE.
|
|
93
|
+
*
|
|
94
|
+
* `.isFile()` is the load-bearing half. A directory or a symlink wearing a backup
|
|
95
|
+
* name is not a backup — nothing can be restored from it — and counting it would
|
|
96
|
+
* make this function answer "already backed up" for a path where no backup exists,
|
|
97
|
+
* which is how a config gets mutated with nothing saved. It also keeps this
|
|
98
|
+
* agreeing with the exclusive create below, which refuses the same things: the two
|
|
99
|
+
* must have one answer, or the fast path waves through exactly what the slow path
|
|
100
|
+
* would have caught.
|
|
101
|
+
*/
|
|
102
|
+
function existingBackups(path) {
|
|
103
|
+
const base = basename(path);
|
|
104
|
+
const dir = dirname(path);
|
|
105
|
+
try {
|
|
106
|
+
return readdirSync(dir).filter((n) => {
|
|
107
|
+
if (!n.startsWith(`${base}${BACKUP_MARKER}`))
|
|
108
|
+
return false;
|
|
109
|
+
try {
|
|
110
|
+
return lstatSync(join(dir, n)).isFile();
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The mode the config comes out with: 0600, unless what is there is already
|
|
123
|
+
* STRICTER, in which case it is kept.
|
|
124
|
+
*
|
|
125
|
+
* The direction matters. A config that holds a bearer token must not stay 0644
|
|
126
|
+
* because it happened to be 0644 before; but a person who has locked their config
|
|
127
|
+
* down to 0400 has made a decision this CLI has no business widening. "Stricter"
|
|
128
|
+
* is "no bits outside owner read/write".
|
|
129
|
+
*
|
|
130
|
+
* (An earlier version also excluded a mode of 0, reasoning that preserving it
|
|
131
|
+
* would leave a file nothing can read back. That branch was UNREACHABLE: a 0-mode
|
|
132
|
+
* config fails `readForMutation`'s read first and is refused as `unreadable`
|
|
133
|
+
* before this function is ever called. It is deleted rather than kept as a
|
|
134
|
+
* comforting no-op.)
|
|
135
|
+
*/
|
|
136
|
+
function modeFor(path) {
|
|
137
|
+
let current;
|
|
138
|
+
try {
|
|
139
|
+
current = statSync(path).mode & 0o777;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return FILE_MODE;
|
|
143
|
+
}
|
|
144
|
+
return (current & ~FILE_MODE) === 0 ? current : FILE_MODE;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Set `mcpServers.flowsprite` in a JSON client config, preserving everything else.
|
|
148
|
+
*
|
|
149
|
+
* ONE BACKUP PER PATH, FOREVER (brief DM2 / ASSUME-2). The first write beside a
|
|
150
|
+
* file that has no `.flowsprite-backup-*` takes one; every later write leaves it
|
|
151
|
+
* alone. The point of the backup is the state BEFORE this CLI ever touched the
|
|
152
|
+
* file, and a rolling backup overwrites exactly that with a copy of our own work.
|
|
153
|
+
* There is no restore command in v1: the file is named in the output and a person
|
|
154
|
+
* (or an agent) moves it back.
|
|
155
|
+
*
|
|
156
|
+
* THE BACKUP IS WRITTEN WITH A DIRECT `wx`, NOT THROUGH `writeFileAtomic`, and
|
|
157
|
+
* that is the whole difference between a backup and a lie. `writeFileAtomic` puts
|
|
158
|
+
* `O_EXCL` on its TEMP file and then RENAMES over the destination — rename
|
|
159
|
+
* replaces whatever is there, so two writes aiming at one backup name (two
|
|
160
|
+
* installs inside the same second, for two different clients' configs that share
|
|
161
|
+
* a directory, or two `flowsprite` processes) would leave a "backup" that is a
|
|
162
|
+
* copy of the ALREADY-MUTATED file. A direct `O_EXCL` create cannot: the second
|
|
163
|
+
* one gets EEXIST, which is read here as "a backup already exists", the same
|
|
164
|
+
* answer the readdir check gives. The check is the fast path; this is the one
|
|
165
|
+
* that is actually a guarantee.
|
|
166
|
+
*/
|
|
167
|
+
export function upsertJsonConfig(input) {
|
|
168
|
+
const { path, entry } = input;
|
|
169
|
+
const existing = readForMutation(path);
|
|
170
|
+
const root = existing?.root ?? {};
|
|
171
|
+
const servers = isPlainObject(root.mcpServers) ? root.mcpServers : {};
|
|
172
|
+
const replaced = Object.prototype.hasOwnProperty.call(servers, SERVER_NAME);
|
|
173
|
+
let backup;
|
|
174
|
+
if (existing !== null && existingBackups(path).length === 0) {
|
|
175
|
+
const stamp = Math.floor((input.now ?? Date.now()) / 1000);
|
|
176
|
+
const candidate = join(dirname(path), `${basename(path)}${BACKUP_MARKER}${stamp}`);
|
|
177
|
+
try {
|
|
178
|
+
// 0600 whatever the original was: the copy may hold a bearer token, and a
|
|
179
|
+
// backup nobody can read is a smaller problem than one everybody can.
|
|
180
|
+
writeFileSync(candidate, existing.raw, { flag: "wx", mode: FILE_MODE });
|
|
181
|
+
backup = candidate;
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
// EEXIST IS NOT BY ITSELF "ALREADY BACKED UP". `O_EXCL` fails with EEXIST for
|
|
185
|
+
// anything at that path — a regular file, a directory, and a DANGLING SYMLINK
|
|
186
|
+
// too. Read as a bare success, a dangling symlink wearing the backup name
|
|
187
|
+
// would mean: nothing saved, no error, and the config mutated anyway. So the
|
|
188
|
+
// success case is narrowed to what can actually be restored from, and
|
|
189
|
+
// everything else is a refusal taken BEFORE the write, leaving the config
|
|
190
|
+
// exactly as it was.
|
|
191
|
+
//
|
|
192
|
+
// Anything that is not EEXIST (a read-only directory, ENOSPC) stops the write
|
|
193
|
+
// for the same reason: losing the only copy of someone's config is the
|
|
194
|
+
// failure this whole file exists to avoid.
|
|
195
|
+
if (err.code !== "EEXIST")
|
|
196
|
+
throw err;
|
|
197
|
+
let isFile = false;
|
|
198
|
+
try {
|
|
199
|
+
isFile = lstatSync(candidate).isFile();
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
isFile = false;
|
|
203
|
+
}
|
|
204
|
+
if (!isFile) {
|
|
205
|
+
throw new McpConfigError("backup_blocked", path, `${candidate} is in the way of this config's backup and is not a regular file; ` +
|
|
206
|
+
"refusing to change the config without one.");
|
|
207
|
+
}
|
|
208
|
+
// A real backup is already there: the pre-Flowsprite state is saved, so this
|
|
209
|
+
// is a success and `backup` stays undefined because THIS call did not take one.
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
servers[SERVER_NAME] = entry;
|
|
213
|
+
root.mcpServers = servers;
|
|
214
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
215
|
+
writeFileAtomic(path, `${JSON.stringify(root, null, 2)}\n`, modeFor(path), input.unique);
|
|
216
|
+
return { wrote: true, replaced, ...(backup === undefined ? {} : { backup }) };
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Is `name` already registered in this JSON config?
|
|
220
|
+
*
|
|
221
|
+
* FALSE FOR EVERYTHING IT CANNOT READ, deliberately: this answers "must I ask
|
|
222
|
+
* before replacing?", and a file that is missing, unreadable or malformed has no
|
|
223
|
+
* entry to protect. It is not a substitute for the refusals above — the write
|
|
224
|
+
* still goes through `upsertJsonConfig`, which throws on exactly those files.
|
|
225
|
+
*/
|
|
226
|
+
export function jsonConfigHasServer(path, name = SERVER_NAME) {
|
|
227
|
+
try {
|
|
228
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
229
|
+
if (!isPlainObject(parsed) || !isPlainObject(parsed.mcpServers))
|
|
230
|
+
return false;
|
|
231
|
+
return Object.prototype.hasOwnProperty.call(parsed.mcpServers, name);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Is `name` already a `[mcp_servers.<name>]` section in a codex `config.toml`?
|
|
239
|
+
*
|
|
240
|
+
* A TEXT SCAN, NOT A TOML PARSE — this repo has no TOML library and card FLO-501
|
|
241
|
+
* adds no dependency (NC-1). It matches the section-header form `codex mcp add`
|
|
242
|
+
* itself writes, MEASURED 2026-09-15 on a throwaway `CODEX_HOME`:
|
|
243
|
+
*
|
|
244
|
+
* [mcp_servers.probe]
|
|
245
|
+
* url = "https://example.invalid/mcp"
|
|
246
|
+
* bearer_token_env_var = "X"
|
|
247
|
+
*
|
|
248
|
+
* CEILING, stated because the next reader will assume more: an entry written by
|
|
249
|
+
* hand as a quoted key (`["mcp_servers"."flowsprite"]`) or as an inline table
|
|
250
|
+
* under a single `mcp_servers = { … }` is NOT seen by this, so the answer is
|
|
251
|
+
* "no conflict" and `codex mcp add` then overwrites that entry — which is what it
|
|
252
|
+
* does anyway (measured: a second `codex mcp add` with the same name replaces the
|
|
253
|
+
* section and exits 0). ponytail: a real TOML reader is the upgrade path, and it
|
|
254
|
+
* costs a dependency this package does not have.
|
|
255
|
+
*/
|
|
256
|
+
export function tomlConfigHasServer(path, name = SERVER_NAME) {
|
|
257
|
+
let text;
|
|
258
|
+
try {
|
|
259
|
+
text = readFileSync(path, "utf8");
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
265
|
+
return new RegExp(`^[ \\t]*\\[mcp_servers\\.${escaped}\\][ \\t]*$`, "m").test(text);
|
|
266
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { SERVER_NAME } from "./mcpConfig.js";
|
|
3
|
+
/** Every client this CLI can install, in the order they are offered. */
|
|
4
|
+
export const CLIENTS = ["claude-code", "codex", "claude-desktop", "cursor"];
|
|
5
|
+
export function isClientId(value) {
|
|
6
|
+
return CLIENTS.includes(value);
|
|
7
|
+
}
|
|
8
|
+
/** The env var codex is told to read the bearer from, unless `--token-env` names another. */
|
|
9
|
+
export const TOKEN_ENV_DEFAULT = "FLOWSPRITE_MCP_TOKEN";
|
|
10
|
+
/** What `mcp print` puts where the bearer goes, unless `--reveal` is given on a TTY. */
|
|
11
|
+
export const TOKEN_PLACEHOLDER = "<YOUR_TOKEN>";
|
|
12
|
+
/**
|
|
13
|
+
* The `mcp-remote` version Claude Desktop is pointed at.
|
|
14
|
+
*
|
|
15
|
+
* MEASURED, not guessed: `npm view mcp-remote version` -> 0.14.2 on 2026-09-15.
|
|
16
|
+
* It is PINNED because the entry this CLI writes is `npx -y mcp-remote@<version>`
|
|
17
|
+
* and an unpinned `npx -y mcp-remote` re-resolves to whatever is newest every time
|
|
18
|
+
* Claude Desktop starts the server — a supply-chain surface on a process that
|
|
19
|
+
* holds a live Flowsprite bearer. Bump it deliberately, in a card, with the
|
|
20
|
+
* measurement re-run.
|
|
21
|
+
*/
|
|
22
|
+
export const MCP_REMOTE_VERSION = "0.14.2";
|
|
23
|
+
function nonEmpty(value) {
|
|
24
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* THE ONE DERIVATION OF EVERY CLIENT'S CONFIG PATH.
|
|
28
|
+
*
|
|
29
|
+
* Both the fallback writer and the "is flowsprite already registered?" check read
|
|
30
|
+
* it from here, so they cannot aim at different files — which is the shape of the
|
|
31
|
+
* bug where a conflict check passes over a file the write then clobbers.
|
|
32
|
+
*
|
|
33
|
+
* Paths, and where each came from:
|
|
34
|
+
* - claude-code: `$CLAUDE_CONFIG_DIR/.claude.json`, else `~/.claude.json`. MEASURED
|
|
35
|
+
* 2026-09-15: `claude mcp add --transport http … --scope user` wrote exactly that
|
|
36
|
+
* file, with a top-level `mcpServers` entry `{type:"http", url, headers}`.
|
|
37
|
+
* - codex: `$CODEX_HOME/config.toml`, else `~/.codex/config.toml`. MEASURED: a
|
|
38
|
+
* throwaway `CODEX_HOME` got a `config.toml` holding `[mcp_servers.<name>]`.
|
|
39
|
+
* - claude-desktop: the platform's application-support path. The darwin one is
|
|
40
|
+
* MEASURED on this machine (its live flowsprite entry is the `npx mcp-remote`
|
|
41
|
+
* shape this file writes); win32 and linux are from Claude Desktop's own
|
|
42
|
+
* documented locations and are UNVERIFIED here — no install of either to read.
|
|
43
|
+
* - cursor: `~/.cursor/mcp.json` (ASSUME-1; no Cursor install on this machine,
|
|
44
|
+
* which is why that client is only WRITTEN when the file is already there).
|
|
45
|
+
*/
|
|
46
|
+
export function configPathFor(client, ctx) {
|
|
47
|
+
const { home, platform, env } = ctx;
|
|
48
|
+
switch (client) {
|
|
49
|
+
case "claude-code":
|
|
50
|
+
return { path: join(nonEmpty(env.CLAUDE_CONFIG_DIR) ?? home, ".claude.json"), format: "json" };
|
|
51
|
+
case "codex":
|
|
52
|
+
return { path: join(nonEmpty(env.CODEX_HOME) ?? join(home, ".codex"), "config.toml"), format: "toml" };
|
|
53
|
+
case "claude-desktop": {
|
|
54
|
+
if (platform === "darwin") {
|
|
55
|
+
return {
|
|
56
|
+
path: join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"),
|
|
57
|
+
format: "json",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (platform === "win32") {
|
|
61
|
+
const appData = nonEmpty(env.APPDATA) ?? join(home, "AppData", "Roaming");
|
|
62
|
+
return { path: join(appData, "Claude", "claude_desktop_config.json"), format: "json" };
|
|
63
|
+
}
|
|
64
|
+
return { path: join(home, ".config", "Claude", "claude_desktop_config.json"), format: "json" };
|
|
65
|
+
}
|
|
66
|
+
default:
|
|
67
|
+
return { path: join(home, ".cursor", "mcp.json"), format: "json" };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** The command each client registers through, or null where it has none. */
|
|
71
|
+
export function commandFor(client) {
|
|
72
|
+
if (client === "claude-code")
|
|
73
|
+
return "claude";
|
|
74
|
+
if (client === "codex")
|
|
75
|
+
return "codex";
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
function bearer(token) {
|
|
79
|
+
return `Bearer ${token}`;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The plan for one client. Pure — every fact comes from `ctx`.
|
|
83
|
+
*
|
|
84
|
+
* The `--token-env` rules are NOT here: `cli.ts` resolves that flag before
|
|
85
|
+
* building the context, because for claude-code it means "read the CURRENT value
|
|
86
|
+
* of that variable and use it as the bearer" (a resolution that can fail) while
|
|
87
|
+
* for codex it means "pass the NAME through", and for the two write clients it is
|
|
88
|
+
* refused outright — their configs hold literal values, so a variable name in one
|
|
89
|
+
* is a config that silently does not authenticate.
|
|
90
|
+
*/
|
|
91
|
+
export function planInstall(client, ctx) {
|
|
92
|
+
const url = ctx.mcpEndpoint;
|
|
93
|
+
const { path } = configPathFor(client, ctx);
|
|
94
|
+
switch (client) {
|
|
95
|
+
case "claude-code": {
|
|
96
|
+
if (ctx.onPath("claude")) {
|
|
97
|
+
return {
|
|
98
|
+
kind: "exec",
|
|
99
|
+
argv: [
|
|
100
|
+
"claude",
|
|
101
|
+
"mcp",
|
|
102
|
+
"add",
|
|
103
|
+
"--transport",
|
|
104
|
+
"http",
|
|
105
|
+
SERVER_NAME,
|
|
106
|
+
url,
|
|
107
|
+
"--header",
|
|
108
|
+
`Authorization: ${bearer(ctx.token)}`,
|
|
109
|
+
"--scope",
|
|
110
|
+
"user",
|
|
111
|
+
],
|
|
112
|
+
describe: "Claude Code, through its own `claude` command",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
kind: "write",
|
|
117
|
+
path,
|
|
118
|
+
entry: { type: "http", url, headers: { Authorization: bearer(ctx.token) } },
|
|
119
|
+
describe: "Claude Code (the `claude` command is not on PATH, so its config was written directly)",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
case "codex": {
|
|
123
|
+
const envName = nonEmpty(ctx.tokenEnv) ?? TOKEN_ENV_DEFAULT;
|
|
124
|
+
if (ctx.onPath("codex")) {
|
|
125
|
+
return {
|
|
126
|
+
kind: "exec",
|
|
127
|
+
argv: ["codex", "mcp", "add", SERVER_NAME, "--url", url, "--bearer-token-env-var", envName],
|
|
128
|
+
describe: "Codex, through its own `codex` command",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// NO TOML WRITER (NC-1): this package has zero dependencies and a
|
|
132
|
+
// hand-rolled TOML mutation of someone's `config.toml` is exactly the
|
|
133
|
+
// "overwrite what you cannot preserve" this card refuses elsewhere.
|
|
134
|
+
return {
|
|
135
|
+
kind: "print",
|
|
136
|
+
snippet: renderSnippet("codex", { url, placeholder: TOKEN_PLACEHOLDER, tokenEnv: envName }),
|
|
137
|
+
reason: `the \`codex\` command is not on PATH, and this CLI does not edit ${path} itself`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
case "claude-desktop": {
|
|
141
|
+
// STDIO ONLY: Claude Desktop takes a command and arguments, not a URL with
|
|
142
|
+
// headers (measured on this machine — every server in its live config is
|
|
143
|
+
// `command`/`args`), so the bridge is `mcp-remote`.
|
|
144
|
+
return {
|
|
145
|
+
kind: "write",
|
|
146
|
+
path,
|
|
147
|
+
entry: {
|
|
148
|
+
command: "npx",
|
|
149
|
+
args: ["-y", `mcp-remote@${MCP_REMOTE_VERSION}`, url, "--header", `Authorization: ${bearer(ctx.token)}`],
|
|
150
|
+
},
|
|
151
|
+
describe: "Claude Desktop (restart it to pick the server up)",
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
default: {
|
|
155
|
+
if (ctx.fileExists(path)) {
|
|
156
|
+
return {
|
|
157
|
+
kind: "write",
|
|
158
|
+
path,
|
|
159
|
+
entry: { url, headers: { Authorization: bearer(ctx.token) } },
|
|
160
|
+
describe: "Cursor",
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// ASSUME-1 is unverified here, so a file that does not exist is not
|
|
164
|
+
// created: a wrong guess at the shape would be a config Cursor ignores,
|
|
165
|
+
// and a person who has never configured Cursor has nothing to compare it
|
|
166
|
+
// against. An existing file proves the location at least.
|
|
167
|
+
return {
|
|
168
|
+
kind: "print",
|
|
169
|
+
snippet: renderSnippet("cursor", { url, placeholder: TOKEN_PLACEHOLDER }),
|
|
170
|
+
reason: `${path} does not exist yet, so there is no Cursor config to add an entry to`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* The hand-edit snippet for a client: what a person pastes when the automated
|
|
177
|
+
* route is not available, and what `mcp print` shows.
|
|
178
|
+
*
|
|
179
|
+
* It is the SAME shape the writer produces, rendered from the same values, so the
|
|
180
|
+
* printed instructions and the automated write cannot describe different configs.
|
|
181
|
+
*/
|
|
182
|
+
export function renderSnippet(client, input) {
|
|
183
|
+
const { url, placeholder } = input;
|
|
184
|
+
const auth = `Bearer ${placeholder}`;
|
|
185
|
+
if (client === "codex") {
|
|
186
|
+
const envName = nonEmpty(input.tokenEnv) ?? TOKEN_ENV_DEFAULT;
|
|
187
|
+
return [
|
|
188
|
+
`# ~/.codex/config.toml (or $CODEX_HOME/config.toml)`,
|
|
189
|
+
`[mcp_servers.${SERVER_NAME}]`,
|
|
190
|
+
`url = "${url}"`,
|
|
191
|
+
`bearer_token_env_var = "${envName}"`,
|
|
192
|
+
``,
|
|
193
|
+
`# and in the shell that starts codex:`,
|
|
194
|
+
`export ${envName}=${placeholder}`,
|
|
195
|
+
].join("\n");
|
|
196
|
+
}
|
|
197
|
+
if (client === "claude-desktop") {
|
|
198
|
+
return [
|
|
199
|
+
`// claude_desktop_config.json`,
|
|
200
|
+
JSON.stringify({
|
|
201
|
+
mcpServers: {
|
|
202
|
+
[SERVER_NAME]: {
|
|
203
|
+
command: "npx",
|
|
204
|
+
args: ["-y", `mcp-remote@${MCP_REMOTE_VERSION}`, url, "--header", `Authorization: ${auth}`],
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
}, null, 2),
|
|
208
|
+
].join("\n");
|
|
209
|
+
}
|
|
210
|
+
if (client === "cursor") {
|
|
211
|
+
return [
|
|
212
|
+
`// ~/.cursor/mcp.json`,
|
|
213
|
+
JSON.stringify({ mcpServers: { [SERVER_NAME]: { url, headers: { Authorization: auth } } } }, null, 2),
|
|
214
|
+
].join("\n");
|
|
215
|
+
}
|
|
216
|
+
// claude-code: the one-liner first, because it is what most people will use,
|
|
217
|
+
// and the file shape under it for anyone who would rather edit than run.
|
|
218
|
+
return [
|
|
219
|
+
`claude mcp add --transport http ${SERVER_NAME} ${url} \\`,
|
|
220
|
+
` --header "Authorization: ${auth}" --scope user`,
|
|
221
|
+
``,
|
|
222
|
+
`// or, in ~/.claude.json ($CLAUDE_CONFIG_DIR/.claude.json when that is set)`,
|
|
223
|
+
JSON.stringify({ mcpServers: { [SERVER_NAME]: { type: "http", url, headers: { Authorization: auth } } } }, null, 2),
|
|
224
|
+
].join("\n");
|
|
225
|
+
}
|
package/dist/messages.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export const LOGIN_REMEDIES = {
|
|
2
|
+
expired_token: "This login code has expired. Codes last 15 minutes — run `flowsprite login` again to start a new one.",
|
|
3
|
+
access_denied: "The login was denied in the browser. If that was not you, run `flowsprite login` again and approve only a code you started yourself.",
|
|
4
|
+
no_grant: "Your Flowsprite account has no MCP access in this workspace yet. Ask a workspace owner or admin to grant you MCP access in Settings, then run `flowsprite login` again.",
|
|
5
|
+
not_member: "You are not an active member of that workspace any more. Ask an owner or admin to re-invite you, then run `flowsprite login` again.",
|
|
6
|
+
suspended: "That workspace is suspended, so no new access tokens can be issued. An owner needs to resolve the suspension in billing before a terminal can log in.",
|
|
7
|
+
revoked: "Your MCP access in that workspace has been revoked. Ask a workspace owner or admin to grant it again, then run `flowsprite login` again.",
|
|
8
|
+
ambiguous_grant: "You hold more than one live MCP grant in that workspace, so Flowsprite cannot tell which one this terminal should use. Ask an owner or admin to revoke the extra grants, then run `flowsprite login` again.",
|
|
9
|
+
rate_limited: "Too many logins have been started from this network in the last few minutes. Wait ten minutes and run `flowsprite login` again.",
|
|
10
|
+
invalid_request: "Flowsprite rejected the login request as malformed. This is a bug in this CLI version — upgrade it, and report it if a newer version does the same.",
|
|
11
|
+
invalid_argument: "Flowsprite rejected one of the values this CLI sent when starting the login. This is a bug in this CLI version — upgrade it, and report it if a newer version does the same.",
|
|
12
|
+
server_misconfigured: "That Flowsprite deployment is not configured for command-line login. Whoever operates it needs to set its MCP service secret.",
|
|
13
|
+
server_error: "Flowsprite hit an unexpected error while logging this terminal in. Try again in a minute; if it keeps happening, that is an operator problem, not something you can fix here.",
|
|
14
|
+
transport: "Could not reach that Flowsprite deployment. Check the endpoint and your network, then run `flowsprite login` again.",
|
|
15
|
+
};
|
|
16
|
+
/** The wire values that mean "keep polling", kept apart from the terminal ones above. */
|
|
17
|
+
export const POLL_CONTINUE_KINDS = new Set(["authorization_pending", "slow_down"]);
|
|
18
|
+
/**
|
|
19
|
+
* The kinds that can arrive FROM THE WIRE — a strict subset of the remedy table.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ `transport` IS NOT IN IT, and that is the point of this set existing. It is a
|
|
22
|
+
* real `LoginFailureKind` with a real remedy ("could not reach that deployment"),
|
|
23
|
+
* but it is a conclusion this CLI draws when `fetch` THROWS — never something a
|
|
24
|
+
* server says. Keying the wire lookup on the remedy table instead meant a
|
|
25
|
+
* response body of `{"error":"transport"}` was read as "unreachable", which is a
|
|
26
|
+
* lie about a deployment that plainly answered, and it points the person at their
|
|
27
|
+
* network instead of at the workspace. Anything not in this set folds to
|
|
28
|
+
* `server_error` and is never echoed: an unrecognised error string is still a
|
|
29
|
+
* server string.
|
|
30
|
+
*/
|
|
31
|
+
const WIRE_KINDS = new Set([
|
|
32
|
+
"expired_token",
|
|
33
|
+
"access_denied",
|
|
34
|
+
"no_grant",
|
|
35
|
+
"not_member",
|
|
36
|
+
"suspended",
|
|
37
|
+
"revoked",
|
|
38
|
+
"ambiguous_grant",
|
|
39
|
+
"rate_limited",
|
|
40
|
+
"invalid_request",
|
|
41
|
+
"invalid_argument",
|
|
42
|
+
"server_misconfigured",
|
|
43
|
+
"server_error",
|
|
44
|
+
]);
|
|
45
|
+
export function isLoginFailureKind(value) {
|
|
46
|
+
return typeof value === "string" && WIRE_KINDS.has(value);
|
|
47
|
+
}
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { redact } from "./redact.js";
|
|
2
|
+
/**
|
|
3
|
+
* `sinks` is injected so a test can record every line without a process.
|
|
4
|
+
* The bin wires `console.log` / `console.error`.
|
|
5
|
+
*/
|
|
6
|
+
export function createWriter(sinks) {
|
|
7
|
+
return {
|
|
8
|
+
out: (line) => sinks.out(redact(line)),
|
|
9
|
+
err: (line) => sinks.err(redact(line)),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** A writer that records, for tests and for the doctor's dry runs. */
|
|
13
|
+
export function createRecordingWriter() {
|
|
14
|
+
const lines = [];
|
|
15
|
+
const errors = [];
|
|
16
|
+
const w = createWriter({
|
|
17
|
+
out: (line) => lines.push(line),
|
|
18
|
+
err: (line) => errors.push(line),
|
|
19
|
+
});
|
|
20
|
+
return { ...w, lines, errors };
|
|
21
|
+
}
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rewrite every Flowsprite bearer token in a string to a stub.
|
|
3
|
+
*
|
|
4
|
+
* NO IMPORTABLE REDACTOR EXISTS IN THIS REPO (measured at f301c49: `fp_user_`
|
|
5
|
+
* appears in lib/ and scripts/ only as comments and a type guard), so this is
|
|
6
|
+
* the CLI's own, re-stated rather than shared.
|
|
7
|
+
*
|
|
8
|
+
* THE TOKEN GRAMMAR IS TAKEN FROM THE MINTER, not guessed: `generateRawToken`
|
|
9
|
+
* in `convex/lib/mcpCrypto.ts` emits `fp_live_` or `fp_user_` followed by 32
|
|
10
|
+
* characters drawn from `[A-Za-z0-9-_]`. The suffix alphabet CONTAINS `_`, so
|
|
11
|
+
* the match is greedy over that class — stopping at the first underscore would
|
|
12
|
+
* leave most of the secret on the screen.
|
|
13
|
+
*
|
|
14
|
+
* THE SET THIS COVERS IS EXACTLY `fp_user_` AND `fp_live_`, and nothing else —
|
|
15
|
+
* stated so the next reader does not assume it is a general secret scrubber.
|
|
16
|
+
* Those are the only two prefixes `generateRawToken` can emit and the only
|
|
17
|
+
* credentials this CLI ever holds. The OTHER value worth never printing, the
|
|
18
|
+
* device code, is not covered here and does not need to be: it is 64 hex
|
|
19
|
+
* characters with no marker to match on, and it is kept off the screen BY
|
|
20
|
+
* CONSTRUCTION instead — `cli/src/login.ts` prints `user_code` and
|
|
21
|
+
* `verification_uri_complete` and never passes `device_code` to the writer, which
|
|
22
|
+
* the AC3 acceptance oracle pins (`printedTheDeviceCode: false`). A setup-link
|
|
23
|
+
* token is the same shape and the same story, except that one is DELIBERATELY
|
|
24
|
+
* shown: it is the thing the person has to open.
|
|
25
|
+
*
|
|
26
|
+
* Applied by the shared writer (`cli/src/output.ts`), so the redaction is a
|
|
27
|
+
* property of the ONE place lines leave this process, not of each caller
|
|
28
|
+
* remembering. A caller that bypassed the writer would bypass this.
|
|
29
|
+
*/
|
|
30
|
+
const TOKEN = /fp_(?:user|live)_[A-Za-z0-9_-]+/g;
|
|
31
|
+
/** How many trailing characters survive, so a person can tell two tokens apart. */
|
|
32
|
+
const KEEP_LAST = 4;
|
|
33
|
+
export function redact(text) {
|
|
34
|
+
return text.replace(TOKEN, (match) => {
|
|
35
|
+
// `fp_user_` / `fp_live_` — a fixed 8 characters, so this is a slice, not a
|
|
36
|
+
// search: the prefix is the part the grammar above already pinned.
|
|
37
|
+
const prefix = match.slice(0, "fp_user_".length);
|
|
38
|
+
const suffix = match.slice("fp_user_".length);
|
|
39
|
+
// A suffix at or under the keep length would be echoed whole. It cannot be a
|
|
40
|
+
// real token (the minter always emits 32), and truncating to nothing would
|
|
41
|
+
// be indistinguishable from an empty value — so the stub says "..." and
|
|
42
|
+
// keeps nothing.
|
|
43
|
+
if (suffix.length <= KEEP_LAST)
|
|
44
|
+
return `${prefix}...`;
|
|
45
|
+
return `${prefix}...${suffix.slice(-KEEP_LAST)}`;
|
|
46
|
+
});
|
|
47
|
+
}
|