@kammcs/buildit-mcp 0.2.0 → 0.7.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 +116 -0
- package/README.md +252 -32
- package/SECURITY.md +1 -1
- package/dist/api/client.js +5 -1
- package/dist/api/generated/operations.js +661 -4
- package/dist/api/generated/schemas.js +680 -2
- package/dist/api/generated/strict.js +799 -2
- package/dist/channel/api.js +128 -0
- package/dist/channel/approvals.js +259 -0
- package/dist/channel/item-events.js +138 -0
- package/dist/channel/lock.js +181 -0
- package/dist/channel/loop.js +776 -0
- package/dist/channel/mask.js +365 -0
- package/dist/channel/threads.js +60 -0
- package/dist/channel/tool-ring.js +124 -0
- package/dist/channel/typing.js +150 -0
- package/dist/channel/waiters.js +229 -0
- package/dist/channel/workers.js +66 -0
- package/dist/cli/claude-config.js +100 -0
- package/dist/cli/commands.js +410 -0
- package/dist/config.js +329 -6
- package/dist/errors.js +38 -0
- package/dist/index.js +122 -6
- package/dist/log.js +3 -2
- package/dist/secrets/keychain.js +273 -0
- package/dist/server.js +53 -5
- package/dist/tools/approve.js +791 -0
- package/dist/tools/ask.js +898 -0
- package/dist/tools/chat.js +98 -1
- package/dist/tools/items-read.js +5 -3
- package/dist/tools/items-write.js +66 -15
- package/dist/tools/plans.js +221 -14
- package/dist/tools/reply.js +138 -0
- package/dist/tools/shared.js +10 -1
- package/dist/tools/threads.js +123 -0
- package/dist/tools/whoami.js +17 -3
- package/dist/toolsets/catalog.js +3 -1
- package/dist/toolsets/registry.js +2 -0
- package/dist/transports/stdio.js +260 -6
- package/package.json +5 -1
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session commands: `connect`, `disconnect` and `status`.
|
|
3
|
+
*
|
|
4
|
+
* They run in a terminal (not under an MCP client), so they write plain
|
|
5
|
+
* text for a person to stdout, and problems to stderr. Nothing they print
|
|
6
|
+
* contains the token. Text that comes from the server (the link, the code,
|
|
7
|
+
* names) is stripped of control and direction characters first, so it can't
|
|
8
|
+
* move the cursor or disguise itself in the terminal.
|
|
9
|
+
*/
|
|
10
|
+
import { hostname } from 'node:os';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { ApiError, ApiClient } from '../api/client.js';
|
|
13
|
+
import { connectPoll, connectStart, getSession, POLL_CODES, revokeSession, SESSION_ENDED_CODES, } from '../channel/api.js';
|
|
14
|
+
import { FORBIDDEN_CHARS } from '../config.js';
|
|
15
|
+
import { isTokenRefused, isTransientFailure } from '../identity.js';
|
|
16
|
+
import { channelServers, duplicateChannelWarning, pluginConfigured } from './claude-config.js';
|
|
17
|
+
import { lockHolders } from '../channel/lock.js';
|
|
18
|
+
import { forgetSession, forgetStaleApi, savedApiUrl, sessionApiKey, sessionKey, } from '../secrets/keychain.js';
|
|
19
|
+
/** The name a session's client shows in buildIt.Social ("Claude Code on <machine>"). */
|
|
20
|
+
export const CONNECT_CLIENT = 'Claude Code';
|
|
21
|
+
/** The answers that confirm: only an explicit yes. */
|
|
22
|
+
const YES = /^y(es)?$/i;
|
|
23
|
+
const defaultSleep = (ms) => new Promise((resolve) => {
|
|
24
|
+
setTimeout(resolve, ms);
|
|
25
|
+
});
|
|
26
|
+
/** Server text made safe for a terminal: no control, escape or direction characters. */
|
|
27
|
+
export function plain(text, max = 200) {
|
|
28
|
+
const flat = text
|
|
29
|
+
.replace(new RegExp(`${FORBIDDEN_CHARS}+`, 'gu'), ' ')
|
|
30
|
+
.replace(/\s+/g, ' ')
|
|
31
|
+
.trim();
|
|
32
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
33
|
+
}
|
|
34
|
+
/** The machine's name as buildIt.Social shows it (1 to 64 characters). */
|
|
35
|
+
export function machineName(raw = hostname()) {
|
|
36
|
+
const name = Array.from(plain(raw, 64)).slice(0, 64).join('').trim();
|
|
37
|
+
return name.length > 0 ? name : 'computer';
|
|
38
|
+
}
|
|
39
|
+
/** A link from the server, if it is an http(s) URL; otherwise undefined (it is not printed). */
|
|
40
|
+
function safeLink(raw) {
|
|
41
|
+
if (raw === undefined)
|
|
42
|
+
return undefined;
|
|
43
|
+
try {
|
|
44
|
+
const url = new URL(raw);
|
|
45
|
+
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : undefined;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function apiFor(options, io, token) {
|
|
52
|
+
return new ApiClient({
|
|
53
|
+
baseUrl: options.apiUrl,
|
|
54
|
+
token,
|
|
55
|
+
logger: io.logger,
|
|
56
|
+
...(io.fetch ? { fetch: io.fetch } : {}),
|
|
57
|
+
...(io.sleep ? { sleep: io.sleep } : {}),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function describeError(err) {
|
|
61
|
+
if (err instanceof ApiError) {
|
|
62
|
+
const hint = err.hint ? ` ${plain(err.hint, 300)}` : '';
|
|
63
|
+
return `${plain(err.message, 300)} (${err.code}).${hint}`;
|
|
64
|
+
}
|
|
65
|
+
return err instanceof Error ? plain(err.message, 300) : 'Unknown error.';
|
|
66
|
+
}
|
|
67
|
+
function sessionLines(info) {
|
|
68
|
+
const s = info.session;
|
|
69
|
+
const lines = [
|
|
70
|
+
`Agent: ${plain(info.agent.name, 80)} · ${plain(s.label, 40)}`,
|
|
71
|
+
`Org: ${plain(info.org.name, 80)}`,
|
|
72
|
+
`Owner: ${plain(info.owner.name, 80)}`,
|
|
73
|
+
];
|
|
74
|
+
if (s.scopes.length > 0) {
|
|
75
|
+
lines.push(`Scopes: ${s.scopes.map((x) => plain(x, 40)).join(', ')}`);
|
|
76
|
+
}
|
|
77
|
+
lines.push(`Projects: ${s.project_ids.length}`, `Expires: ${plain(s.expires_at, 40)} (30 days after it was last used)`);
|
|
78
|
+
return lines;
|
|
79
|
+
}
|
|
80
|
+
/** The commands to start Claude Code with this folder's session, after connecting. */
|
|
81
|
+
export function nextSteps(platform = process.platform, entry = process.argv[1]) {
|
|
82
|
+
const local = localCheckout(entry);
|
|
83
|
+
const quote = (path) => (/\s/.test(path) ? `"${path}"` : path);
|
|
84
|
+
return [
|
|
85
|
+
'Next, in this folder, start Claude Code with buildIt.Social, in one of two ways:',
|
|
86
|
+
"- The buildit plugin (recommended): your messages, and Claude's questions and approvals as cards in buildIt.Social.",
|
|
87
|
+
...(local === undefined
|
|
88
|
+
? [
|
|
89
|
+
' claude plugin marketplace add kammcs/buildit-mcp',
|
|
90
|
+
' claude plugin install buildit@buildit-mcp',
|
|
91
|
+
' claude --dangerously-load-development-channels plugin:buildit@buildit-mcp',
|
|
92
|
+
' From a checkout of buildit-mcp (after npm ci && npm run build in it), run npm run dev-marketplace there and the three commands it prints.',
|
|
93
|
+
]
|
|
94
|
+
: [
|
|
95
|
+
' From this checkout: npm run dev-marketplace in it writes a local marketplace whose plugin runs this build, then:',
|
|
96
|
+
` claude plugin marketplace add ${quote(join(local, '.dev-marketplace'))}`,
|
|
97
|
+
' claude plugin install buildit@buildit-dev',
|
|
98
|
+
' claude --dangerously-load-development-channels plugin:buildit@buildit-dev',
|
|
99
|
+
' (--plugin-dir alone does not register the channel.)',
|
|
100
|
+
]),
|
|
101
|
+
'- Or a plain channel server (messages and reply only):',
|
|
102
|
+
` ${serverCommand(platform, entry)}`,
|
|
103
|
+
' claude --dangerously-load-development-channels server:buildit',
|
|
104
|
+
'Use one of them, not both. Messages you send to the agent in buildIt.Social then arrive in that Claude Code session.',
|
|
105
|
+
];
|
|
106
|
+
}
|
|
107
|
+
/** The checkout a local build runs from (its dist/index.js), or undefined when run through npx. */
|
|
108
|
+
function localCheckout(entry) {
|
|
109
|
+
if (entry === undefined || /[\\/](_npx|node_modules)[\\/]/.test(entry))
|
|
110
|
+
return undefined;
|
|
111
|
+
return dirname(dirname(entry));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The `claude mcp add` line for this folder, run the way this command was:
|
|
115
|
+
* through npx, or a local build's dist/index.js (before a release is on
|
|
116
|
+
* npm). The server finds the API connect saved, so the line needs no
|
|
117
|
+
* --api-url.
|
|
118
|
+
*/
|
|
119
|
+
export function serverCommand(platform = process.platform, entry = process.argv[1]) {
|
|
120
|
+
const viaNpx = entry === undefined || /[\\/](_npx|node_modules)[\\/]/.test(entry);
|
|
121
|
+
if (!viaNpx)
|
|
122
|
+
return `claude mcp add buildit -- node "${entry}" --channel`;
|
|
123
|
+
return platform === 'win32'
|
|
124
|
+
? 'claude mcp add buildit -- cmd /c npx -y "@kammcs/buildit-mcp@latest" --channel'
|
|
125
|
+
: 'claude mcp add buildit -- npx -y @kammcs/buildit-mcp@latest --channel';
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Which buildit servers are set up for the folder (the plugin, and those
|
|
129
|
+
* added with claude mcp add or in .mcp.json, by name), and which running
|
|
130
|
+
* one receives its messages (from the channel lock files).
|
|
131
|
+
*/
|
|
132
|
+
function channelLines(folder, apiUrl, io) {
|
|
133
|
+
const servers = channelServers(folder, io.home);
|
|
134
|
+
const configured = [
|
|
135
|
+
...(pluginConfigured(folder, io.home) ? ['the buildit plugin'] : []),
|
|
136
|
+
...servers.map((s) => `"${s.name}" (in ${s.place})`),
|
|
137
|
+
];
|
|
138
|
+
const lines = configured.length === 0 ? [] : [`Servers: ${configured.join('; ')}`];
|
|
139
|
+
let holders;
|
|
140
|
+
try {
|
|
141
|
+
holders = lockHolders(folder, apiUrl, io.lockDir === undefined ? {} : { dir: io.lockDir });
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return lines;
|
|
145
|
+
}
|
|
146
|
+
if (holders.plugin !== undefined) {
|
|
147
|
+
lines.push(`Channel: the buildit plugin's server (pid ${holders.plugin.pid}) receives this folder's messages.${holders.server === undefined ? '' : ` A server without the plugin (pid ${holders.server.pid}) is running too: it gives way at its next poll.`}`);
|
|
148
|
+
}
|
|
149
|
+
else if (holders.server !== undefined) {
|
|
150
|
+
lines.push(`Channel: a buildit server without the plugin (pid ${holders.server.pid}) receives this folder's messages. Claude Code shows them only if it registered that server as a channel (--dangerously-load-development-channels server:<name>).`);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
lines.push("Channel: no buildit server is receiving this folder's messages now.");
|
|
154
|
+
}
|
|
155
|
+
return lines;
|
|
156
|
+
}
|
|
157
|
+
/** The API a command works with: --api-url, else the one connect saved for the folder. */
|
|
158
|
+
async function apiUrlOf(options, store) {
|
|
159
|
+
if (options.apiUrlFlag === true)
|
|
160
|
+
return options.apiUrl;
|
|
161
|
+
return (await savedApiUrl(store, options.folder)) ?? options.apiUrl;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* buildit-mcp connect: starts the device-code flow, shows the code and the
|
|
165
|
+
* link, polls at the server's interval until the person approves (or denies,
|
|
166
|
+
* or the code expires), and saves the session token for the folder.
|
|
167
|
+
*/
|
|
168
|
+
export async function runConnect(options, io) {
|
|
169
|
+
const sleep = io.sleep ?? defaultSleep;
|
|
170
|
+
const now = io.now ?? Date.now;
|
|
171
|
+
const key = sessionKey(options.folder, options.apiUrl);
|
|
172
|
+
const existing = await io.store.get(key);
|
|
173
|
+
if (existing !== undefined) {
|
|
174
|
+
try {
|
|
175
|
+
const info = await getSession(apiFor(options, io, existing));
|
|
176
|
+
io.out([
|
|
177
|
+
`This folder is already connected:`,
|
|
178
|
+
...sessionLines(info).map((l) => ` ${l}`),
|
|
179
|
+
'Run "buildit-mcp disconnect" first to connect it again.',
|
|
180
|
+
].join('\n') + '\n');
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
if (!isTokenRefused(err)) {
|
|
185
|
+
io.err(`Could not check the saved session: ${describeError(err)}\n`);
|
|
186
|
+
return 1;
|
|
187
|
+
}
|
|
188
|
+
io.out(`The saved session is no longer accepted (${err.code}); connecting again.\n`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const api = apiFor(options, io);
|
|
192
|
+
const body = {
|
|
193
|
+
client: CONNECT_CLIENT,
|
|
194
|
+
machine: io.machine ?? machineName(),
|
|
195
|
+
label: options.label,
|
|
196
|
+
projects: options.projects,
|
|
197
|
+
};
|
|
198
|
+
let start;
|
|
199
|
+
try {
|
|
200
|
+
start = await connectStart(api, body);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
// A short rate limit is waited out once (the client already retried one of 10 s or less).
|
|
204
|
+
const wait = rateLimitWait(err);
|
|
205
|
+
if (wait === undefined || wait > MAX_START_WAIT_S) {
|
|
206
|
+
io.err(`Could not start connecting: ${startRefusal(err, wait)}\n`);
|
|
207
|
+
return 1;
|
|
208
|
+
}
|
|
209
|
+
io.out(`buildIt.Social asks to wait ${Math.ceil(wait)} s before connecting; waiting...\n`);
|
|
210
|
+
await sleep(Math.ceil(wait) * 1000);
|
|
211
|
+
try {
|
|
212
|
+
start = await connectStart(api, body);
|
|
213
|
+
}
|
|
214
|
+
catch (again) {
|
|
215
|
+
io.err(`Could not start connecting: ${startRefusal(again, rateLimitWait(again))}\n`);
|
|
216
|
+
return 1;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const link = safeLink(start.verification_uri_complete) ?? safeLink(start.verification_uri);
|
|
220
|
+
const page = safeLink(start.verification_uri);
|
|
221
|
+
const code = plain(start.user_code, 20);
|
|
222
|
+
const minutes = Math.max(1, Math.round(start.expires_in / 60));
|
|
223
|
+
io.out([
|
|
224
|
+
`To connect "${options.label}" (${options.projects.join(', ')}) to buildIt.Social, open:`,
|
|
225
|
+
` ${link ?? '(the server sent no usable link)'}`,
|
|
226
|
+
`and check that buildIt.Social shows this code: ${code}`,
|
|
227
|
+
...(page !== undefined && page !== link ? [`(Or open ${page} and type the code.)`] : []),
|
|
228
|
+
`Approve it only if you started this. The code works for ${minutes} minutes.`,
|
|
229
|
+
'Waiting for approval...',
|
|
230
|
+
].join('\n') + '\n');
|
|
231
|
+
let intervalMs = Math.max(1, start.interval) * 1000;
|
|
232
|
+
const deadline = now() + start.expires_in * 1000;
|
|
233
|
+
for (;;) {
|
|
234
|
+
await sleep(intervalMs);
|
|
235
|
+
if (now() > deadline) {
|
|
236
|
+
io.err('The code expired before it was approved. Run "buildit-mcp connect" again.\n');
|
|
237
|
+
return 1;
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
const done = await connectPoll(api, start.device_code);
|
|
241
|
+
// The projects as approved, which the person may have changed from the ones asked for.
|
|
242
|
+
const keys = done.session.projects.map((p) => plain(p.key, 10));
|
|
243
|
+
const plural = keys.length === 1 ? 'project' : 'projects';
|
|
244
|
+
io.out(`Connected as ${plain(done.agent.name, 80)} (${plain(done.owner.name, 80)}) in ${plain(done.org.name, 80)}, ${plural} ${keys.join(', ')}; session "${plain(done.session.label, 40)}".\n`);
|
|
245
|
+
// Whoever approves a code gets the session: check it was the person here.
|
|
246
|
+
const answer = options.yes ? 'y' : ((await io.ask?.('Is this you? [y/N] ')) ?? '');
|
|
247
|
+
if (!YES.test(answer.trim())) {
|
|
248
|
+
const cancelled = await cancel(apiFor(options, io, done.token));
|
|
249
|
+
io.err([
|
|
250
|
+
cancelled === undefined
|
|
251
|
+
? 'Not saved, and the session was cancelled: the connection was not confirmed.'
|
|
252
|
+
: `Not saved: the connection was not confirmed. The session could not be cancelled (${cancelled}): revoke the session "${plain(done.session.label, 40)}" in buildIt.Social (Settings → Agents), or have its owner do it.`,
|
|
253
|
+
'If that was not you, someone else may have approved your code.',
|
|
254
|
+
'Run "buildit-mcp connect" again for a new code, and approve it yourself.',
|
|
255
|
+
].join('\n') + '\n');
|
|
256
|
+
return 1;
|
|
257
|
+
}
|
|
258
|
+
await io.store.set(key, done.token);
|
|
259
|
+
await io.store.set(sessionApiKey(options.folder), options.apiUrl);
|
|
260
|
+
io.out([
|
|
261
|
+
io.store.kind === 'keyring'
|
|
262
|
+
? `The session token is saved in ${io.store.location}, for this folder.`
|
|
263
|
+
: `The session token is saved in ${io.store.location} (readable only by your user account), for this folder.`,
|
|
264
|
+
'',
|
|
265
|
+
...nextSteps(process.platform, io.entry ?? process.argv[1]),
|
|
266
|
+
].join('\n') + '\n');
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
if (!(err instanceof ApiError))
|
|
271
|
+
throw err;
|
|
272
|
+
const interval = intervalOf(err.details);
|
|
273
|
+
switch (err.code) {
|
|
274
|
+
case POLL_CODES.pending:
|
|
275
|
+
if (interval !== undefined)
|
|
276
|
+
intervalMs = interval * 1000;
|
|
277
|
+
continue;
|
|
278
|
+
case POLL_CODES.slowDown:
|
|
279
|
+
intervalMs = interval !== undefined ? interval * 1000 : intervalMs + 5000;
|
|
280
|
+
continue;
|
|
281
|
+
case POLL_CODES.denied:
|
|
282
|
+
io.err('The connection was denied in buildIt.Social, or it can no longer be used: the agent was turned off or deleted, or its owner is no longer a member of the org. Nothing was saved.\n');
|
|
283
|
+
return 1;
|
|
284
|
+
case POLL_CODES.expired:
|
|
285
|
+
io.err('The code expired or was already used. Run "buildit-mcp connect" again.\n');
|
|
286
|
+
return 1;
|
|
287
|
+
default:
|
|
288
|
+
if (isTransientFailure(err)) {
|
|
289
|
+
const wait = (err.retryAfterSeconds ?? 0) * 1000;
|
|
290
|
+
if (wait > intervalMs)
|
|
291
|
+
intervalMs = Math.min(wait, 60_000);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
io.err(`Connecting failed: ${describeError(err)}\n`);
|
|
295
|
+
return 1;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/** The longest Retry-After that connect waits out before starting. */
|
|
301
|
+
const MAX_START_WAIT_S = 60;
|
|
302
|
+
/** The seconds a rate limit asks to wait, when the error is one. */
|
|
303
|
+
function rateLimitWait(err) {
|
|
304
|
+
if (!(err instanceof ApiError) || (err.code !== 'rate_limited' && err.status !== 429)) {
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
return err.retryAfterSeconds ?? 60;
|
|
308
|
+
}
|
|
309
|
+
function bucketOf(err) {
|
|
310
|
+
if (!(err instanceof ApiError) || err.details === null || typeof err.details !== 'object') {
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
const bucket = err.details.bucket;
|
|
314
|
+
return typeof bucket === 'string' ? bucket : undefined;
|
|
315
|
+
}
|
|
316
|
+
/** Why connect_start refused, in plain words, with how long to wait when it said. */
|
|
317
|
+
function startRefusal(err, wait) {
|
|
318
|
+
if (wait === undefined)
|
|
319
|
+
return describeError(err);
|
|
320
|
+
const minutes = Math.max(1, Math.ceil(wait / 60));
|
|
321
|
+
const later = `Try again in about ${minutes} minute${minutes === 1 ? '' : 's'}.`;
|
|
322
|
+
switch (bucketOf(err)) {
|
|
323
|
+
case 'connect_pending_per_address':
|
|
324
|
+
return `too many connections from this network are waiting for approval. Approve or deny the codes already shown (or let them expire), then run connect again. ${later}`;
|
|
325
|
+
case 'connect_starts_per_hour':
|
|
326
|
+
return `too many connections were started from this network in the last hour. ${later}`;
|
|
327
|
+
case 'connect_pending_grants':
|
|
328
|
+
return `buildIt.Social has too many connections waiting for approval right now. ${later}`;
|
|
329
|
+
default:
|
|
330
|
+
return `too many requests. ${later}`;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function intervalOf(details) {
|
|
334
|
+
if (details === null || typeof details !== 'object')
|
|
335
|
+
return undefined;
|
|
336
|
+
const value = details.interval;
|
|
337
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 && value <= 300
|
|
338
|
+
? value
|
|
339
|
+
: undefined;
|
|
340
|
+
}
|
|
341
|
+
/** buildit-mcp disconnect: says the session is offline and forgets its token here. */
|
|
342
|
+
export async function runDisconnect(given, io) {
|
|
343
|
+
const options = { ...given, apiUrl: await apiUrlOf(given, io.store) };
|
|
344
|
+
const key = sessionKey(options.folder, options.apiUrl);
|
|
345
|
+
const token = await io.store.get(key);
|
|
346
|
+
if (token === undefined) {
|
|
347
|
+
// A record of the deployment can outlive its token (an older version left it after a revoke).
|
|
348
|
+
const stale = await forgetStaleApi(io.store, options.folder);
|
|
349
|
+
io.out(stale
|
|
350
|
+
? 'No session is saved for this folder; removed the leftover record of its deployment.\n'
|
|
351
|
+
: 'No session is saved for this folder.\n');
|
|
352
|
+
return 0;
|
|
353
|
+
}
|
|
354
|
+
// Best effort: the session ends in buildIt.Social too, before its token is forgotten here.
|
|
355
|
+
const failed = await cancel(apiFor(options, io, token));
|
|
356
|
+
await forgetSession(io.store, options.folder, options.apiUrl);
|
|
357
|
+
io.out(failed === undefined
|
|
358
|
+
? 'Disconnected: the session is cancelled in buildIt.Social, and this folder no longer has a saved token.\n'
|
|
359
|
+
: `Disconnected: this folder no longer has a saved token. The session could not be cancelled in buildIt.Social (${failed}): revoke it there (Settings → Agents).\n`);
|
|
360
|
+
return 0;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Ends the session with DELETE /v1/session. Returns undefined when it is
|
|
364
|
+
* over (a session already revoked or expired counts), else why it failed.
|
|
365
|
+
*/
|
|
366
|
+
async function cancel(api) {
|
|
367
|
+
try {
|
|
368
|
+
await revokeSession(api, { signal: AbortSignal.timeout(10_000) });
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
if (err instanceof ApiError && SESSION_ENDED_CODES.has(err.code))
|
|
373
|
+
return undefined;
|
|
374
|
+
return err instanceof ApiError ? err.code : 'unknown error';
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/** buildit-mcp status: the folder's saved session, as buildIt.Social sees it. */
|
|
378
|
+
export async function runStatus(given, io) {
|
|
379
|
+
const options = { ...given, apiUrl: await apiUrlOf(given, io.store) };
|
|
380
|
+
const key = sessionKey(options.folder, options.apiUrl);
|
|
381
|
+
const token = await io.store.get(key);
|
|
382
|
+
const duplicate = duplicateChannelWarning(options.folder, io.home);
|
|
383
|
+
const header = [
|
|
384
|
+
`Folder: ${options.folder}`,
|
|
385
|
+
`API: ${options.apiUrl}`,
|
|
386
|
+
...channelLines(options.folder, options.apiUrl, io),
|
|
387
|
+
...(duplicate === undefined ? [] : [duplicate]),
|
|
388
|
+
];
|
|
389
|
+
if (token === undefined) {
|
|
390
|
+
io.out([...header, 'Not connected. Run "buildit-mcp connect --project <KEY>" in this folder.'].join('\n') + '\n');
|
|
391
|
+
return 1;
|
|
392
|
+
}
|
|
393
|
+
const stored = `Token: in ${io.store.location}`;
|
|
394
|
+
try {
|
|
395
|
+
const info = await getSession(apiFor(options, io, token));
|
|
396
|
+
io.out([...header, stored, ...sessionLines(info)].join('\n') + '\n');
|
|
397
|
+
return 0;
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
const refused = isTokenRefused(err);
|
|
401
|
+
io.out([
|
|
402
|
+
...header,
|
|
403
|
+
stored,
|
|
404
|
+
refused
|
|
405
|
+
? `buildIt.Social no longer accepts this session: ${describeError(err)} Run "buildit-mcp disconnect", then connect again.`
|
|
406
|
+
: `Could not check the session: ${describeError(err)}`,
|
|
407
|
+
].join('\n') + '\n');
|
|
408
|
+
return 1;
|
|
409
|
+
}
|
|
410
|
+
}
|