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 +57 -3
- 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.js +2 -2
- package/dist/cli.js +137 -6
- package/dist/commands.d.ts +37 -0
- package/dist/commands.js +338 -0
- 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/sources.d.ts +1 -0
- package/dist/sources.js +1 -1
- package/dist/state.d.ts +48 -0
- package/dist/state.js +51 -0
- package/package.json +5 -3
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
|
-
##
|
|
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
|
|
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
|
package/dist/agents.d.ts
ADDED
|
@@ -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
|
|
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
|
|
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>;
|