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/README.md +68 -6
- package/dist/agents.d.ts +43 -0
- package/dist/agents.js +192 -0
- package/dist/api.d.ts +64 -0
- package/dist/api.js +61 -0
- package/dist/checks.d.ts +3 -0
- package/dist/checks.js +71 -22
- package/dist/cli.js +137 -6
- package/dist/commands.d.ts +37 -0
- package/dist/commands.js +338 -0
- package/dist/parse.d.ts +15 -1
- package/dist/parse.js +72 -13
- package/dist/prompt.d.ts +10 -0
- package/dist/prompt.js +54 -0
- package/dist/proxy.d.ts +15 -0
- package/dist/proxy.js +235 -0
- package/dist/report.js +1 -1
- package/dist/secrets.js +3 -3
- package/dist/sources.d.ts +1 -0
- package/dist/sources.js +19 -4
- package/dist/state.d.ts +48 -0
- package/dist/state.js +51 -0
- package/dist/types.d.ts +4 -1
- package/package.json +7 -5
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
|
|
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
|
-
|
|
30
|
-
|
|
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(
|
|
248
|
+
args = parseArgs(argv);
|
|
118
249
|
}
|
|
119
250
|
catch (error) {
|
|
120
|
-
process.stderr.write(`${error.message}\n\n${
|
|
251
|
+
process.stderr.write(`${error.message}\n\n${help}`);
|
|
121
252
|
return 2;
|
|
122
253
|
}
|
|
123
254
|
if (args === "help") {
|
|
124
|
-
process.stdout.write(
|
|
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>;
|
package/dist/commands.js
ADDED
|
@@ -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
|
-
/**
|
|
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;
|