toolstile 0.1.1 → 0.2.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/dist/cli.js CHANGED
@@ -2,10 +2,24 @@
2
2
  import { readFileSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { resolve } from "node:path";
5
+ import { createApi } from "./api.js";
5
6
  import { renderCard, shareText, shareUrl } from "./card.js";
7
+ import { login, logout, proxy, setup, UserError } from "./commands.js";
6
8
  import { audit } from "./index.js";
7
9
  import { redact, renderJson, renderReport, style } from "./report.js";
8
- const HELP = `toolstile: audit the MCP server configs your AI agents use
10
+ const HELP = `toolstile: audit your MCP configs and connect your agents to Toolstile
11
+
12
+ Usage
13
+ npx toolstile audit [options] Check local MCP configs (offline, read-only)
14
+ npx toolstile auth login Sign in, from any machine, with a code
15
+ npx toolstile auth logout Sign out on this machine
16
+ npx toolstile mcp setup Choose which agents here use Toolstile
17
+
18
+ Run toolstile <command> --help for a command's options.
19
+ -h, --help Show this help
20
+ -v, --version Show the version
21
+ `;
22
+ const AUDIT_HELP = `toolstile audit: check the MCP server configs your AI agents use
9
23
 
10
24
  Usage
11
25
  npx toolstile audit [options]
@@ -26,8 +40,40 @@ Options
26
40
  --project <dir> Also scan project configs in <dir> (default: current directory)
27
41
  --no-project Scan only user-level configs
28
42
  --fail-on <level> Exit with code 1 if any finding is at or above high, medium or low
29
- -h, --help Show this help
30
- -v, --version Show the version
43
+ `;
44
+ const AUTH_HELP = `toolstile auth: sign the CLI in to Toolstile
45
+
46
+ Usage
47
+ npx toolstile auth login [--server <url>] [--no-browser]
48
+ npx toolstile auth logout
49
+
50
+ login prints a link and a code. Open the link on any device with a browser
51
+ (your laptop, your phone), sign in and approve the code. Nothing has to
52
+ reach this machine, so it works over SSH and on servers without a browser.
53
+
54
+ Options
55
+ --server <url> Toolstile address (default https://toolstile.com, or TOOLSTILE_URL)
56
+ --no-browser Do not try to open a browser on this machine
57
+ `;
58
+ const MCP_HELP = `toolstile mcp: connect the agents on this machine to Toolstile
59
+
60
+ Usage
61
+ npx toolstile mcp setup [options]
62
+ npx toolstile mcp proxy --agent <agent>
63
+
64
+ setup lists the agents found here and lets you turn each one on or off.
65
+ Each enabled agent gets its own access, shown under this device in the
66
+ Toolstile dashboard. Its config runs \`toolstile mcp proxy\`, which holds the
67
+ agent's token in the CLI's own owner-only credentials file, so no secret
68
+ is written into the agent's config.
69
+
70
+ Options for setup
71
+ --enable <agents> Turn these on without prompting (comma-separated)
72
+ --disable <agents> Turn these off without prompting
73
+ --workspace <slug> Workspace the agents use (default: the last one, or personal)
74
+ --device-name <name> Name for this machine (default: its hostname)
75
+
76
+ Agents: claude-code, claude-desktop, cursor, codex, vscode, windsurf, gemini, cline
31
77
  `;
32
78
  function version() {
33
79
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
@@ -111,17 +157,102 @@ async function writeCard(path, svg) {
111
157
  }
112
158
  }
113
159
  const RANK = { high: 3, medium: 2, low: 1, info: 0 };
160
+ /** `--name value`, `--name=value` and boolean `--name` flags for the auth and mcp commands. */
161
+ function parseFlags(argv, valued, booleans) {
162
+ const flags = {};
163
+ for (let i = 0; i < argv.length; i++) {
164
+ const a = argv[i];
165
+ if (a === "-h" || a === "--help")
166
+ return "help";
167
+ const eq = a.indexOf("=");
168
+ const name = a.startsWith("--") ? (eq === -1 ? a.slice(2) : a.slice(2, eq)) : "";
169
+ if (valued.includes(name)) {
170
+ const v = eq === -1 ? argv[++i] : a.slice(eq + 1);
171
+ if (v === undefined || v.startsWith("-"))
172
+ throw new UserError(`--${name} needs a value`);
173
+ flags[name] = v;
174
+ }
175
+ else if (booleans.includes(name) && eq === -1)
176
+ flags[name] = true;
177
+ else
178
+ throw new UserError(`Unknown option: ${a}`);
179
+ }
180
+ return flags;
181
+ }
182
+ async function command(argv) {
183
+ const [group, action, ...rest] = argv;
184
+ const help = group === "auth" ? AUTH_HELP : MCP_HELP;
185
+ const actions = group === "auth" ? ["login", "logout"] : ["setup", "proxy"];
186
+ if (!action || action === "-h" || action === "--help" || action === "help") {
187
+ process.stdout.write(help);
188
+ return action ? 0 : 2;
189
+ }
190
+ if (!actions.includes(action)) {
191
+ process.stderr.write(`Unknown command: toolstile ${group} ${action}\n\n${help}`);
192
+ return 2;
193
+ }
194
+ const color = process.stdout.isTTY === true && !process.env.NO_COLOR;
195
+ const ctx = {
196
+ home: homedir(),
197
+ platform: process.platform,
198
+ env: process.env,
199
+ version: version(),
200
+ api: (server) => createApi(server),
201
+ stdin: process.stdin,
202
+ stdout: process.stdout,
203
+ stderr: process.stderr,
204
+ // The proxy's stdout carries MCP messages, so nothing else may be printed there.
205
+ style: style(action !== "proxy" && color),
206
+ };
207
+ try {
208
+ const flags = action === "login"
209
+ ? parseFlags(rest, ["server"], ["no-browser"])
210
+ : action === "logout"
211
+ ? parseFlags(rest, [], [])
212
+ : action === "setup"
213
+ ? parseFlags(rest, ["enable", "disable", "workspace", "device-name"], [])
214
+ : parseFlags(rest, ["agent"], []);
215
+ if (flags === "help") {
216
+ process.stdout.write(help);
217
+ return 0;
218
+ }
219
+ const value = (name) => (typeof flags[name] === "string" ? flags[name] : undefined);
220
+ if (action === "login")
221
+ return await login(ctx, { server: value("server"), browser: !flags["no-browser"] });
222
+ if (action === "logout")
223
+ return await logout(ctx);
224
+ if (action === "setup")
225
+ return await setup(ctx, {
226
+ enable: value("enable"),
227
+ disable: value("disable"),
228
+ workspace: value("workspace"),
229
+ deviceName: value("device-name"),
230
+ });
231
+ return await proxy(ctx, value("agent"));
232
+ }
233
+ catch (error) {
234
+ if (error instanceof UserError || error.name === "ApiError") {
235
+ process.stderr.write(`toolstile: ${error.message}\n`);
236
+ return 1;
237
+ }
238
+ throw error;
239
+ }
240
+ }
114
241
  async function main() {
242
+ const argv = process.argv.slice(2);
243
+ if (argv[0] === "auth" || argv[0] === "mcp")
244
+ return command(argv);
245
+ const help = argv[0] === "audit" ? AUDIT_HELP : HELP;
115
246
  let args;
116
247
  try {
117
- args = parseArgs(process.argv.slice(2));
248
+ args = parseArgs(argv);
118
249
  }
119
250
  catch (error) {
120
- process.stderr.write(`${error.message}\n\n${HELP}`);
251
+ process.stderr.write(`${error.message}\n\n${help}`);
121
252
  return 2;
122
253
  }
123
254
  if (args === "help") {
124
- process.stdout.write(HELP);
255
+ process.stdout.write(help);
125
256
  return 0;
126
257
  }
127
258
  if (args === "version") {
@@ -0,0 +1,37 @@
1
+ import { type Api } from "./api.js";
2
+ import { type Style } from "./report.js";
3
+ export interface Context {
4
+ home: string;
5
+ platform: NodeJS.Platform;
6
+ env: Record<string, string | undefined>;
7
+ version: string;
8
+ api: (server: string) => Api;
9
+ stdin: NodeJS.ReadableStream & {
10
+ isTTY?: boolean;
11
+ };
12
+ stdout: NodeJS.WritableStream & {
13
+ isTTY?: boolean;
14
+ };
15
+ stderr: NodeJS.WritableStream;
16
+ style: Style;
17
+ sleep?: (ms: number) => Promise<void>;
18
+ openBrowser?: (url: string) => void;
19
+ }
20
+ /** A failure explained to the user; exits 1 without a stack trace. */
21
+ export declare class UserError extends Error {
22
+ }
23
+ /** HTTPS, except a loopback server for local development. */
24
+ export declare function normalizeServer(value: string): string;
25
+ export declare function login(ctx: Context, options: {
26
+ server?: string;
27
+ browser: boolean;
28
+ }): Promise<number>;
29
+ export declare function logout(ctx: Context): Promise<number>;
30
+ export interface SetupOptions {
31
+ enable?: string;
32
+ disable?: string;
33
+ workspace?: string;
34
+ deviceName?: string;
35
+ }
36
+ export declare function setup(ctx: Context, options: SetupOptions): Promise<number>;
37
+ export declare function proxy(ctx: Context, agent: string | undefined): Promise<number>;
@@ -0,0 +1,338 @@
1
+ import { spawn } from "node:child_process";
2
+ import { hostname } from "node:os";
3
+ import { ManualEditError, proxyCommand, readEntry, isProxyEntry, targets, writeEntry } from "./agents.js";
4
+ import { ApiError } from "./api.js";
5
+ import { checklist } from "./prompt.js";
6
+ import { runProxy } from "./proxy.js";
7
+ import { tildify } from "./report.js";
8
+ import { CLIENT_NAMES } from "./sources.js";
9
+ import { loadState, saveState } from "./state.js";
10
+ /** A failure explained to the user; exits 1 without a stack trace. */
11
+ export class UserError extends Error {
12
+ }
13
+ const stateEnv = (ctx) => ({ home: ctx.home, env: ctx.env, platform: ctx.platform });
14
+ const print = (ctx, text = "") => ctx.stdout.write(`${text}\n`);
15
+ const interactive = (ctx) => ctx.stdin.isTTY === true && ctx.stdout.isTTY === true;
16
+ /** HTTPS, except a loopback server for local development. */
17
+ export function normalizeServer(value) {
18
+ let url;
19
+ try {
20
+ url = new URL(value);
21
+ }
22
+ catch {
23
+ throw new UserError(`--server must be a URL, for example https://toolstile.com`);
24
+ }
25
+ const loopback = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\])$/.test(url.hostname);
26
+ if (url.protocol !== "https:" && !(loopback && url.protocol === "http:"))
27
+ throw new UserError("--server must use https://");
28
+ return url.origin;
29
+ }
30
+ function defaultOpenBrowser(ctx) {
31
+ return (url) => {
32
+ // Over SSH or without a display the URL and code are enough; nothing to open.
33
+ if (ctx.env.SSH_CONNECTION || ctx.env.SSH_TTY)
34
+ return;
35
+ if (ctx.platform === "linux" && !ctx.env.DISPLAY && !ctx.env.WAYLAND_DISPLAY)
36
+ return;
37
+ const [command, args] = ctx.platform === "darwin" ? ["open", [url]] : ctx.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
38
+ try {
39
+ spawn(command, args, { stdio: "ignore", detached: true }).on("error", () => { }).unref();
40
+ }
41
+ catch { }
42
+ };
43
+ }
44
+ const formatCode = (code) => (code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code);
45
+ export async function login(ctx, options) {
46
+ const s = ctx.style;
47
+ const state = loadState(stateEnv(ctx));
48
+ const server = normalizeServer(options.server ?? ctx.env.TOOLSTILE_URL ?? state.server);
49
+ const api = ctx.api(server);
50
+ const code = await api.deviceCode();
51
+ print(ctx, `Open this page on any device with a browser, sign in to Toolstile and enter the code:`);
52
+ print(ctx);
53
+ print(ctx, ` ${s.bold(code.verification_uri)}`);
54
+ print(ctx, ` ${s.bold(formatCode(code.user_code))}`);
55
+ print(ctx);
56
+ print(ctx, s.dim(`Waiting for approval. The code expires in ${Math.round(code.expires_in / 60)} minutes.`));
57
+ if (options.browser)
58
+ (ctx.openBrowser ?? defaultOpenBrowser(ctx))(code.verification_uri);
59
+ const sleep = ctx.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
60
+ let interval = Math.max(code.interval, 1) * 1000;
61
+ const deadline = Date.now() + code.expires_in * 1000;
62
+ let token;
63
+ while (!token) {
64
+ if (Date.now() > deadline)
65
+ throw new UserError("The code expired. Run toolstile auth login again.");
66
+ await sleep(interval);
67
+ try {
68
+ token = (await api.deviceToken(code.device_code)).access_token;
69
+ }
70
+ catch (error) {
71
+ if (!(error instanceof ApiError))
72
+ throw error;
73
+ if (error.code === "authorization_pending")
74
+ continue;
75
+ if (error.code === "slow_down") {
76
+ interval += 5000;
77
+ continue;
78
+ }
79
+ if (error.code === "access_denied")
80
+ throw new UserError("Sign-in was denied in the browser.");
81
+ if (error.code === "expired_token")
82
+ throw new UserError("The code expired. Run toolstile auth login again.");
83
+ throw error;
84
+ }
85
+ }
86
+ const account = await api.account(token);
87
+ const previous = state.session;
88
+ // The old session is ended where it was issued, never sent to the new server.
89
+ if (previous)
90
+ await ctx.api(previous.server ?? state.server).signOut(previous.token).catch(() => undefined);
91
+ if (server !== state.server || (previous && previous.email !== account.email)) {
92
+ // Another account's workspace and device do not apply; enabled agents keep their own access.
93
+ delete state.workspace;
94
+ delete state.device;
95
+ }
96
+ state.server = server;
97
+ state.session = { token, email: account.email, expiresAt: account.expiresAt, server };
98
+ saveState(state, stateEnv(ctx));
99
+ print(ctx);
100
+ print(ctx, `Signed in as ${s.bold(account.email)}. Next: ${s.bold("toolstile mcp setup")}`);
101
+ return 0;
102
+ }
103
+ export async function logout(ctx) {
104
+ const state = loadState(stateEnv(ctx));
105
+ if (!state.session) {
106
+ print(ctx, "Not signed in.");
107
+ return 0;
108
+ }
109
+ let reached = true;
110
+ await ctx
111
+ .api(state.session.server ?? state.server)
112
+ .signOut(state.session.token)
113
+ .catch((error) => {
114
+ // 401 means the session had already ended; anything else is worth saying.
115
+ if (!(error instanceof ApiError && error.status === 401))
116
+ reached = false;
117
+ });
118
+ delete state.session;
119
+ saveState(state, stateEnv(ctx));
120
+ print(ctx, reached ? "Signed out." : "Signed out on this machine. Toolstile could not be reached, so the session ends when it expires.");
121
+ const enabled = Object.keys(state.agents).length;
122
+ if (enabled)
123
+ print(ctx, ctx.style.dim(`${enabled === 1 ? "The agent" : `The ${enabled} agents`} you enabled keep working. To turn ${enabled === 1 ? "it" : "them"} off, run toolstile mcp setup while signed in, or delete this device in the Toolstile dashboard.`));
124
+ return 0;
125
+ }
126
+ function parseClients(list, flag) {
127
+ if (!list)
128
+ return [];
129
+ const ids = Object.keys(CLIENT_NAMES);
130
+ return list.split(",").map((raw) => {
131
+ const value = raw.trim().toLowerCase();
132
+ const match = ids.find((id) => id === value || CLIENT_NAMES[id].toLowerCase().replace(/\s+/g, "-") === value);
133
+ if (!match)
134
+ throw new UserError(`${flag}: unknown agent "${raw.trim()}". Use ${ids.join(", ")}.`);
135
+ return match;
136
+ });
137
+ }
138
+ async function chooseWorkspace(ctx, state, workspaces, wanted) {
139
+ if (wanted) {
140
+ const value = wanted.toLowerCase();
141
+ const match = workspaces.find((w) => w.slug === value || w.id === wanted || w.name.toLowerCase() === value);
142
+ if (!match)
143
+ throw new UserError(`No workspace "${wanted}". Yours: ${workspaces.map((w) => w.slug).join(", ")}.`);
144
+ return match;
145
+ }
146
+ const current = workspaces.find((w) => w.id === state.workspace?.id) ?? workspaces.find((w) => w.personal) ?? workspaces[0];
147
+ if (!current)
148
+ throw new UserError("Your account has no workspace yet. Open Toolstile in a browser once, then try again.");
149
+ if (workspaces.length === 1 || !interactive(ctx))
150
+ return current;
151
+ const picked = await checklist("Which workspace should this machine's agents use?", workspaces.map((w) => ({ label: w.name, hint: w.personal ? "personal" : w.slug, checked: w.id === current.id })), ctx.stdin, ctx.stdout, { single: true });
152
+ if (!picked)
153
+ throw new UserError("Cancelled; nothing changed.");
154
+ return workspaces[picked.indexOf(true)];
155
+ }
156
+ export async function setup(ctx, options) {
157
+ const s = ctx.style;
158
+ const state = loadState(stateEnv(ctx));
159
+ const session = state.session;
160
+ if (!session)
161
+ throw new UserError("Sign in first: toolstile auth login");
162
+ const api = ctx.api(session.server ?? state.server);
163
+ const account = await api.account(session.token).catch((error) => {
164
+ if (error instanceof ApiError && error.status === 401)
165
+ throw new UserError("Your sign-in has expired. Run toolstile auth login again.");
166
+ throw error;
167
+ });
168
+ const enable = parseClients(options.enable, "--enable");
169
+ const disable = parseClients(options.disable, "--disable");
170
+ const both = enable.find((c) => disable.includes(c));
171
+ if (both)
172
+ throw new UserError(`${CLIENT_NAMES[both]} is in both --enable and --disable.`);
173
+ const workspace = await chooseWorkspace(ctx, state, account.workspaces, options.workspace);
174
+ state.workspace = { id: workspace.id, slug: workspace.slug, name: workspace.name };
175
+ saveState(state, stateEnv(ctx));
176
+ /** Created (or renamed) only when something needs it, so listing changes nothing. */
177
+ let device;
178
+ const ensureDevice = async () => {
179
+ device ??= await api.device(session.token, {
180
+ workspaceId: workspace.id,
181
+ id: state.device?.id,
182
+ label: options.deviceName ?? state.device?.label ?? hostname().replace(/\.local$/, ""),
183
+ rename: options.deviceName !== undefined,
184
+ });
185
+ state.device = device;
186
+ saveState(state, stateEnv(ctx));
187
+ return device;
188
+ };
189
+ if (options.deviceName)
190
+ await ensureDevice();
191
+ // Agents deleted from the dashboard, or left in another workspace, are no longer enabled.
192
+ const remote = new Set(state.device ? (await api.agents(session.token, workspace.id, state.device.id)).agents.map((a) => a.id) : []);
193
+ const live = (client) => {
194
+ const link = state.agents[client];
195
+ return !!link && link.server === state.server && link.workspaceId === workspace.id && remote.has(link.id);
196
+ };
197
+ /** Removes an agent where it was created; links from another server are just dropped. */
198
+ const removeRemote = (link) => link.server === state.server
199
+ ? api.removeAgent(session.token, link.workspaceId, link.id).catch((error) => {
200
+ if (!(error instanceof ApiError && (error.status === 404 || error.status === 403)))
201
+ throw error;
202
+ })
203
+ : Promise.resolve();
204
+ const deviceLabel = state.device?.label ?? hostname().replace(/\.local$/, "");
205
+ const all = targets(ctx);
206
+ const shown = all.filter((t) => t.detected || live(t.client) || state.agents[t.client] || enable.includes(t.client));
207
+ if (!shown.length) {
208
+ print(ctx, "No supported agents found on this machine. Supported: " + all.map((t) => t.name).join(", ") + ".");
209
+ return 0;
210
+ }
211
+ let wanted;
212
+ if (enable.length || disable.length) {
213
+ wanted = new Set(all.filter((t) => live(t.client)).map((t) => t.client));
214
+ for (const c of enable)
215
+ wanted.add(c);
216
+ for (const c of disable)
217
+ wanted.delete(c);
218
+ }
219
+ else if (interactive(ctx)) {
220
+ print(ctx, `Signed in as ${account.email} · workspace ${s.bold(workspace.name)} · device ${s.bold(deviceLabel)}`);
221
+ const picked = await checklist("Which agents on this machine should use Toolstile?", shown.map((t) => ({
222
+ label: t.name,
223
+ hint: t.detected ? tildify(t.path, ctx.home) : "not found on this machine",
224
+ checked: live(t.client),
225
+ })), ctx.stdin, ctx.stdout);
226
+ if (!picked) {
227
+ print(ctx, "Cancelled; nothing changed.");
228
+ return 0;
229
+ }
230
+ wanted = new Set(shown.filter((_, i) => picked[i]).map((t) => t.client));
231
+ }
232
+ else {
233
+ print(ctx, `Workspace ${workspace.name} · device ${deviceLabel}`);
234
+ for (const t of shown)
235
+ print(ctx, ` ${live(t.client) ? "enabled " : "disabled"} ${t.client.padEnd(14)} ${t.name}`);
236
+ print(ctx);
237
+ print(ctx, "Choose agents with --enable and --disable, for example: toolstile mcp setup --enable claude-code,codex");
238
+ return 0;
239
+ }
240
+ const manual = [];
241
+ const changes = [];
242
+ const replaced = [];
243
+ for (const target of all) {
244
+ const want = wanted.has(target.client);
245
+ const link = state.agents[target.client];
246
+ if (want) {
247
+ if (!live(target.client)) {
248
+ if (link)
249
+ await removeRemote(link).catch(() => undefined);
250
+ const { id: deviceId } = await ensureDevice();
251
+ const created = await api.addAgent(session.token, { workspaceId: workspace.id, deviceId, label: target.name });
252
+ state.agents[target.client] = {
253
+ id: created.id,
254
+ workspaceId: workspace.id,
255
+ server: state.server,
256
+ label: created.label,
257
+ token: created.token,
258
+ };
259
+ // Saved before touching the agent's config, so a token is never lost.
260
+ saveState(state, stateEnv(ctx));
261
+ }
262
+ try {
263
+ const result = writeEntry(target, proxyCommand(target.client, ctx.version, ctx.platform));
264
+ if (!live(target.client) || result !== "unchanged")
265
+ changes.push(`${s.bold("+")} ${target.name} ${s.dim(tildify(target.path, ctx.home))}`);
266
+ if (result === "replaced")
267
+ replaced.push(target.name);
268
+ }
269
+ catch (error) {
270
+ if (!(error instanceof ManualEditError))
271
+ throw error;
272
+ manual.push({ target, error, enable: true });
273
+ }
274
+ }
275
+ else if (link || isProxyEntry(readEntry(target))) {
276
+ try {
277
+ writeEntry(target, null);
278
+ }
279
+ catch (error) {
280
+ if (!(error instanceof ManualEditError))
281
+ throw error;
282
+ manual.push({ target, error, enable: false });
283
+ }
284
+ if (link) {
285
+ await removeRemote(link);
286
+ delete state.agents[target.client];
287
+ saveState(state, stateEnv(ctx));
288
+ }
289
+ changes.push(`${s.bold("-")} ${target.name}`);
290
+ }
291
+ }
292
+ print(ctx);
293
+ if (changes.length) {
294
+ for (const line of changes)
295
+ print(ctx, line);
296
+ print(ctx);
297
+ print(ctx, "Restart agents that are running to pick up the change.");
298
+ }
299
+ else
300
+ print(ctx, "Nothing to change.");
301
+ if (replaced.length) {
302
+ print(ctx);
303
+ print(ctx, `Replaced an existing "toolstile" server in ${replaced.join(", ")}. If it signed in directly, remove that older agent under Devices & agents in the dashboard.`);
304
+ }
305
+ for (const { target, error, enable } of manual) {
306
+ print(ctx);
307
+ print(ctx, `${target.name}: ${tildify(target.path, ctx.home)} was not changed because ${error.message}.`);
308
+ if (enable) {
309
+ print(ctx, "Add this entry yourself:");
310
+ print(ctx, error.snippet.replace(/^/gm, " "));
311
+ }
312
+ else
313
+ print(ctx, `Remove its "toolstile" server entry yourself.`);
314
+ }
315
+ if ([...wanted].length) {
316
+ print(ctx);
317
+ print(ctx, `Choose which MCP servers they can use: ${state.server}/app/${workspace.slug}/access`);
318
+ }
319
+ return manual.length ? 1 : 0;
320
+ }
321
+ export async function proxy(ctx, agent) {
322
+ const [client] = parseClients(agent, "--agent");
323
+ if (!client)
324
+ throw new UserError("--agent is required");
325
+ const state = loadState(stateEnv(ctx));
326
+ const link = state.agents[client];
327
+ if (!link)
328
+ throw new UserError(`${CLIENT_NAMES[client]} is not enabled on this machine. Run toolstile mcp setup.`);
329
+ await runProxy({
330
+ // The token goes only to the server that issued it, whatever the environment says.
331
+ endpoint: `${link.server}/mcp`,
332
+ token: link.token,
333
+ input: ctx.stdin,
334
+ output: ctx.stdout,
335
+ log: (message) => ctx.stderr.write(`toolstile: ${message}\n`),
336
+ });
337
+ return 0;
338
+ }
package/dist/parse.d.ts CHANGED
@@ -1,3 +1,17 @@
1
- /** Parses JSON that may contain comments and trailing commas (VS Code style). */
1
+ /**
2
+ * A config file that could not be parsed. The message is fixed text plus a position: parser
3
+ * messages quote the offending source line, which can hold a secret, so they are never kept.
4
+ */
5
+ export declare class ConfigParseError extends Error {
6
+ readonly format: "JSON" | "TOML";
7
+ readonly line?: number | undefined;
8
+ readonly column?: number | undefined;
9
+ constructor(format: "JSON" | "TOML", line?: number | undefined, column?: number | undefined);
10
+ }
11
+ /**
12
+ * Parses JSON that may contain comments and trailing commas (VS Code style). Comments and
13
+ * trailing commas become spaces so parser positions still match the original text.
14
+ */
2
15
  export declare function parseJsonc(text: string): unknown;
16
+ /** Parses a config file. Throws only {@link ConfigParseError}, whose message never contains file text. */
3
17
  export declare function parseConfig(path: string, text: string): unknown;