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 CHANGED
@@ -6,8 +6,6 @@ Check the MCP server configs your AI agents use, in one command:
6
6
  npx toolstile audit
7
7
  ```
8
8
 
9
- The same package is also published as `@toolstile/cli` (`npx @toolstile/cli audit`).
10
-
11
9
  It reads the configs of Claude Code, Claude Desktop, Cursor, Codex, VS Code,
12
10
  Windsurf, Gemini CLI and Cline and grades them for:
13
11
 
@@ -40,13 +38,16 @@ HIGH Plaintext secret in github (Cursor)
40
38
  Fix: Reference an environment variable instead (for example ${API_TOKEN}) …
41
39
  ```
42
40
 
43
- ## It only reads
41
+ ## The audit only reads
44
42
 
45
43
  - Reads config files on your machine. Writes nothing unless you ask for a card.
46
- - 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.)
47
46
  - Never prints, stores or sends secret values. Findings name the file and key,
48
47
  for example `mcpServers.github.env.GITHUB_TOKEN`, and the kind of secret.
49
- - About 1,100 lines of TypeScript with one runtime dependency
48
+ - Never quotes a config file: one that cannot be read or parsed is listed with a
49
+ reason and position only, for example `invalid TOML at line 3, column 12`.
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.
@@ -66,6 +67,14 @@ HIGH Plaintext secret in github (Cursor)
66
67
  `--fail-on high` makes it usable in CI or a pre-commit hook for repositories that
67
68
  check in `.mcp.json`, `.cursor/mcp.json` or `.vscode/mcp.json`.
68
69
 
70
+ Exit codes:
71
+
72
+ | Code | Meaning |
73
+ | --- | --- |
74
+ | 0 | The audit ran, and no finding reached the `--fail-on` level (or `--fail-on` was not set) |
75
+ | 1 | A finding is at or above the `--fail-on` level |
76
+ | 2 | Invalid options, or the audit itself failed |
77
+
69
78
  ## Scoring
70
79
 
71
80
  Each distinct problem has a weight: 10 for a leaked secret, plain-http remote or
@@ -98,6 +107,59 @@ values) and prints a ready-to-post link. To post the text report instead, add
98
107
  | Gemini CLI | `~/.gemini/settings.json` | `.gemini/settings.json` |
99
108
  | Cline | `cline_mcp_settings.json` in VS Code or Cursor storage | |
100
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
+
101
163
  ## Use it from code
102
164
 
103
165
  ```ts
@@ -117,7 +179,7 @@ Questions and reports: support@toolstile.com.
117
179
 
118
180
  ## Releases
119
181
 
120
- Both npm packages are published by [GitHub Actions](.github/workflows/publish.yml)
182
+ The npm package is published by [GitHub Actions](.github/workflows/publish.yml)
121
183
  with npm trusted publishing and provenance, so each release links back to the
122
184
  commit and workflow that built it. No npm token is stored anywhere.
123
185
 
@@ -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.d.ts CHANGED
@@ -4,6 +4,9 @@ interface Package {
4
4
  registry: "npm" | "pypi" | "docker";
5
5
  name: string;
6
6
  version: string | null;
7
+ /** Index of the package in the unwrapped words. */
8
+ wordIndex: number;
9
+ /** Index of the package in `args`, or -1 when it is inside `command`. */
7
10
  argIndex: number;
8
11
  }
9
12
  /** Finds the package a launcher such as npx, uvx or docker runs. */
package/dist/checks.js CHANGED
@@ -41,20 +41,67 @@ const LAUNCHERS = {
41
41
  pnpx: "npm",
42
42
  uvx: "pypi",
43
43
  };
44
- /** Splits `command` and `args` into words, handling configs that put the whole command in one string. */
44
+ const base = (p) => win32.basename(posix.basename(p)).replace(/\.(cmd|exe)$/i, "");
45
+ const split = (text) => text.trim().split(/\s+/).filter(Boolean);
46
+ /** WSL options that take a value. */
47
+ const WSL_VALUED = new Set(["-d", "--distribution", "--distribution-id", "-u", "--user", "--cd", "--shell-type"]);
48
+ /** Index of the first word after a `cmd /c` or `wsl` wrapper at `start`, or `start` when there is none. */
49
+ function afterWrapper(list, start) {
50
+ const name = base(list[start] ?? "").toLowerCase();
51
+ let j = start + 1;
52
+ if (name === "cmd") {
53
+ // cmd [/d] [/s] [/q] … /c|/k command
54
+ while (j < list.length && /^\/[a-z]$/i.test(list[j]) && !/^\/[ck]$/i.test(list[j]))
55
+ j++;
56
+ return /^\/[ck]$/i.test(list[j] ?? "") ? j + 1 : start;
57
+ }
58
+ if (name === "wsl") {
59
+ while (j < list.length) {
60
+ const a = list[j];
61
+ if (WSL_VALUED.has(a))
62
+ j += 2;
63
+ else if (a === "-e" || a === "--exec" || a === "--")
64
+ return j + 1;
65
+ else if (a.startsWith("-"))
66
+ j++;
67
+ else
68
+ break;
69
+ }
70
+ return j;
71
+ }
72
+ return start;
73
+ }
74
+ /**
75
+ * Splits `command` and `args` into words, handling configs that put the whole command in one string,
76
+ * and skips Windows wrappers (`cmd /c npx …`, `wsl npx …`) so the real launcher comes first.
77
+ */
45
78
  function words(s) {
46
79
  if (!s.command)
47
- return [];
48
- const command = s.args.length === 0 && /\s/.test(s.command.trim()) ? s.command.trim().split(/\s+/) : [s.command];
49
- return [...command, ...s.args];
80
+ return { list: [], arg: [] };
81
+ const oneString = s.args.length === 0 && /\s/.test(s.command.trim());
82
+ let list = oneString ? split(s.command) : [s.command, ...s.args];
83
+ let arg = oneString ? list.map(() => -1) : list.map((_, i) => i - 1);
84
+ for (;;) {
85
+ const start = afterWrapper(list, 0);
86
+ if (start === 0 || start >= list.length)
87
+ break;
88
+ list = list.slice(start);
89
+ arg = arg.slice(start);
90
+ // `cmd /c "npx -y pkg"` passes the inner command as one argument.
91
+ if (list.length === 1 && /\s/.test(list[0].trim())) {
92
+ const from = arg[0];
93
+ list = split(list[0]);
94
+ arg = list.map(() => from);
95
+ }
96
+ }
97
+ return { list, arg };
50
98
  }
51
- const base = (p) => win32.basename(posix.basename(p)).replace(/\.(cmd|exe)$/i, "");
52
99
  /** Finds the package a launcher such as npx, uvx or docker runs. */
53
100
  export function packageOf(s) {
54
- const w = words(s);
101
+ const { list: w, arg } = words(s);
55
102
  if (w.length === 0)
56
103
  return null;
57
- const cmd = base(w[0]);
104
+ const cmd = base(w[0]).toLowerCase();
58
105
  const sub = w.slice(1);
59
106
  let registry = LAUNCHERS[cmd];
60
107
  let skip = 0;
@@ -74,6 +121,7 @@ export function packageOf(s) {
74
121
  return null;
75
122
  const rest = sub.slice(skip);
76
123
  const offset = 1 + skip;
124
+ const at = (spec, i) => parsePackage(registry, spec, i, arg[i] ?? -1);
77
125
  // Flags that take a value; their value is not the package.
78
126
  const valued = new Set(["-p", "--package", "--from", "--with", "-e", "--env", "-v", "--volume", "--name", "--network", "--mount", "--env-file", "-w", "--workdir", "--entrypoint", "-u", "--user", "--python"]);
79
127
  for (let i = 0; i < rest.length; i++) {
@@ -83,41 +131,42 @@ export function packageOf(s) {
83
131
  if (a.startsWith("-")) {
84
132
  if (valued.has(a)) {
85
133
  if ((a === "--package" || a === "-p" || a === "--from") && registry !== "docker") {
86
- return parsePackage(registry, rest[i + 1] ?? "", offset + i + 1);
134
+ return at(rest[i + 1] ?? "", offset + i + 1);
87
135
  }
88
136
  i++;
89
137
  }
90
138
  continue;
91
139
  }
92
- return parsePackage(registry, a, offset + i);
140
+ return at(a, offset + i);
93
141
  }
94
142
  return null;
95
143
  }
96
- function parsePackage(registry, spec, argIndex) {
144
+ function parsePackage(registry, spec, wordIndex, argIndex) {
97
145
  if (!spec)
98
146
  return null;
147
+ const where = { wordIndex, argIndex };
99
148
  if (registry === "docker") {
100
149
  const at = spec.indexOf("@sha256:");
101
150
  if (at > 0)
102
- return { registry, name: spec.slice(0, at), version: spec.slice(at + 1), argIndex };
151
+ return { registry, name: spec.slice(0, at), version: spec.slice(at + 1), ...where };
103
152
  const slash = spec.lastIndexOf("/");
104
153
  const colon = spec.lastIndexOf(":");
105
154
  return colon > slash
106
- ? { registry, name: spec.slice(0, colon), version: spec.slice(colon + 1), argIndex }
107
- : { registry, name: spec, version: null, argIndex };
155
+ ? { registry, name: spec.slice(0, colon), version: spec.slice(colon + 1), ...where }
156
+ : { registry, name: spec, version: null, ...where };
108
157
  }
109
158
  if (registry === "pypi") {
110
159
  const m = /^([A-Za-z0-9._\-[\],]+?)(?:(==|@|>=|~=)(.+))?$/.exec(spec);
111
160
  if (!m)
112
161
  return null;
113
- return { registry, name: m[1], version: m[2] === "==" || m[2] === "@" ? m[3] : null, argIndex };
162
+ return { registry, name: m[1], version: m[2] === "==" || m[2] === "@" ? m[3] : null, ...where };
114
163
  }
115
164
  if (/^(https?:|git\+|file:|\.|\/)/.test(spec))
116
165
  return null;
117
166
  const at = spec.indexOf("@", spec.startsWith("@") ? 1 : 0);
118
167
  return at > 0
119
- ? { registry, name: spec.slice(0, at), version: spec.slice(at + 1), argIndex }
120
- : { registry, name: spec, version: null, argIndex };
168
+ ? { registry, name: spec.slice(0, at), version: spec.slice(at + 1), ...where }
169
+ : { registry, name: spec, version: null, ...where };
121
170
  }
122
171
  const isPinned = (p) => p.version !== null &&
123
172
  p.version !== "latest" &&
@@ -138,7 +187,7 @@ export function identityOf(s) {
138
187
  const pkg = packageOf(s);
139
188
  if (pkg)
140
189
  return `${pkg.registry}:${pkg.name.toLowerCase()}`;
141
- const w = words(s);
190
+ const w = words(s).list;
142
191
  return w.length ? `cmd:${w.join(" ")}` : null;
143
192
  }
144
193
  const LOOPBACK = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|0\.0\.0\.0)$/i;
@@ -149,12 +198,12 @@ function broadRoots(s, home) {
149
198
  return [];
150
199
  const homes = new Set([home, home.replace(/\/+$/, ""), "~", "~/", "$HOME", "${HOME}", "%USERPROFILE%"]);
151
200
  return words(s)
152
- .slice(pkg.argIndex + 1)
201
+ .list.slice(pkg.wordIndex + 1)
153
202
  .filter((a) => a === "/" || /^[A-Za-z]:\\?$/.test(a) || homes.has(a) || homes.has(a.replace(/\/+$/, "")));
154
203
  }
155
204
  const SEVERITY_ORDER = { high: 0, medium: 1, low: 2, info: 3 };
156
- /** Toolstile's own gateway is meant to be added to every client. */
157
- 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;
158
207
  /** A path for a shell command: `~/...` under the home folder, single-quoted when needed. */
159
208
  export function shellPath(path, home) {
160
209
  const quote = (p) => (/^[\w@%+=:,./-]+$/.test(p) ? p : `'${p.replace(/'/g, "'\\''")}'`);
@@ -250,11 +299,11 @@ export function runChecks(d, home) {
250
299
  fix: pkg.registry === "docker"
251
300
  ? `Pin an image digest or exact tag, for example ${pkg.name}:<version>.`
252
301
  : `Pin an exact version, for example ${pkg.name}${pkg.registry === "pypi" ? "==" : "@"}<version>.`,
253
- locations: [at(s, "args", String(Math.max(0, pkg.argIndex - 1)))],
302
+ locations: [pkg.argIndex >= 0 ? at(s, "args", String(pkg.argIndex)) : at(s, "command")],
254
303
  weight: 4,
255
304
  });
256
305
  }
257
- const w = words(s);
306
+ const w = words(s).list;
258
307
  if (pkg?.registry === "docker") {
259
308
  const risky = w.filter((a) => a === "--privileged" || /\/var\/run\/docker\.sock/.test(a) || a === "--net=host" || a === "--network=host");
260
309
  if (risky.length) {