@paradigma-inc/flywheel 0.1.0 → 0.1.1
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 +4 -10
- package/package.json +12 -1
- package/src/agents.mjs +113 -61
- package/src/cli.mjs +390 -329
- package/src/mcp-writer.mjs +105 -76
- package/src/setup-auth.mjs +7 -2
- package/tests/mcp-writer.test.mjs +0 -117
package/src/mcp-writer.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
function stripJsonComments(text) {
|
|
@@ -8,32 +8,40 @@ function stripJsonComments(text) {
|
|
|
8
8
|
if (text[i] === '"') {
|
|
9
9
|
const start = i++;
|
|
10
10
|
while (i < text.length && text[i] !== '"') {
|
|
11
|
-
if (text[i] === "\\") i
|
|
12
|
-
i
|
|
11
|
+
if (text[i] === "\\") i += 1;
|
|
12
|
+
i += 1;
|
|
13
13
|
}
|
|
14
14
|
result += text.slice(start, ++i);
|
|
15
15
|
} else if (text[i] === "/" && text[i + 1] === "/") {
|
|
16
16
|
i += 2;
|
|
17
|
-
while (i < text.length && text[i] !== "\n") i
|
|
17
|
+
while (i < text.length && text[i] !== "\n") i += 1;
|
|
18
18
|
} else if (text[i] === "/" && text[i + 1] === "*") {
|
|
19
19
|
i += 2;
|
|
20
|
-
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
|
|
20
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
|
|
21
|
+
i += 1;
|
|
21
22
|
i += 2;
|
|
22
23
|
} else {
|
|
23
|
-
result += text[i
|
|
24
|
+
result += text[i];
|
|
25
|
+
i += 1;
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
return result;
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
function normalizeLineEndings(text) {
|
|
32
|
+
return text.replace(/\r\n/g, "\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
29
35
|
export async function readJsonConfig(filePath) {
|
|
36
|
+
let raw;
|
|
30
37
|
try {
|
|
31
|
-
|
|
32
|
-
if (!raw) return {};
|
|
33
|
-
return JSON.parse(stripJsonComments(raw));
|
|
38
|
+
raw = await readFile(filePath, "utf8");
|
|
34
39
|
} catch {
|
|
35
40
|
return {};
|
|
36
41
|
}
|
|
42
|
+
const trimmed = raw.trim();
|
|
43
|
+
if (!trimmed) return {};
|
|
44
|
+
return JSON.parse(stripJsonComments(trimmed));
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
export async function writeJsonConfig(filePath, config) {
|
|
@@ -44,79 +52,129 @@ export async function writeJsonConfig(filePath, config) {
|
|
|
44
52
|
});
|
|
45
53
|
}
|
|
46
54
|
|
|
47
|
-
function
|
|
48
|
-
|
|
55
|
+
export async function resolveMcpPath(candidates) {
|
|
56
|
+
for (const candidate of candidates) {
|
|
57
|
+
try {
|
|
58
|
+
// eslint-disable-next-line no-await-in-loop
|
|
59
|
+
await access(candidate);
|
|
60
|
+
return candidate;
|
|
61
|
+
} catch {
|
|
62
|
+
// keep searching
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return candidates[0];
|
|
49
66
|
}
|
|
50
67
|
|
|
51
|
-
export function
|
|
68
|
+
export function mergeServerEntry(existing, configKey, serverName, entry) {
|
|
52
69
|
const section =
|
|
53
|
-
|
|
54
|
-
|
|
70
|
+
existing &&
|
|
71
|
+
typeof existing[configKey] === "object" &&
|
|
72
|
+
existing[configKey] !== null
|
|
73
|
+
? existing[configKey]
|
|
55
74
|
: {};
|
|
56
|
-
const previous = section[serverName] ?? null;
|
|
57
|
-
section[serverName] = entry;
|
|
58
|
-
const nextConfig = {
|
|
59
|
-
...(config || {}),
|
|
60
|
-
[configKey]: section,
|
|
61
|
-
};
|
|
62
75
|
|
|
63
|
-
|
|
76
|
+
if (serverName in section) {
|
|
77
|
+
return { config: existing, alreadyExists: true };
|
|
78
|
+
}
|
|
79
|
+
|
|
64
80
|
return {
|
|
65
|
-
config:
|
|
66
|
-
|
|
67
|
-
|
|
81
|
+
config: {
|
|
82
|
+
...(existing || {}),
|
|
83
|
+
[configKey]: {
|
|
84
|
+
...section,
|
|
85
|
+
[serverName]: entry,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
alreadyExists: false,
|
|
68
89
|
};
|
|
69
90
|
}
|
|
70
91
|
|
|
71
92
|
export function removeJsonServerEntry({ config, configKey, serverName }) {
|
|
72
93
|
const section =
|
|
73
|
-
config &&
|
|
94
|
+
config &&
|
|
95
|
+
typeof config[configKey] === "object" &&
|
|
96
|
+
config[configKey] !== null
|
|
74
97
|
? { ...config[configKey] }
|
|
75
98
|
: {};
|
|
99
|
+
|
|
76
100
|
if (!(serverName in section)) {
|
|
77
|
-
return {
|
|
78
|
-
config: config || {},
|
|
79
|
-
changed: false,
|
|
80
|
-
};
|
|
101
|
+
return { config: config || {}, changed: false };
|
|
81
102
|
}
|
|
103
|
+
|
|
82
104
|
delete section[serverName];
|
|
83
|
-
const nextConfig = {
|
|
84
|
-
...(config || {}),
|
|
85
|
-
[configKey]: section,
|
|
86
|
-
};
|
|
87
105
|
return {
|
|
88
|
-
config:
|
|
106
|
+
config: {
|
|
107
|
+
...(config || {}),
|
|
108
|
+
[configKey]: section,
|
|
109
|
+
},
|
|
89
110
|
changed: true,
|
|
90
111
|
};
|
|
91
112
|
}
|
|
92
113
|
|
|
93
|
-
function
|
|
94
|
-
|
|
114
|
+
export async function readTomlServerExists(filePath, serverName) {
|
|
115
|
+
try {
|
|
116
|
+
const raw = await readFile(filePath, "utf8");
|
|
117
|
+
return raw.includes(`[mcp_servers.${serverName}]`);
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
95
121
|
}
|
|
96
122
|
|
|
97
|
-
export function
|
|
123
|
+
export function buildTomlServerBlock(serverName, entry) {
|
|
98
124
|
const lines = [`[mcp_servers.${serverName}]`];
|
|
99
|
-
|
|
100
|
-
|
|
125
|
+
const headers =
|
|
126
|
+
entry && typeof entry.headers === "object" && entry.headers !== null
|
|
127
|
+
? entry.headers
|
|
128
|
+
: null;
|
|
129
|
+
|
|
130
|
+
for (const [key, value] of Object.entries(entry || {})) {
|
|
131
|
+
if (key === "headers") continue;
|
|
132
|
+
lines.push(`${key} = ${JSON.stringify(value)}`);
|
|
133
|
+
}
|
|
101
134
|
|
|
102
|
-
|
|
103
|
-
const headerEntries = Object.entries(headers);
|
|
104
|
-
if (headerEntries.length > 0) {
|
|
135
|
+
if (headers && Object.keys(headers).length > 0) {
|
|
105
136
|
lines.push("");
|
|
106
137
|
lines.push(`[mcp_servers.${serverName}.http_headers]`);
|
|
107
|
-
for (const [
|
|
108
|
-
lines.push(`${
|
|
138
|
+
for (const [headerKey, headerValue] of Object.entries(headers)) {
|
|
139
|
+
lines.push(`${headerKey} = ${JSON.stringify(String(headerValue))}`);
|
|
109
140
|
}
|
|
110
141
|
}
|
|
111
142
|
|
|
112
143
|
return `${lines.join("\n")}\n`;
|
|
113
144
|
}
|
|
114
145
|
|
|
146
|
+
export async function appendTomlServer(filePath, serverName, entry) {
|
|
147
|
+
if (await readTomlServerExists(filePath, serverName)) {
|
|
148
|
+
return { alreadyExists: true };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const block = buildTomlServerBlock(serverName, entry);
|
|
152
|
+
|
|
153
|
+
let existing = "";
|
|
154
|
+
try {
|
|
155
|
+
existing = await readFile(filePath, "utf8");
|
|
156
|
+
} catch {
|
|
157
|
+
existing = "";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const separator =
|
|
161
|
+
existing.length > 0 && !existing.endsWith("\n")
|
|
162
|
+
? "\n\n"
|
|
163
|
+
: existing.length > 0
|
|
164
|
+
? "\n"
|
|
165
|
+
: "";
|
|
166
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
167
|
+
await writeFile(filePath, `${existing}${separator}${block}`, {
|
|
168
|
+
encoding: "utf8",
|
|
169
|
+
mode: 0o600,
|
|
170
|
+
});
|
|
171
|
+
return { alreadyExists: false };
|
|
172
|
+
}
|
|
173
|
+
|
|
115
174
|
function stripCodexTomlServerBlock({ tomlText, serverName }) {
|
|
116
175
|
const normalized = normalizeLineEndings(tomlText || "");
|
|
117
176
|
const lines = normalized.split("\n");
|
|
118
177
|
const output = [];
|
|
119
|
-
|
|
120
178
|
let skipping = false;
|
|
121
179
|
let removed = false;
|
|
122
180
|
|
|
@@ -146,37 +204,10 @@ function stripCodexTomlServerBlock({ tomlText, serverName }) {
|
|
|
146
204
|
|
|
147
205
|
let stripped = output.join("\n");
|
|
148
206
|
stripped = stripped.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
149
|
-
if (stripped.length > 0)
|
|
150
|
-
stripped += "\n";
|
|
151
|
-
}
|
|
207
|
+
if (stripped.length > 0) stripped += "\n";
|
|
152
208
|
return { stripped, removed };
|
|
153
209
|
}
|
|
154
210
|
|
|
155
|
-
export async function upsertCodexTomlServer({ filePath, serverName, entry }) {
|
|
156
|
-
let existing = "";
|
|
157
|
-
try {
|
|
158
|
-
existing = await readFile(filePath, "utf8");
|
|
159
|
-
} catch {
|
|
160
|
-
existing = "";
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const { stripped, removed } = stripCodexTomlServerBlock({
|
|
164
|
-
tomlText: existing,
|
|
165
|
-
serverName,
|
|
166
|
-
});
|
|
167
|
-
const block = buildCodexTomlServerBlock({ serverName, entry });
|
|
168
|
-
const separator = stripped.length > 0 ? "\n" : "";
|
|
169
|
-
const next = `${stripped}${separator}${block}`;
|
|
170
|
-
|
|
171
|
-
await mkdir(path.dirname(filePath), { recursive: true });
|
|
172
|
-
await writeFile(filePath, next, { encoding: "utf8", mode: 0o600 });
|
|
173
|
-
|
|
174
|
-
return {
|
|
175
|
-
changed: normalizeLineEndings(existing) !== normalizeLineEndings(next),
|
|
176
|
-
hadExisting: removed,
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
|
|
180
211
|
export async function removeCodexTomlServer({ filePath, serverName }) {
|
|
181
212
|
let existing = "";
|
|
182
213
|
try {
|
|
@@ -189,9 +220,7 @@ export async function removeCodexTomlServer({ filePath, serverName }) {
|
|
|
189
220
|
tomlText: existing,
|
|
190
221
|
serverName,
|
|
191
222
|
});
|
|
192
|
-
if (!removed) {
|
|
193
|
-
return { changed: false };
|
|
194
|
-
}
|
|
223
|
+
if (!removed) return { changed: false };
|
|
195
224
|
|
|
196
225
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
197
226
|
await writeFile(filePath, stripped, { encoding: "utf8", mode: 0o600 });
|
package/src/setup-auth.mjs
CHANGED
|
@@ -36,6 +36,7 @@ export async function acquireApiKeyViaBrowserBridge({
|
|
|
36
36
|
baseUrl,
|
|
37
37
|
keyName,
|
|
38
38
|
timeoutMs = DEFAULT_AUTH_TIMEOUT_MS,
|
|
39
|
+
openUrl = openUrlInBrowser,
|
|
39
40
|
}) {
|
|
40
41
|
const state = crypto.randomUUID();
|
|
41
42
|
|
|
@@ -51,6 +52,8 @@ export async function acquireApiKeyViaBrowserBridge({
|
|
|
51
52
|
}
|
|
52
53
|
if (server) {
|
|
53
54
|
server.close();
|
|
55
|
+
server.closeAllConnections?.();
|
|
56
|
+
server.closeIdleConnections?.();
|
|
54
57
|
server = null;
|
|
55
58
|
}
|
|
56
59
|
};
|
|
@@ -143,8 +146,10 @@ export async function acquireApiKeyViaBrowserBridge({
|
|
|
143
146
|
console.log(`If it does not open, use this URL:\n${setupUrl.toString()}\n`);
|
|
144
147
|
|
|
145
148
|
try {
|
|
146
|
-
const child =
|
|
147
|
-
child.unref
|
|
149
|
+
const child = openUrl(setupUrl.toString());
|
|
150
|
+
if (child && typeof child.unref === "function") {
|
|
151
|
+
child.unref();
|
|
152
|
+
}
|
|
148
153
|
} catch {
|
|
149
154
|
// User can still open URL manually.
|
|
150
155
|
}
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
import { mkdtemp, readFile } from "node:fs/promises";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import assert from "node:assert/strict";
|
|
5
|
-
import test from "node:test";
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
removeCodexTomlServer,
|
|
9
|
-
removeJsonServerEntry,
|
|
10
|
-
upsertCodexTomlServer,
|
|
11
|
-
upsertJsonServerEntry,
|
|
12
|
-
writeJsonConfig,
|
|
13
|
-
readJsonConfig,
|
|
14
|
-
} from "../src/mcp-writer.mjs";
|
|
15
|
-
|
|
16
|
-
test("upsertJsonServerEntry installs and removes server entries", async () => {
|
|
17
|
-
const initial = {
|
|
18
|
-
mcpServers: {
|
|
19
|
-
other: {
|
|
20
|
-
url: "http://localhost/other",
|
|
21
|
-
},
|
|
22
|
-
},
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
const inserted = upsertJsonServerEntry({
|
|
26
|
-
config: initial,
|
|
27
|
-
configKey: "mcpServers",
|
|
28
|
-
serverName: "flywheel",
|
|
29
|
-
entry: {
|
|
30
|
-
type: "http",
|
|
31
|
-
url: "https://flywheel.paradigma.inc/mcp-server",
|
|
32
|
-
},
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
assert.equal(inserted.changed, true);
|
|
36
|
-
assert.equal(inserted.hadExisting, false);
|
|
37
|
-
assert.equal(inserted.config.mcpServers.flywheel.type, "http");
|
|
38
|
-
|
|
39
|
-
const removed = removeJsonServerEntry({
|
|
40
|
-
config: inserted.config,
|
|
41
|
-
configKey: "mcpServers",
|
|
42
|
-
serverName: "flywheel",
|
|
43
|
-
});
|
|
44
|
-
assert.equal(removed.changed, true);
|
|
45
|
-
assert.equal("flywheel" in removed.config.mcpServers, false);
|
|
46
|
-
assert.equal("other" in removed.config.mcpServers, true);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("codex toml writer replaces flywheel block idempotently", async () => {
|
|
50
|
-
const tempDir = await mkdtemp(path.join(os.tmpdir(), "flywheel-mcp-writer-"));
|
|
51
|
-
const filePath = path.join(tempDir, "config.toml");
|
|
52
|
-
|
|
53
|
-
const first = await upsertCodexTomlServer({
|
|
54
|
-
filePath,
|
|
55
|
-
serverName: "flywheel",
|
|
56
|
-
entry: {
|
|
57
|
-
type: "http",
|
|
58
|
-
url: "https://flywheel.paradigma.inc/mcp-server",
|
|
59
|
-
headers: {
|
|
60
|
-
Authorization: "Bearer one",
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
|
-
});
|
|
64
|
-
assert.equal(first.changed, true);
|
|
65
|
-
|
|
66
|
-
const second = await upsertCodexTomlServer({
|
|
67
|
-
filePath,
|
|
68
|
-
serverName: "flywheel",
|
|
69
|
-
entry: {
|
|
70
|
-
type: "http",
|
|
71
|
-
url: "https://flywheel.paradigma.inc/mcp-server",
|
|
72
|
-
headers: {
|
|
73
|
-
Authorization: "Bearer two",
|
|
74
|
-
},
|
|
75
|
-
},
|
|
76
|
-
});
|
|
77
|
-
assert.equal(second.changed, true);
|
|
78
|
-
assert.equal(second.hadExisting, true);
|
|
79
|
-
|
|
80
|
-
const text = await readFile(filePath, "utf8");
|
|
81
|
-
assert.equal(
|
|
82
|
-
text.includes("Authorization = \"Bearer two\""),
|
|
83
|
-
true,
|
|
84
|
-
"updated token should be present",
|
|
85
|
-
);
|
|
86
|
-
assert.equal(
|
|
87
|
-
text.includes("Authorization = \"Bearer one\""),
|
|
88
|
-
false,
|
|
89
|
-
"old token should not survive repeated setup",
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
const removed = await removeCodexTomlServer({
|
|
93
|
-
filePath,
|
|
94
|
-
serverName: "flywheel",
|
|
95
|
-
});
|
|
96
|
-
assert.equal(removed.changed, true);
|
|
97
|
-
const afterRemove = await readFile(filePath, "utf8");
|
|
98
|
-
assert.equal(afterRemove.includes("[mcp_servers.flywheel]"), false);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test("readJsonConfig/writeJsonConfig round-trip", async () => {
|
|
102
|
-
const tempDir = await mkdtemp(path.join(os.tmpdir(), "flywheel-mcp-json-"));
|
|
103
|
-
const filePath = path.join(tempDir, "mcp.json");
|
|
104
|
-
|
|
105
|
-
const config = {
|
|
106
|
-
mcpServers: {
|
|
107
|
-
flywheel: {
|
|
108
|
-
type: "http",
|
|
109
|
-
url: "https://flywheel.paradigma.inc/mcp-server",
|
|
110
|
-
},
|
|
111
|
-
},
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
await writeJsonConfig(filePath, config);
|
|
115
|
-
const loaded = await readJsonConfig(filePath);
|
|
116
|
-
assert.deepEqual(loaded, config);
|
|
117
|
-
});
|