sbuilder-mcp 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/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +142 -0
- package/README.vi.md +137 -0
- package/dist/catalog/api.generated.js +11938 -0
- package/dist/catalog/element-types.js +1 -0
- package/dist/catalog/elements.generated.js +14761 -0
- package/dist/catalog/search.js +87 -0
- package/dist/catalog/types.js +1 -0
- package/dist/core/patch.js +110 -0
- package/dist/core/tree.js +69 -0
- package/dist/domains/site/builder.js +224 -0
- package/dist/domains/site/document.js +112 -0
- package/dist/domains/site/ids.js +27 -0
- package/dist/domains/site/node.js +43 -0
- package/dist/domains/site/review.js +141 -0
- package/dist/domains/site/traps.js +98 -0
- package/dist/domains/site/validate.js +49 -0
- package/dist/index.js +20 -0
- package/dist/install/index.js +108 -0
- package/dist/install/paths.js +83 -0
- package/dist/install/write.js +97 -0
- package/dist/live/session.js +164 -0
- package/dist/mcp/response.js +40 -0
- package/dist/server.js +59 -0
- package/dist/smoke.js +106 -0
- package/dist/tools/api.js +96 -0
- package/dist/tools/context.js +1 -0
- package/dist/tools/credentialpick.js +11 -0
- package/dist/tools/live.js +104 -0
- package/dist/tools/page.js +383 -0
- package/dist/tools/session.js +72 -0
- package/dist/transport/auth.js +61 -0
- package/dist/transport/credential.js +7 -0
- package/dist/transport/http.js +77 -0
- package/dist/transport/pages.js +51 -0
- package/dist/transport/socket.js +85 -0
- package/dist/vision/preview.js +30 -0
- package/dist/vision/shoot.js +103 -0
- package/package.json +66 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { childrenOf, isOverlay, pageChildren } from '../../core/tree.js';
|
|
2
|
+
import { ELEMENTS, BINDING_SOURCES } from '../../catalog/elements.generated.js';
|
|
3
|
+
/**
|
|
4
|
+
* Shipped with every non-empty finding list.
|
|
5
|
+
*
|
|
6
|
+
* The sibling `webcake-landing-mcp` learned this the hard way and says so in its
|
|
7
|
+
* own source: without a directive, models read warnings as advisory noise and
|
|
8
|
+
* save anyway. These are not suggestions — each one is something a customer
|
|
9
|
+
* loads the page and sees.
|
|
10
|
+
*/
|
|
11
|
+
export const REVIEW_NOTICE = 'FIX THESE. Each one is a defect a visitor will see on the published page, not a ' +
|
|
12
|
+
'suggestion — a blank band, a placeholder sentence, a broken image. Apply the fix each ' +
|
|
13
|
+
'finding names, then review again until the list is empty. Do not report the page as done ' +
|
|
14
|
+
'while findings stand; if you believe one is a false positive, say which and why.';
|
|
15
|
+
/** The specials keys an element seeds that hold its visible content. */
|
|
16
|
+
function contentKeys(type) {
|
|
17
|
+
const seeded = ELEMENTS[type]?.defaults.specials ?? {};
|
|
18
|
+
return Object.keys(seeded).filter((k) => k === 'text' || k === 'src' || k === 'url');
|
|
19
|
+
}
|
|
20
|
+
function seededValue(type, key) {
|
|
21
|
+
return (ELEMENTS[type]?.defaults.specials ?? {})[key];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Everything wrong with this page that a person would notice.
|
|
25
|
+
*
|
|
26
|
+
* Overlays are skipped: the cart drawer is composed onto ROOT on read and is not
|
|
27
|
+
* this page's to fix. Findings are ordered by document order so a caller working
|
|
28
|
+
* top-down meets them in the order they appear on screen.
|
|
29
|
+
*/
|
|
30
|
+
export function reviewDesign(doc) {
|
|
31
|
+
const d = doc.doc;
|
|
32
|
+
const out = [];
|
|
33
|
+
const overlayIds = new Set(childrenOf(d, d.root_node_id).filter((id) => isOverlay(d, id)));
|
|
34
|
+
if (pageChildren(d).length === 0) {
|
|
35
|
+
out.push({
|
|
36
|
+
code: 'empty_page',
|
|
37
|
+
nodeId: d.root_node_id,
|
|
38
|
+
type: 'root',
|
|
39
|
+
problem: 'The page has no content — it publishes as a blank document.',
|
|
40
|
+
fix: 'Add a section with sb_add (parent_id ROOT, type flex-section), or drop a designed one in with sb_template_use.',
|
|
41
|
+
});
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
// Document order, depth-first from ROOT: the order a reader meets them.
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
const walkOrder = [];
|
|
47
|
+
const go = (id) => {
|
|
48
|
+
if (seen.has(id) || overlayIds.has(id))
|
|
49
|
+
return;
|
|
50
|
+
seen.add(id);
|
|
51
|
+
walkOrder.push(id);
|
|
52
|
+
for (const k of childrenOf(d, id))
|
|
53
|
+
go(k);
|
|
54
|
+
};
|
|
55
|
+
go(d.root_node_id);
|
|
56
|
+
for (const id of walkOrder) {
|
|
57
|
+
if (id === d.root_node_id)
|
|
58
|
+
continue;
|
|
59
|
+
const n = d.nodes[id];
|
|
60
|
+
const type = n.data.type;
|
|
61
|
+
const meta = ELEMENTS[type];
|
|
62
|
+
if (!meta) {
|
|
63
|
+
out.push({
|
|
64
|
+
code: 'unknown_element',
|
|
65
|
+
nodeId: id,
|
|
66
|
+
type,
|
|
67
|
+
problem: `"${type}" is not an element this catalog knows, so nothing can say how it renders.`,
|
|
68
|
+
fix: 'Regenerate the catalog (npm run codegen against a current web_builder checkout). If the element was removed from the platform, delete the node with sb_remove.',
|
|
69
|
+
});
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// A container with nothing in it is a band of empty space. The commonest way
|
|
73
|
+
// to ship one is to add the section and then get distracted.
|
|
74
|
+
if (meta.isContainer && childrenOf(d, id).length === 0) {
|
|
75
|
+
out.push({
|
|
76
|
+
code: 'empty_container',
|
|
77
|
+
nodeId: id,
|
|
78
|
+
type,
|
|
79
|
+
problem: 'This container holds nothing — it renders as an empty band.',
|
|
80
|
+
fix: `Add something inside it (sb_add with parent_id "${id}"), or remove it with sb_remove.`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
for (const key of contentKeys(type)) {
|
|
84
|
+
const value = (n.specials ?? {})[key];
|
|
85
|
+
const seed = seededValue(type, key);
|
|
86
|
+
const isBlank = value === undefined || value === null || String(value).trim() === '';
|
|
87
|
+
if (isBlank) {
|
|
88
|
+
// An element that seeds a blank (image.src is "") is not misconfigured —
|
|
89
|
+
// it is unfinished, and it renders as a gap or a broken frame.
|
|
90
|
+
out.push({
|
|
91
|
+
code: key === 'text' ? 'empty_text' : 'missing_media',
|
|
92
|
+
nodeId: id,
|
|
93
|
+
type,
|
|
94
|
+
problem: key === 'text'
|
|
95
|
+
? 'This element has no text — it renders as empty space.'
|
|
96
|
+
: `This element has no ${key} — it renders as a broken or missing image.`,
|
|
97
|
+
fix: `Set it: sb_set id "${id}", namespace specials, keys { "${key}": … }.`,
|
|
98
|
+
});
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// Content still equal to the element's OWN seeded default is the author's
|
|
102
|
+
// placeholder, published. "Enter your text here" on a live page is the
|
|
103
|
+
// single most visible way this goes wrong, and it is invisible to every
|
|
104
|
+
// structural check because the document is perfectly well-formed.
|
|
105
|
+
if (seed !== undefined && String(seed).trim() !== '' && value === seed) {
|
|
106
|
+
out.push({
|
|
107
|
+
code: 'placeholder_content',
|
|
108
|
+
nodeId: id,
|
|
109
|
+
type,
|
|
110
|
+
problem: `Still the placeholder the element ships with (${JSON.stringify(seed)}).`,
|
|
111
|
+
fix: `Write the real copy: sb_set id "${id}", namespace specials, keys { "${key}": … }.`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// A binding whose source the renderer does not provide resolves to nothing,
|
|
116
|
+
// and the element falls back to its own placeholder — indistinguishable, on
|
|
117
|
+
// screen, from data that has not loaded.
|
|
118
|
+
for (const b of n
|
|
119
|
+
.bindings ?? []) {
|
|
120
|
+
if (b.source && !BINDING_SOURCES.includes(b.source)) {
|
|
121
|
+
out.push({
|
|
122
|
+
code: 'dead_binding',
|
|
123
|
+
nodeId: id,
|
|
124
|
+
type,
|
|
125
|
+
problem: `Bound to "${b.source}", which the renderer does not provide — it will show the placeholder forever.`,
|
|
126
|
+
fix: `Rebind with sb_bind using one of: ${BINDING_SOURCES.join(', ')}.`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (b.field && !b.field.startsWith('specials.')) {
|
|
130
|
+
out.push({
|
|
131
|
+
code: 'dead_binding',
|
|
132
|
+
nodeId: id,
|
|
133
|
+
type,
|
|
134
|
+
problem: `Binds into "${b.field}"; the renderer only applies bindings under "specials".`,
|
|
135
|
+
fix: `Rebind with sb_bind and a field of the form "specials.<key>".`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { pageChildren, isOverlay, SPEC_GLOBAL_ID, SPEC_GLOBAL_KIND, } from '../../core/tree.js';
|
|
2
|
+
/**
|
|
3
|
+
* Which band a direct child of ROOT belongs to.
|
|
4
|
+
*
|
|
5
|
+
* An unstamped section lives in the middle; a global's band comes from its
|
|
6
|
+
* `globalKind`. An UNRECOGNISED kind is middle rather than an error, matching
|
|
7
|
+
* the platform's own `bandOf` — a document storing a kind we do not know is
|
|
8
|
+
* still a document that must open.
|
|
9
|
+
*/
|
|
10
|
+
export function bandOf(doc, id) {
|
|
11
|
+
const n = doc.nodes[id];
|
|
12
|
+
if (!n || n.specials?.[SPEC_GLOBAL_ID] === undefined)
|
|
13
|
+
return 'middle';
|
|
14
|
+
const kind = `${n.specials[SPEC_GLOBAL_KIND] ?? ''}`;
|
|
15
|
+
if (kind === 'header' || kind === 'footer')
|
|
16
|
+
return kind;
|
|
17
|
+
return 'middle';
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* THE BAND RULE: ROOT's children must read [header*][middle*][footer*].
|
|
21
|
+
*
|
|
22
|
+
* The platform enforces this on EVERY save (`checkBands` in
|
|
23
|
+
* server/internal/page/decompose.go), so a document that breaks it cannot be
|
|
24
|
+
* stored at all — the author gets `ErrBandOrder` and loses the write. Checking
|
|
25
|
+
* it here means the builder refuses to construct the violation, rather than
|
|
26
|
+
* discovering it one autosave later with a tree nobody will ever store.
|
|
27
|
+
*
|
|
28
|
+
* Ordinary sections are constrained too, not just globals: once a page has a
|
|
29
|
+
* global header, nothing may sit above it, or the "header" stops being one.
|
|
30
|
+
*
|
|
31
|
+
* Overlays are excluded, because the platform strips them BEFORE it checks —
|
|
32
|
+
* that ordering is why the band rule needs no overlay exception, and copying
|
|
33
|
+
* the ordering is why ours needs none either.
|
|
34
|
+
*
|
|
35
|
+
* Returns null when the order is legal, or a sentence naming the offender.
|
|
36
|
+
*/
|
|
37
|
+
export function checkBandOrder(doc) {
|
|
38
|
+
let phase = 'header';
|
|
39
|
+
for (const id of pageChildren(doc)) {
|
|
40
|
+
const band = bandOf(doc, id);
|
|
41
|
+
if (band === 'header') {
|
|
42
|
+
if (phase !== 'header') {
|
|
43
|
+
return `Node ${id} is a global header but sits after ${phase} content. ROOT's children must read header, then middle, then footer — the platform refuses every save otherwise.`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else if (band === 'footer') {
|
|
47
|
+
phase = 'footer';
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
if (phase === 'footer') {
|
|
51
|
+
return `Node ${id} is ordinary content but sits after a global footer. ROOT's children must read header, then middle, then footer — the platform refuses every save otherwise.`;
|
|
52
|
+
}
|
|
53
|
+
phase = 'middle';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
/** Is this node a composed GLOBAL SECTION master — a shared header or footer? */
|
|
59
|
+
export function isGlobal(doc, id) {
|
|
60
|
+
return doc.nodes[id]?.specials?.[SPEC_GLOBAL_ID] !== undefined;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The sentence to attach to any result that touched a global.
|
|
64
|
+
*
|
|
65
|
+
* Editing a master is not a page-local act: it changes every page carrying that
|
|
66
|
+
* section, and publishing one cascades to those pages — a header edited once
|
|
67
|
+
* must not go live on one page and stay stale on the rest. An agent that does
|
|
68
|
+
* not know this reports "updated the header" having changed the whole site.
|
|
69
|
+
*/
|
|
70
|
+
export function globalWarning(doc, id) {
|
|
71
|
+
if (!isGlobal(doc, id))
|
|
72
|
+
return null;
|
|
73
|
+
const gid = doc.nodes[id].specials[SPEC_GLOBAL_ID];
|
|
74
|
+
return `Node ${id} is the shared global section ${JSON.stringify(gid)}. Editing it changes EVERY page that carries it, and publishing cascades to all of them. Say so when reporting this change.`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Keys that are identity or content rather than a visual quantity.
|
|
78
|
+
*
|
|
79
|
+
* Kept as a hint for callers deciding where a value belongs — NOT as a gate. An
|
|
80
|
+
* earlier version of this file claimed base-only values "vanish on publish" and
|
|
81
|
+
* `setKeys` refused them; both were wrong. The published cascade has a base
|
|
82
|
+
* layer (style/cascade.go MergeNamespace: current slot → wider → BASE →
|
|
83
|
+
* narrower), and every element's meta.defaults seeds into it.
|
|
84
|
+
*
|
|
85
|
+
* The platform's responsive mandate is about ELEMENT IMPLEMENTATION — a renderer
|
|
86
|
+
* reading `n.Config[...]` directly bypasses that cascade, which is what makes a
|
|
87
|
+
* per-breakpoint value unreachable at publish. Nothing a document stores can
|
|
88
|
+
* cause it.
|
|
89
|
+
*/
|
|
90
|
+
const IDENTITY_KEYS = new Set(['htmlTag', 'kind', 'name', 'id', 'type', 'href', 'src', 'alt']);
|
|
91
|
+
export function isIdentityKey(key) {
|
|
92
|
+
return IDENTITY_KEYS.has(key);
|
|
93
|
+
}
|
|
94
|
+
export const RESPONSIVE_NOTICE = 'Written per breakpoint, which is the default because a design should respond. Base is ' +
|
|
95
|
+
'legitimate too — it is the cascade\'s fallback layer, below every breakpoint slot, and ' +
|
|
96
|
+
'where an element\'s own defaults live. Use base for a value that genuinely should not ' +
|
|
97
|
+
'vary; use a breakpoint for anything a narrower screen should change.';
|
|
98
|
+
export { isOverlay };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { childrenOf, subtreeIds } from '../../core/tree.js';
|
|
2
|
+
import { checkBandOrder } from './traps.js';
|
|
3
|
+
/**
|
|
4
|
+
* Everything that would make the platform refuse this document on save.
|
|
5
|
+
*
|
|
6
|
+
* Each check mirrors one the server runs. Running them here turns a silent
|
|
7
|
+
* failure — an autosave rejected twenty minutes ago, with the agent still
|
|
8
|
+
* happily editing a tree nobody will ever store — into a refusal at the call
|
|
9
|
+
* that caused it.
|
|
10
|
+
*
|
|
11
|
+
* Returns an empty array when the document is storable.
|
|
12
|
+
*/
|
|
13
|
+
export function validateForSave(doc) {
|
|
14
|
+
const d = doc.doc;
|
|
15
|
+
const problems = [];
|
|
16
|
+
const band = checkBandOrder(d);
|
|
17
|
+
if (band)
|
|
18
|
+
problems.push(band);
|
|
19
|
+
// Dangling child ids: a parent naming a node that is not in the map. The
|
|
20
|
+
// renderer walks children by id, so this is a hole in the rendered page.
|
|
21
|
+
for (const [id, n] of Object.entries(d.nodes)) {
|
|
22
|
+
for (const kid of n.data.nodes) {
|
|
23
|
+
if (!d.nodes[kid]) {
|
|
24
|
+
problems.push(`Node ${id} lists child "${kid}", which is not in the document.`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Parent pointers that disagree with the child lists. BOTH are stored, and the
|
|
29
|
+
// renderer trusts the child list while a move trusts the pointer — so a
|
|
30
|
+
// disagreement renders one tree and edits another.
|
|
31
|
+
for (const [id, n] of Object.entries(d.nodes)) {
|
|
32
|
+
if (id === d.root_node_id)
|
|
33
|
+
continue;
|
|
34
|
+
const p = n.data.parent;
|
|
35
|
+
if (p && d.nodes[p] && !childrenOf(d, p).includes(id)) {
|
|
36
|
+
problems.push(`Node ${id} claims parent ${p}, but ${p} does not list it as a child.`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Orphans: reachable from nobody. They bloat every save and never render.
|
|
40
|
+
// Walking from ROOT covers overlays too — they are composed onto ROOT's child
|
|
41
|
+
// list, so they are reachable and correctly not reported here.
|
|
42
|
+
const reachable = new Set(subtreeIds(d, d.root_node_id));
|
|
43
|
+
for (const id of Object.keys(d.nodes)) {
|
|
44
|
+
if (!reachable.has(id)) {
|
|
45
|
+
problems.push(`Node ${id} is unreachable from ROOT — nothing references it.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return problems;
|
|
49
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { createServer } from './server.js';
|
|
4
|
+
import { runInstallCli } from './install/index.js';
|
|
5
|
+
async function main() {
|
|
6
|
+
// `sbuilder-mcp install` writes this server into the agent clients on this
|
|
7
|
+
// machine and exits. Checked BEFORE the transport opens: an installer that
|
|
8
|
+
// also spoke MCP on stdout would corrupt the protocol for whatever ran it.
|
|
9
|
+
if (process.argv[2] === 'install') {
|
|
10
|
+
process.exit(runInstallCli(process.argv.slice(3)));
|
|
11
|
+
}
|
|
12
|
+
const server = createServer();
|
|
13
|
+
await server.connect(new StdioServerTransport());
|
|
14
|
+
// stdout is the MCP channel. Every log line in this repo is console.error.
|
|
15
|
+
console.error('[sbuilder-mcp] ready on stdio');
|
|
16
|
+
}
|
|
17
|
+
main().catch((err) => {
|
|
18
|
+
console.error('[sbuilder-mcp] fatal:', err);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { targets, detected } from './paths.js';
|
|
2
|
+
import { mergeInto } from './write.js';
|
|
3
|
+
/** The name the server appears under in every client. */
|
|
4
|
+
export const SERVER_NAME = 'sbuilder';
|
|
5
|
+
export function buildEntry(opts, pkg = 'sbuilder-mcp') {
|
|
6
|
+
const env = {};
|
|
7
|
+
if (opts.api)
|
|
8
|
+
env.SB_API = opts.api;
|
|
9
|
+
if (opts.token)
|
|
10
|
+
env.SB_TOKEN = opts.token;
|
|
11
|
+
// Only when a key is absent: a key opens everything the agent does day to day,
|
|
12
|
+
// and writing an account password into six config files to buy the handful of
|
|
13
|
+
// account-level calls it adds is a bad trade the installer should not make for
|
|
14
|
+
// someone.
|
|
15
|
+
if (!opts.token && opts.email)
|
|
16
|
+
env.SB_EMAIL = opts.email;
|
|
17
|
+
if (!opts.token && opts.password)
|
|
18
|
+
env.SB_PASSWORD = opts.password;
|
|
19
|
+
return { command: 'npx', args: ['-y', pkg], env };
|
|
20
|
+
}
|
|
21
|
+
export function chooseTargets(opts) {
|
|
22
|
+
const all = targets(opts.home);
|
|
23
|
+
if (opts.clients && opts.clients.length > 0) {
|
|
24
|
+
const wanted = new Set(opts.clients.map((c) => c.trim().toLowerCase()));
|
|
25
|
+
const picked = all.filter((t) => wanted.has(t.id));
|
|
26
|
+
const unknown = [...wanted].filter((w) => !all.some((t) => t.id === w));
|
|
27
|
+
if (unknown.length) {
|
|
28
|
+
throw new Error(`sbuilder: unknown client(s) ${unknown.join(', ')}. Known: ${all.map((t) => t.id).join(', ')}.`);
|
|
29
|
+
}
|
|
30
|
+
return picked;
|
|
31
|
+
}
|
|
32
|
+
return detected(all);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Write the server into every chosen client.
|
|
36
|
+
*
|
|
37
|
+
* One client failing never stops the others: a broken Cursor config is no reason
|
|
38
|
+
* to leave Claude Code unconfigured, and the report says which is which. The
|
|
39
|
+
* token is never echoed — the result names files, not secrets.
|
|
40
|
+
*/
|
|
41
|
+
export function install(opts) {
|
|
42
|
+
const entry = buildEntry(opts);
|
|
43
|
+
return chooseTargets(opts).map((t) => {
|
|
44
|
+
if (opts.dryRun) {
|
|
45
|
+
return { client: t.label, path: t.path, status: 'skipped', reason: 'dry run', note: t.note };
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const out = mergeInto(t, SERVER_NAME, entry);
|
|
49
|
+
return {
|
|
50
|
+
client: t.label,
|
|
51
|
+
path: t.path,
|
|
52
|
+
status: out.wrote ? 'installed' : 'unchanged',
|
|
53
|
+
...(out.reason ? { reason: out.reason } : {}),
|
|
54
|
+
...(out.backup ? { backup: out.backup } : {}),
|
|
55
|
+
...(out.wrote && t.note ? { note: t.note } : {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
return { client: t.label, path: t.path, status: 'skipped', reason: String(err) };
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** `sbuilder-mcp install --token wbk_… --api https://…` */
|
|
64
|
+
export function runInstallCli(argv) {
|
|
65
|
+
const get = (flag) => {
|
|
66
|
+
const i = argv.indexOf(flag);
|
|
67
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
68
|
+
};
|
|
69
|
+
const opts = {
|
|
70
|
+
token: get('--token') ?? process.env.SB_TOKEN,
|
|
71
|
+
api: get('--api') ?? process.env.SB_API,
|
|
72
|
+
email: get('--email') ?? process.env.SB_EMAIL,
|
|
73
|
+
password: get('--password') ?? process.env.SB_PASSWORD,
|
|
74
|
+
clients: get('--client')?.split(','),
|
|
75
|
+
dryRun: argv.includes('--dry-run'),
|
|
76
|
+
};
|
|
77
|
+
if (!opts.token && !(opts.email && opts.password)) {
|
|
78
|
+
console.error('sbuilder: nothing to install with.\n' +
|
|
79
|
+
' Get a key from your store: Apps → AI agent → Create key, then\n' +
|
|
80
|
+
' npx -y sbuilder-mcp install --token wbk_… --api https://your-host\n');
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
let results;
|
|
84
|
+
try {
|
|
85
|
+
results = install(opts);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
console.error(`sbuilder: ${err.message}`);
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
if (results.length === 0) {
|
|
92
|
+
console.error('sbuilder: no agent client found on this machine.\n' +
|
|
93
|
+
` Name one explicitly: --client ${targets().map((t) => t.id).join(',')}`);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
for (const r of results) {
|
|
97
|
+
const mark = r.status === 'installed' ? '✔' : r.status === 'unchanged' ? '·' : '✖';
|
|
98
|
+
console.error(`${mark} ${r.client} — ${r.status}${r.reason ? ` (${r.reason})` : ''}`);
|
|
99
|
+
console.error(` ${r.path}`);
|
|
100
|
+
// The undo, named. A config writer that changes a file without saying where
|
|
101
|
+
// the old one went leaves the user with nothing to reach for.
|
|
102
|
+
if (r.backup)
|
|
103
|
+
console.error(` previous file saved as ${r.backup}`);
|
|
104
|
+
if (r.note)
|
|
105
|
+
console.error(` ${r.note}`);
|
|
106
|
+
}
|
|
107
|
+
return results.some((r) => r.status === 'installed' || r.status === 'unchanged') ? 0 : 1;
|
|
108
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { homedir, platform } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
const PLAT = platform();
|
|
5
|
+
/**
|
|
6
|
+
* `home` is a PARAMETER, not a constant read at import time.
|
|
7
|
+
*
|
|
8
|
+
* It exists because a test of a config writer must not be able to reach the real
|
|
9
|
+
* one, and a module-level `homedir()` makes that impossible to guarantee: a
|
|
10
|
+
* `HOME=` prefix does not reliably reach `os.homedir()`, which I proved the
|
|
11
|
+
* expensive way — a "sandboxed" run wrote an entry into this machine's actual
|
|
12
|
+
* Cursor config. Passing the directory in removes the possibility rather than
|
|
13
|
+
* relying on an env var behaving.
|
|
14
|
+
*/
|
|
15
|
+
export function targets(home = homedir()) {
|
|
16
|
+
const HOME = home;
|
|
17
|
+
const APPDATA = process.env.APPDATA || join(HOME, 'AppData', 'Roaming');
|
|
18
|
+
const claudeDesktopDir = PLAT === 'win32'
|
|
19
|
+
? join(APPDATA, 'Claude')
|
|
20
|
+
: existsSync(join(HOME, 'Library', 'Application Support', 'Claude'))
|
|
21
|
+
? join(HOME, 'Library', 'Application Support', 'Claude')
|
|
22
|
+
: join(HOME, '.config', 'Claude');
|
|
23
|
+
const vscodeUserDir = PLAT === 'win32'
|
|
24
|
+
? join(APPDATA, 'Code', 'User')
|
|
25
|
+
: existsSync(join(HOME, 'Library', 'Application Support', 'Code', 'User'))
|
|
26
|
+
? join(HOME, 'Library', 'Application Support', 'Code', 'User')
|
|
27
|
+
: join(HOME, '.config', 'Code', 'User');
|
|
28
|
+
return [
|
|
29
|
+
{
|
|
30
|
+
id: 'claude-code',
|
|
31
|
+
label: 'Claude Code',
|
|
32
|
+
format: 'json',
|
|
33
|
+
path: join(HOME, '.claude.json'),
|
|
34
|
+
key: 'mcpServers',
|
|
35
|
+
note: 'Open a new session to pick it up.',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'claude-desktop',
|
|
39
|
+
label: 'Claude Desktop',
|
|
40
|
+
format: 'json',
|
|
41
|
+
path: join(claudeDesktopDir, 'claude_desktop_config.json'),
|
|
42
|
+
key: 'mcpServers',
|
|
43
|
+
note: 'Quit and reopen Claude Desktop.',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'cursor',
|
|
47
|
+
label: 'Cursor',
|
|
48
|
+
format: 'json',
|
|
49
|
+
path: join(HOME, '.cursor', 'mcp.json'),
|
|
50
|
+
key: 'mcpServers',
|
|
51
|
+
note: 'Restart Cursor.',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'windsurf',
|
|
55
|
+
label: 'Windsurf',
|
|
56
|
+
format: 'json',
|
|
57
|
+
path: join(HOME, '.codeium', 'windsurf', 'mcp_config.json'),
|
|
58
|
+
key: 'mcpServers',
|
|
59
|
+
note: 'Restart Windsurf.',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: 'vscode',
|
|
63
|
+
label: 'VS Code',
|
|
64
|
+
format: 'json',
|
|
65
|
+
path: join(vscodeUserDir, 'mcp.json'),
|
|
66
|
+
// VS Code is the one that did not follow `mcpServers`.
|
|
67
|
+
key: 'servers',
|
|
68
|
+
note: 'Reload the window.',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'codex',
|
|
72
|
+
label: 'Codex',
|
|
73
|
+
format: 'toml',
|
|
74
|
+
path: join(HOME, '.codex', 'config.toml'),
|
|
75
|
+
key: 'mcp_servers',
|
|
76
|
+
note: 'Restart Codex.',
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
/** The clients this machine appears to have — a config file or its folder exists. */
|
|
81
|
+
export function detected(all = targets()) {
|
|
82
|
+
return all.filter((t) => existsSync(t.path) || existsSync(join(t.path, '..')));
|
|
83
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Add or replace ONE server inside a client's config, leaving everything else
|
|
5
|
+
* exactly as it was.
|
|
6
|
+
*
|
|
7
|
+
* MERGE, never write: these are the user's own files and they hold other
|
|
8
|
+
* people's servers. A whole-file write is the difference between installing a
|
|
9
|
+
* server and deleting somebody's setup — and they would only find out the next
|
|
10
|
+
* time they reached for a tool that had quietly gone.
|
|
11
|
+
*
|
|
12
|
+
* A malformed existing file is REFUSED rather than replaced. It is far more
|
|
13
|
+
* likely to be a config with a trailing comma than one worth discarding, and
|
|
14
|
+
* overwriting it destroys the very thing the user would need to fix it.
|
|
15
|
+
*/
|
|
16
|
+
export function mergeJson(target, name, entry) {
|
|
17
|
+
let doc = {};
|
|
18
|
+
if (existsSync(target.path)) {
|
|
19
|
+
const raw = readFileSync(target.path, 'utf8').trim();
|
|
20
|
+
if (raw) {
|
|
21
|
+
try {
|
|
22
|
+
doc = JSON.parse(raw);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return {
|
|
26
|
+
wrote: false,
|
|
27
|
+
reason: `${target.path} is not valid JSON. Refusing to overwrite it — fix or move it, then run this again.`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const servers = (doc[target.key] ?? {});
|
|
33
|
+
const before = JSON.stringify(servers[name] ?? null);
|
|
34
|
+
servers[name] = entry;
|
|
35
|
+
doc[target.key] = servers;
|
|
36
|
+
// Idempotent: an identical entry is not a write, so re-running the installer
|
|
37
|
+
// does not churn a file or leave a pointless backup behind.
|
|
38
|
+
if (before === JSON.stringify(entry))
|
|
39
|
+
return { wrote: false, reason: 'already configured' };
|
|
40
|
+
let backup;
|
|
41
|
+
if (existsSync(target.path)) {
|
|
42
|
+
backup = `${target.path}.sbuilder-backup`;
|
|
43
|
+
copyFileSync(target.path, backup);
|
|
44
|
+
}
|
|
45
|
+
mkdirSync(dirname(target.path), { recursive: true });
|
|
46
|
+
writeFileSync(target.path, JSON.stringify(doc, null, 2) + '\n');
|
|
47
|
+
return { wrote: true, backup };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The same job for Codex, which uses TOML.
|
|
51
|
+
*
|
|
52
|
+
* Hand-written rather than pulling in a TOML library: the block this writes is
|
|
53
|
+
* three known keys, and the file is edited by REPLACING the `[mcp_servers.<name>]`
|
|
54
|
+
* table if it is there and appending it if it is not. A parser would let us
|
|
55
|
+
* rewrite the whole document, which is exactly what the merge rule above forbids
|
|
56
|
+
* — a reformatted file is a diff the user did not ask for, across settings this
|
|
57
|
+
* tool has no business touching.
|
|
58
|
+
*/
|
|
59
|
+
export function mergeToml(target, name, entry) {
|
|
60
|
+
const header = `[${target.key}.${name}]`;
|
|
61
|
+
const env = Object.entries(entry.env)
|
|
62
|
+
.map(([k, v]) => `${k} = ${JSON.stringify(v)}`)
|
|
63
|
+
.join(', ');
|
|
64
|
+
const block = `${header}\n` +
|
|
65
|
+
`command = ${JSON.stringify(entry.command)}\n` +
|
|
66
|
+
`args = [${entry.args.map((a) => JSON.stringify(a)).join(', ')}]\n` +
|
|
67
|
+
(env ? `env = { ${env} }\n` : '');
|
|
68
|
+
let existing = '';
|
|
69
|
+
if (existsSync(target.path))
|
|
70
|
+
existing = readFileSync(target.path, 'utf8');
|
|
71
|
+
// Replace from our header up to the next table header, or to the end.
|
|
72
|
+
const start = existing.indexOf(header);
|
|
73
|
+
let next;
|
|
74
|
+
if (start === -1) {
|
|
75
|
+
next = existing.trimEnd() ? `${existing.trimEnd()}\n\n${block}` : block;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const after = existing.indexOf('\n[', start + 1);
|
|
79
|
+
const tail = after === -1 ? '' : existing.slice(after + 1);
|
|
80
|
+
next = existing.slice(0, start) + block + (tail ? `\n${tail}` : '');
|
|
81
|
+
}
|
|
82
|
+
if (next === existing)
|
|
83
|
+
return { wrote: false, reason: 'already configured' };
|
|
84
|
+
let backup;
|
|
85
|
+
if (existsSync(target.path)) {
|
|
86
|
+
backup = `${target.path}.sbuilder-backup`;
|
|
87
|
+
copyFileSync(target.path, backup);
|
|
88
|
+
}
|
|
89
|
+
mkdirSync(dirname(target.path), { recursive: true });
|
|
90
|
+
writeFileSync(target.path, next);
|
|
91
|
+
return { wrote: true, backup };
|
|
92
|
+
}
|
|
93
|
+
export function mergeInto(target, name, entry) {
|
|
94
|
+
return target.format === 'toml'
|
|
95
|
+
? mergeToml(target, name, entry)
|
|
96
|
+
: mergeJson(target, name, entry);
|
|
97
|
+
}
|