toolstile 0.1.2 → 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 CHANGED
@@ -38,15 +38,16 @@ HIGH Plaintext secret in github (Cursor)
38
38
  Fix: Reference an environment variable instead (for example ${API_TOKEN}) …
39
39
  ```
40
40
 
41
- ## It only reads
41
+ ## The audit only reads
42
42
 
43
43
  - Reads config files on your machine. Writes nothing unless you ask for a card.
44
- - Makes no network requests and collects no telemetry.
44
+ - Makes no network requests and collects no telemetry. (Only the separate
45
+ `auth` and `mcp` commands below talk to Toolstile, and only when you run them.)
45
46
  - Never prints, stores or sends secret values. Findings name the file and key,
46
47
  for example `mcpServers.github.env.GITHUB_TOKEN`, and the kind of secret.
47
48
  - Never quotes a config file: one that cannot be read or parsed is listed with a
48
49
  reason and position only, for example `invalid TOML at line 3, column 12`.
49
- - About 1,400 lines of TypeScript with one runtime dependency
50
+ - About 2,500 lines of TypeScript with one runtime dependency
50
51
  ([smol-toml](https://github.com/squirrelchat/smol-toml), for Codex's TOML
51
52
  config) and an optional one ([resvg-js](https://github.com/thx/resvg-js), only
52
53
  to render `--card` as PNG). Read it before you run it.
@@ -106,6 +107,59 @@ values) and prints a ready-to-post link. To post the text report instead, add
106
107
  | Gemini CLI | `~/.gemini/settings.json` | `.gemini/settings.json` |
107
108
  | Cline | `cline_mcp_settings.json` in VS Code or Cursor storage | |
108
109
 
110
+ ## Connect your agents to Toolstile
111
+
112
+ If you use [Toolstile](https://toolstile.com), the CLI can also sign in and set
113
+ up the agents on a machine for you:
114
+
115
+ ```sh
116
+ npx toolstile auth login
117
+ npx toolstile mcp setup
118
+ ```
119
+
120
+ `auth login` prints a link and a code. Open the link on any device with a
121
+ browser (your laptop or phone), sign in and approve the code. Nothing has to
122
+ reach the machine you are signing in, so it works over SSH, in containers and on
123
+ servers without a browser.
124
+
125
+ `mcp setup` lists the agents it finds (Claude Code, Claude Desktop, Cursor,
126
+ Codex, VS Code, Windsurf, Gemini CLI, Cline) and lets you turn each one on or
127
+ off. Run it again any time to change the selection. Each enabled agent appears
128
+ under this machine in the Toolstile dashboard with its own access, so you can
129
+ grant, limit or revoke it there; then choose which MCP servers it can use.
130
+
131
+ ```text
132
+ Which agents on this machine should use Toolstile?
133
+ › ◉ Claude Code ~/.claude.json
134
+ ○ Cursor ~/.cursor/mcp.json
135
+ ◉ Codex ~/.codex/config.toml
136
+ ↑/↓ move · space toggle · a all · enter save · esc cancel
137
+ ```
138
+
139
+ Without a terminal, pass the choice as flags:
140
+ `npx toolstile mcp setup --enable claude-code,codex --disable cursor`.
141
+
142
+ How it stays out of your configs: an enabled agent's config gets a `toolstile`
143
+ server that runs `npx -y toolstile@<version> mcp proxy --agent <agent>`. The proxy
144
+ relays the agent's MCP messages to Toolstile using that agent's own token, which
145
+ lives only in the CLI's credentials file (`~/.config/toolstile/credentials.json`,
146
+ readable by you alone; `%APPDATA%\toolstile` on Windows). No token is written
147
+ into the agent's config, so `toolstile audit` has nothing to flag. JSON configs
148
+ with comments are not rewritten; setup prints the entry to add yourself.
149
+
150
+ `npx toolstile auth logout` ends the CLI's sign-in. Agents you enabled keep
151
+ working until you turn them off with `mcp setup` or delete the device in the
152
+ dashboard.
153
+
154
+ | Option | Command | Meaning |
155
+ | --- | --- | --- |
156
+ | `--server <url>` | `auth login` | Toolstile address (default `https://toolstile.com`, or `TOOLSTILE_URL`) |
157
+ | `--no-browser` | `auth login` | Do not try to open a browser on this machine |
158
+ | `--enable <agents>` | `mcp setup` | Turn these on without prompting (comma-separated) |
159
+ | `--disable <agents>` | `mcp setup` | Turn these off without prompting |
160
+ | `--workspace <slug>` | `mcp setup` | Workspace the agents use (default: the last one, or personal) |
161
+ | `--device-name <name>` | `mcp setup` | Name for this machine in the dashboard (default: its hostname) |
162
+
109
163
  ## Use it from code
110
164
 
111
165
  ```ts
@@ -0,0 +1,43 @@
1
+ import type { ClientId } from "./types.js";
2
+ /** The server name `toolstile mcp setup` adds to each agent's config. */
3
+ export declare const ENTRY = "toolstile";
4
+ type Format = "mcpServers" | "vscode" | "codex";
5
+ export interface Target {
6
+ client: ClientId;
7
+ name: string;
8
+ /** User-level config file that gets the entry. */
9
+ path: string;
10
+ format: Format;
11
+ /** Whether the app looks installed: its config file or folder exists. */
12
+ detected: boolean;
13
+ }
14
+ export interface TargetEnv {
15
+ home: string;
16
+ platform: NodeJS.Platform;
17
+ env: Record<string, string | undefined>;
18
+ }
19
+ export declare function targets(opts: TargetEnv): Target[];
20
+ /** How the agent starts the proxy. Pinned, so the config runs the version that wrote it. */
21
+ export declare function proxyCommand(client: ClientId, version: string, platform: NodeJS.Platform): {
22
+ command: string;
23
+ args: string[];
24
+ };
25
+ /** A config entry for a different, hand-written `toolstile` server is left alone. */
26
+ export declare function isProxyEntry(value: unknown): boolean;
27
+ export declare class ManualEditError extends Error {
28
+ readonly target: Target;
29
+ readonly snippet: string;
30
+ constructor(target: Target, reason: string, snippet: string);
31
+ }
32
+ /** The current entry, if the agent's config has one. */
33
+ export declare function readEntry(target: Target): unknown;
34
+ /**
35
+ * Adds (or, with `command` null, removes) the `toolstile` entry. JSON files with comments are
36
+ * not rewritten, since that would drop the comments; the caller shows the snippet instead.
37
+ */
38
+ export type WriteResult = "added" | "updated" | "replaced" | "removed" | "unchanged";
39
+ export declare function writeEntry(target: Target, command: {
40
+ command: string;
41
+ args: string[];
42
+ } | null): WriteResult;
43
+ export {};
package/dist/agents.js ADDED
@@ -0,0 +1,192 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { parse as parseToml } from "smol-toml";
4
+ import { parseJsonc } from "./parse.js";
5
+ import { appData, CLIENT_NAMES } from "./sources.js";
6
+ /** The server name `toolstile mcp setup` adds to each agent's config. */
7
+ export const ENTRY = "toolstile";
8
+ export function targets(opts) {
9
+ const { home, env } = opts;
10
+ const data = appData(opts);
11
+ const codexHome = env.CODEX_HOME ?? join(home, ".codex");
12
+ const code = ["Code", "Code - Insiders"].map((edition) => join(data, edition, "User")).find((dir) => existsSync(dir)) ??
13
+ join(data, "Code", "User");
14
+ const cline = join(code, "globalStorage", "saoudrizwan.claude-dev");
15
+ const list = [
16
+ ["claude-code", join(home, ".claude.json"), "mcpServers", join(home, ".claude")],
17
+ ["claude-desktop", join(data, "Claude", "claude_desktop_config.json"), "mcpServers", join(data, "Claude")],
18
+ ["cursor", join(home, ".cursor", "mcp.json"), "mcpServers", join(home, ".cursor")],
19
+ ["codex", join(codexHome, "config.toml"), "codex", codexHome],
20
+ ["vscode", join(code, "mcp.json"), "vscode", code],
21
+ ["windsurf", join(home, ".codeium", "windsurf", "mcp_config.json"), "mcpServers", join(home, ".codeium", "windsurf")],
22
+ ["gemini", join(home, ".gemini", "settings.json"), "mcpServers", join(home, ".gemini")],
23
+ ["cline", join(cline, "settings", "cline_mcp_settings.json"), "mcpServers", cline],
24
+ ];
25
+ return list.map(([client, path, format, folder]) => ({
26
+ client,
27
+ name: CLIENT_NAMES[client],
28
+ path,
29
+ format,
30
+ detected: existsSync(path) || existsSync(folder),
31
+ }));
32
+ }
33
+ /** How the agent starts the proxy. Pinned, so the config runs the version that wrote it. */
34
+ export function proxyCommand(client, version, platform) {
35
+ const args = ["-y", `toolstile@${version}`, "mcp", "proxy", "--agent", client];
36
+ // Windows apps start commands without a shell, and npx is a .cmd script there.
37
+ return platform === "win32" ? { command: "cmd", args: ["/c", "npx", ...args] } : { command: "npx", args };
38
+ }
39
+ /** A config entry for a different, hand-written `toolstile` server is left alone. */
40
+ export function isProxyEntry(value) {
41
+ if (typeof value !== "object" || value === null)
42
+ return false;
43
+ const args = value.args;
44
+ return Array.isArray(args) && args.some((a) => typeof a === "string" && /^toolstile(@|$)/.test(a)) &&
45
+ args.includes("proxy");
46
+ }
47
+ export class ManualEditError extends Error {
48
+ target;
49
+ snippet;
50
+ constructor(target, reason, snippet) {
51
+ super(reason);
52
+ this.target = target;
53
+ this.snippet = snippet;
54
+ this.name = "ManualEditError";
55
+ }
56
+ }
57
+ function readText(path) {
58
+ try {
59
+ return readFileSync(path, "utf8");
60
+ }
61
+ catch (error) {
62
+ if (error.code === "ENOENT")
63
+ return null;
64
+ throw error;
65
+ }
66
+ }
67
+ /** Replaces the file in one step and keeps its permission bits; a symlink keeps pointing at it. */
68
+ function writeText(link, text) {
69
+ let path = link;
70
+ let mode = 0o600;
71
+ try {
72
+ path = realpathSync(link);
73
+ mode = statSync(path).mode & 0o777;
74
+ }
75
+ catch {
76
+ mkdirSync(dirname(path), { recursive: true });
77
+ }
78
+ const temp = `${path}.toolstile-${process.pid}.tmp`;
79
+ try {
80
+ writeFileSync(temp, text, { mode });
81
+ chmodSync(temp, mode);
82
+ renameSync(temp, path);
83
+ }
84
+ finally {
85
+ rmSync(temp, { force: true });
86
+ }
87
+ }
88
+ const serversKey = (format) => (format === "vscode" ? "servers" : "mcpServers");
89
+ function jsonEntry(target, command) {
90
+ return target.format === "vscode" || target.client === "claude-code" ? { type: "stdio", ...command } : command;
91
+ }
92
+ function tomlEntry(command) {
93
+ return `[mcp_servers.${ENTRY}]\ncommand = ${JSON.stringify(command.command)}\nargs = [${command.args.map((a) => JSON.stringify(a)).join(", ")}]\n`;
94
+ }
95
+ /** Drops `[mcp_servers.toolstile]` and its sub-tables, keeping every other line and comment. */
96
+ function withoutTomlEntry(text) {
97
+ const lines = text.split("\n");
98
+ const out = [];
99
+ let skipping = false;
100
+ for (const line of lines) {
101
+ const header = line.match(/^\s*\[\s*([^\]]+?)\s*\]\s*(#.*)?$/)?.[1];
102
+ if (header !== undefined) {
103
+ const name = header.replace(/\s+/g, "").replace(/"/g, "");
104
+ skipping = name === `mcp_servers.${ENTRY}` || name.startsWith(`mcp_servers.${ENTRY}.`);
105
+ }
106
+ if (!skipping)
107
+ out.push(line);
108
+ }
109
+ return out.join("\n").replace(/\n{3,}$/, "\n\n");
110
+ }
111
+ /** The current entry, if the agent's config has one. */
112
+ export function readEntry(target) {
113
+ const text = readText(target.path);
114
+ if (!text?.trim())
115
+ return undefined;
116
+ try {
117
+ if (target.format === "codex") {
118
+ const servers = parseToml(text).mcp_servers;
119
+ return servers?.[ENTRY];
120
+ }
121
+ const data = parseJsonc(text);
122
+ return data?.[serversKey(target.format)]?.[ENTRY];
123
+ }
124
+ catch {
125
+ return undefined;
126
+ }
127
+ }
128
+ /** `replaced` means a hand-written `toolstile` server (not the proxy) was swapped out. */
129
+ const outcome = (command, existing) => !command ? "removed" : existing === undefined ? "added" : isProxyEntry(existing) ? "updated" : "replaced";
130
+ export function writeEntry(target, command) {
131
+ const text = readText(target.path) ?? "";
132
+ if (target.format === "codex") {
133
+ let current;
134
+ try {
135
+ current = parseToml(text).mcp_servers;
136
+ }
137
+ catch {
138
+ throw new ManualEditError(target, "its TOML could not be parsed", command ? tomlEntry(command) : "");
139
+ }
140
+ const existing = current?.[ENTRY];
141
+ if (!command && (existing === undefined || !isProxyEntry(existing)))
142
+ return "unchanged";
143
+ let next = withoutTomlEntry(text);
144
+ if (command)
145
+ next = `${next.replace(/\s*$/, "")}${next.trim() ? "\n\n" : ""}${tomlEntry(command)}`;
146
+ let parsed;
147
+ try {
148
+ parsed = parseToml(next).mcp_servers?.[ENTRY];
149
+ }
150
+ catch {
151
+ parsed = null;
152
+ }
153
+ if (command ? !isProxyEntry(parsed) : parsed !== undefined)
154
+ throw new ManualEditError(target, "its toolstile server is written in a form setup cannot edit", command ? tomlEntry(command) : "");
155
+ if (next === text)
156
+ return "unchanged";
157
+ writeText(target.path, next);
158
+ return outcome(command, existing);
159
+ }
160
+ const key = serversKey(target.format);
161
+ const entry = command ? jsonEntry(target, command) : undefined;
162
+ const snippet = entry ? JSON.stringify({ [key]: { [ENTRY]: entry } }, null, 2) : "";
163
+ let data;
164
+ try {
165
+ data = text.trim() ? parseJsonc(text) : {};
166
+ }
167
+ catch {
168
+ throw new ManualEditError(target, "its JSON could not be parsed", snippet);
169
+ }
170
+ if (typeof data !== "object" || data === null || Array.isArray(data))
171
+ throw new ManualEditError(target, "it is not a JSON object", snippet);
172
+ const object = data;
173
+ const servers = (typeof object[key] === "object" && object[key] !== null ? object[key] : {});
174
+ const existing = servers[ENTRY];
175
+ if (entry ? JSON.stringify(existing) === JSON.stringify(entry) : existing === undefined || !isProxyEntry(existing))
176
+ return "unchanged";
177
+ // Rewriting would drop comments and trailing commas, so such files are left to the user.
178
+ try {
179
+ JSON.parse(text || "{}");
180
+ }
181
+ catch {
182
+ throw new ManualEditError(target, "it contains comments", snippet);
183
+ }
184
+ if (entry)
185
+ servers[ENTRY] = entry;
186
+ else
187
+ delete servers[ENTRY];
188
+ object[key] = servers;
189
+ const indent = text.match(/\n([ \t]+)"/)?.[1] ?? " ";
190
+ writeText(target.path, `${JSON.stringify(object, null, indent)}\n`);
191
+ return outcome(entry, existing);
192
+ }
package/dist/api.d.ts ADDED
@@ -0,0 +1,64 @@
1
+ export declare const CLIENT_ID = "toolstile-cli";
2
+ export declare class ApiError extends Error {
3
+ readonly status: number;
4
+ readonly code?: string | undefined;
5
+ constructor(status: number, message: string, code?: string | undefined);
6
+ }
7
+ export interface Workspace {
8
+ id: string;
9
+ slug: string;
10
+ name: string;
11
+ personal: boolean;
12
+ }
13
+ export interface Account {
14
+ email: string;
15
+ name: string;
16
+ expiresAt: string;
17
+ workspaces: Workspace[];
18
+ }
19
+ export interface DeviceCode {
20
+ device_code: string;
21
+ user_code: string;
22
+ verification_uri: string;
23
+ verification_uri_complete: string;
24
+ expires_in: number;
25
+ interval: number;
26
+ }
27
+ type Fetch = typeof fetch;
28
+ /** The Toolstile endpoints the CLI uses. Session calls carry the device-flow session token. */
29
+ export declare function createApi(server: string, fetcher?: Fetch): {
30
+ deviceCode: () => Promise<DeviceCode>;
31
+ deviceToken: (deviceCode: string) => Promise<{
32
+ access_token: string;
33
+ expires_in: number;
34
+ }>;
35
+ account: (token: string) => Promise<Account>;
36
+ signOut: (token: string) => Promise<void>;
37
+ device: (token: string, input: {
38
+ workspaceId: string;
39
+ id?: string;
40
+ label: string;
41
+ rename?: boolean;
42
+ }) => Promise<{
43
+ id: string;
44
+ label: string;
45
+ }>;
46
+ agents: (token: string, workspaceId: string, deviceId: string) => Promise<{
47
+ agents: {
48
+ id: string;
49
+ label: string;
50
+ }[];
51
+ }>;
52
+ addAgent: (token: string, input: {
53
+ workspaceId: string;
54
+ deviceId: string;
55
+ label: string;
56
+ }) => Promise<{
57
+ id: string;
58
+ label: string;
59
+ token: string;
60
+ }>;
61
+ removeAgent: (token: string, workspaceId: string, id: string) => Promise<void>;
62
+ };
63
+ export type Api = ReturnType<typeof createApi>;
64
+ export {};
package/dist/api.js ADDED
@@ -0,0 +1,61 @@
1
+ export const CLIENT_ID = "toolstile-cli";
2
+ export class ApiError extends Error {
3
+ status;
4
+ code;
5
+ constructor(status, message, code) {
6
+ super(message);
7
+ this.status = status;
8
+ this.code = code;
9
+ this.name = "ApiError";
10
+ }
11
+ }
12
+ async function request(fetcher, url, init = {}) {
13
+ let response;
14
+ try {
15
+ response = await fetcher(url, {
16
+ ...init,
17
+ // Never follow a redirect with a session token to another address.
18
+ redirect: "error",
19
+ headers: { "Content-Type": "application/json", Accept: "application/json", ...init.headers },
20
+ });
21
+ }
22
+ catch {
23
+ throw new ApiError(0, `Could not reach ${new URL(url).origin}. Check your connection.`);
24
+ }
25
+ if (response.status === 204)
26
+ return undefined;
27
+ const body = (await response.json().catch(() => null));
28
+ if (!response.ok) {
29
+ const message = body?.error_description ?? body?.message;
30
+ throw new ApiError(response.status, typeof message === "string" ? message : `Request failed (${response.status})`, typeof body?.error === "string" ? body.error : undefined);
31
+ }
32
+ return body;
33
+ }
34
+ /** The Toolstile endpoints the CLI uses. Session calls carry the device-flow session token. */
35
+ export function createApi(server, fetcher = fetch) {
36
+ const base = server.replace(/\/+$/, "");
37
+ const session = (token, init = {}) => ({
38
+ ...init,
39
+ headers: { ...init.headers, Authorization: `Bearer ${token}` },
40
+ });
41
+ return {
42
+ deviceCode: () => request(fetcher, `${base}/api/auth/device/code`, {
43
+ method: "POST",
44
+ body: JSON.stringify({ client_id: CLIENT_ID }),
45
+ }),
46
+ deviceToken: (deviceCode) => request(fetcher, `${base}/api/auth/device/token`, {
47
+ method: "POST",
48
+ body: JSON.stringify({
49
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
50
+ device_code: deviceCode,
51
+ client_id: CLIENT_ID,
52
+ }),
53
+ }),
54
+ account: (token) => request(fetcher, `${base}/api/cli/account`, session(token)),
55
+ signOut: (token) => request(fetcher, `${base}/api/cli/sign-out`, session(token, { method: "POST" })),
56
+ device: (token, input) => request(fetcher, `${base}/api/cli/device`, session(token, { method: "POST", body: JSON.stringify(input) })),
57
+ agents: (token, workspaceId, deviceId) => request(fetcher, `${base}/api/cli/agents?${new URLSearchParams({ workspaceId, deviceId })}`, session(token)),
58
+ addAgent: (token, input) => request(fetcher, `${base}/api/cli/agents`, session(token, { method: "POST", body: JSON.stringify(input) })),
59
+ removeAgent: (token, workspaceId, id) => request(fetcher, `${base}/api/cli/agents/${encodeURIComponent(id)}?${new URLSearchParams({ workspaceId })}`, session(token, { method: "DELETE" })),
60
+ };
61
+ }
package/dist/checks.js CHANGED
@@ -202,8 +202,8 @@ function broadRoots(s, home) {
202
202
  .filter((a) => a === "/" || /^[A-Za-z]:\\?$/.test(a) || homes.has(a) || homes.has(a.replace(/\/+$/, "")));
203
203
  }
204
204
  const SEVERITY_ORDER = { high: 0, medium: 1, low: 2, info: 3 };
205
- /** Toolstile's own gateway is meant to be added to every client. */
206
- const TOOLSTILE = /^url:([a-z0-9-]+\.)*toolstile\.com\//i;
205
+ /** Toolstile's own gateway, directly or through `toolstile mcp proxy`, is meant to be added to every client. */
206
+ const TOOLSTILE = /^(url:([a-z0-9-]+\.)*toolstile\.com\/|npm:toolstile$)/i;
207
207
  /** A path for a shell command: `~/...` under the home folder, single-quoted when needed. */
208
208
  export function shellPath(path, home) {
209
209
  const quote = (p) => (/^[\w@%+=:,./-]+$/.test(p) ? p : `'${p.replace(/'/g, "'\\''")}'`);
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>;