toolstile 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/parse.js CHANGED
@@ -1,5 +1,24 @@
1
- import { parse as parseToml } from "smol-toml";
2
- /** Parses JSON that may contain comments and trailing commas (VS Code style). */
1
+ import { parse as parseToml, TomlError } from "smol-toml";
2
+ /**
3
+ * A config file that could not be parsed. The message is fixed text plus a position: parser
4
+ * messages quote the offending source line, which can hold a secret, so they are never kept.
5
+ */
6
+ export class ConfigParseError extends Error {
7
+ format;
8
+ line;
9
+ column;
10
+ constructor(format, line, column) {
11
+ super(`invalid ${format}${line ? ` at line ${line}${column ? `, column ${column}` : ""}` : ""}`);
12
+ this.format = format;
13
+ this.line = line;
14
+ this.column = column;
15
+ this.name = "ConfigParseError";
16
+ }
17
+ }
18
+ /**
19
+ * Parses JSON that may contain comments and trailing commas (VS Code style). Comments and
20
+ * trailing commas become spaces so parser positions still match the original text.
21
+ */
3
22
  export function parseJsonc(text) {
4
23
  let out = "";
5
24
  let inString = false;
@@ -22,15 +41,19 @@ export function parseJsonc(text) {
22
41
  out += ch;
23
42
  }
24
43
  else if (ch === "/" && next === "/") {
25
- while (i < text.length && text[i] !== "\n")
44
+ while (i < text.length && text[i] !== "\n") {
45
+ out += blank(text[i]);
26
46
  i++;
27
- out += "\n";
47
+ }
48
+ if (i < text.length)
49
+ out += "\n";
28
50
  }
29
51
  else if (ch === "/" && next === "*") {
30
- i += 2;
31
- while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
32
- i++;
33
- i++;
52
+ const end = text.indexOf("*/", i + 2);
53
+ const stop = end === -1 ? text.length : end + 2;
54
+ for (; i < stop; i++)
55
+ out += blank(text[i]);
56
+ i--;
34
57
  }
35
58
  else {
36
59
  out += ch;
@@ -38,6 +61,8 @@ export function parseJsonc(text) {
38
61
  }
39
62
  return JSON.parse(stripTrailingCommas(out));
40
63
  }
64
+ /** Keeps line breaks so line numbers survive; everything else becomes a space. */
65
+ const blank = (ch) => (ch === "\n" || ch === "\r" ? ch : " ");
41
66
  function stripTrailingCommas(text) {
42
67
  let out = "";
43
68
  let inString = false;
@@ -60,17 +85,51 @@ function stripTrailingCommas(text) {
60
85
  let j = i + 1;
61
86
  while (j < text.length && /\s/.test(text[j]))
62
87
  j++;
63
- if (text[j] === "}" || text[j] === "]")
88
+ if (text[j] === "}" || text[j] === "]") {
89
+ out += " ";
64
90
  continue;
91
+ }
65
92
  }
66
93
  out += ch;
67
94
  }
68
95
  return out;
69
96
  }
97
+ /** 1-based line and column of a character offset. */
98
+ function lineColumn(text, offset) {
99
+ const before = text.slice(0, Math.min(Math.max(offset, 0), text.length));
100
+ const lastBreak = before.lastIndexOf("\n");
101
+ return { line: before.split("\n").length, column: offset - lastBreak };
102
+ }
103
+ /** Reads only the position from a V8 JSON error; the rest of its message may quote the file. */
104
+ function jsonPosition(error, text) {
105
+ const message = error instanceof Error ? error.message : "";
106
+ const at = /\bposition (\d+)/.exec(message);
107
+ if (at)
108
+ return lineColumn(text, Number(at[1]));
109
+ if (/Unexpected end of JSON input/.test(message))
110
+ return lineColumn(text, text.length);
111
+ return {};
112
+ }
113
+ /** Parses a config file. Throws only {@link ConfigParseError}, whose message never contains file text. */
70
114
  export function parseConfig(path, text) {
71
- if (path.endsWith(".toml"))
72
- return parseToml(text);
73
- if (text.trim() === "")
115
+ const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
116
+ if (path.endsWith(".toml")) {
117
+ try {
118
+ return parseToml(body);
119
+ }
120
+ catch (error) {
121
+ if (error instanceof TomlError)
122
+ throw new ConfigParseError("TOML", error.line, error.column);
123
+ throw new ConfigParseError("TOML");
124
+ }
125
+ }
126
+ if (body.trim() === "")
74
127
  return {};
75
- return parseJsonc(text);
128
+ try {
129
+ return parseJsonc(body);
130
+ }
131
+ catch (error) {
132
+ const { line, column } = jsonPosition(error, body);
133
+ throw new ConfigParseError("JSON", line, column);
134
+ }
76
135
  }
@@ -0,0 +1,10 @@
1
+ import type { ReadStream, WriteStream } from "node:tty";
2
+ export interface Choice {
3
+ label: string;
4
+ hint?: string;
5
+ checked: boolean;
6
+ }
7
+ /** Terminal checklist: arrows (or j/k) move, space toggles, a toggles all, enter saves. */
8
+ export declare function checklist(title: string, choices: Choice[], input: ReadStream, output: WriteStream, options?: {
9
+ single?: boolean;
10
+ }): Promise<boolean[] | null>;
package/dist/prompt.js ADDED
@@ -0,0 +1,54 @@
1
+ import { emitKeypressEvents } from "node:readline";
2
+ /** Terminal checklist: arrows (or j/k) move, space toggles, a toggles all, enter saves. */
3
+ export function checklist(title, choices, input, output, options = {}) {
4
+ const state = choices.map((c) => c.checked);
5
+ let cursor = Math.max(0, options.single ? state.indexOf(true) : 0);
6
+ let drawn = 0;
7
+ const help = options.single ? "↑/↓ move · enter select · esc cancel" : "↑/↓ move · space toggle · a all · enter save · esc cancel";
8
+ const render = () => {
9
+ if (drawn)
10
+ output.write(`\x1b[${drawn}A\x1b[0J`);
11
+ const lines = [
12
+ title,
13
+ ...choices.map((c, i) => {
14
+ const mark = options.single ? (i === cursor ? "●" : "○") : state[i] ? "◉" : "○";
15
+ const pointer = i === cursor ? "›" : " ";
16
+ return `${pointer} ${mark} ${c.label}${c.hint ? ` \x1b[2m${c.hint}\x1b[22m` : ""}`;
17
+ }),
18
+ `\x1b[2m${help}\x1b[22m`,
19
+ ];
20
+ output.write(`${lines.join("\n")}\n`);
21
+ drawn = lines.length;
22
+ };
23
+ return new Promise((resolve) => {
24
+ emitKeypressEvents(input);
25
+ const raw = input.isRaw;
26
+ input.setRawMode(true);
27
+ input.resume();
28
+ output.write("\x1b[?25l");
29
+ const finish = (value) => {
30
+ input.off("keypress", onKey);
31
+ input.setRawMode(raw);
32
+ input.pause();
33
+ output.write("\x1b[?25h");
34
+ resolve(value);
35
+ };
36
+ const onKey = (text, key = {}) => {
37
+ if ((key.ctrl && key.name === "c") || key.name === "escape")
38
+ return finish(null);
39
+ if (key.name === "up" || text === "k")
40
+ cursor = (cursor - 1 + choices.length) % choices.length;
41
+ else if (key.name === "down" || text === "j")
42
+ cursor = (cursor + 1) % choices.length;
43
+ else if (!options.single && (key.name === "space" || text === " "))
44
+ state[cursor] = !state[cursor];
45
+ else if (!options.single && text === "a")
46
+ state.fill(!state.every(Boolean));
47
+ else if (key.name === "return" || key.name === "enter")
48
+ return finish(options.single ? choices.map((_, i) => i === cursor) : state);
49
+ render();
50
+ };
51
+ input.on("keypress", onKey);
52
+ render();
53
+ });
54
+ }
@@ -0,0 +1,15 @@
1
+ import type { Readable, Writable } from "node:stream";
2
+ /**
3
+ * Bridges an agent's stdio MCP connection to the Toolstile gateway's Streamable HTTP endpoint,
4
+ * so the agent's config holds a command instead of a credential. Messages pass through
5
+ * unchanged; only the transport differs.
6
+ */
7
+ export interface ProxyOptions {
8
+ endpoint: string;
9
+ token: string;
10
+ input: Readable;
11
+ output: Writable;
12
+ log: (message: string) => void;
13
+ fetch?: typeof fetch;
14
+ }
15
+ export declare function runProxy(options: ProxyOptions): Promise<void>;
package/dist/proxy.js ADDED
@@ -0,0 +1,235 @@
1
+ import { createInterface } from "node:readline";
2
+ /** Yields each SSE event's data and id. */
3
+ async function* events(body) {
4
+ const decoder = new TextDecoder();
5
+ let buffer = "";
6
+ let data = [];
7
+ let id;
8
+ for await (const chunk of body) {
9
+ buffer += decoder.decode(chunk, { stream: true });
10
+ let newline;
11
+ while ((newline = buffer.search(/\r?\n/)) !== -1) {
12
+ const line = buffer.slice(0, newline);
13
+ buffer = buffer.slice(buffer[newline] === "\r" ? newline + 2 : newline + 1);
14
+ if (line === "") {
15
+ if (data.length)
16
+ yield { data: data.join("\n"), id };
17
+ data = [];
18
+ continue;
19
+ }
20
+ if (line.startsWith(":"))
21
+ continue;
22
+ const colon = line.indexOf(":");
23
+ const field = colon === -1 ? line : line.slice(0, colon);
24
+ const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
25
+ if (field === "data")
26
+ data.push(value);
27
+ else if (field === "id")
28
+ id = value;
29
+ }
30
+ }
31
+ if (data.length)
32
+ yield { data: data.join("\n"), id };
33
+ }
34
+ /** How long in-flight requests may finish after the agent closes stdin. */
35
+ const CLOSE_GRACE_MS = 3000;
36
+ export function runProxy(options) {
37
+ const fetcher = options.fetch ?? fetch;
38
+ let sessionId;
39
+ let protocolVersion;
40
+ /** The agent's own `initialize`, replayed if the gateway forgets the session. */
41
+ let opening;
42
+ let listening = false;
43
+ let closed = false;
44
+ /** Aborts every request and the listener's waits once the agent has gone. */
45
+ const shutdown = new AbortController();
46
+ const pending = new Set();
47
+ /** Later messages wait for `initialize`, so they carry the session it opens. */
48
+ let initialized = Promise.resolve();
49
+ const write = (message) => {
50
+ if (!options.output.writableEnded)
51
+ options.output.write(`${JSON.stringify(message)}\n`);
52
+ };
53
+ const headers = (extra = {}) => ({
54
+ Authorization: `Bearer ${options.token}`,
55
+ ...(sessionId ? { "Mcp-Session-Id": sessionId } : {}),
56
+ ...(protocolVersion ? { "Mcp-Protocol-Version": protocolVersion } : {}),
57
+ ...extra,
58
+ });
59
+ /** Never follow a redirect: the token is only for the gateway endpoint. */
60
+ const request = (init) => fetcher(options.endpoint, { ...init, redirect: "error", signal: shutdown.signal });
61
+ const post = (body) => request({
62
+ method: "POST",
63
+ headers: headers({ "Content-Type": "application/json", Accept: "application/json, text/event-stream" }),
64
+ body,
65
+ });
66
+ const explain = (status, text) => status === 401
67
+ ? "Toolstile no longer accepts this agent's access (removed from the dashboard?). Run `toolstile mcp setup` to enable it again."
68
+ : status === 403
69
+ ? `Toolstile refused this agent: ${text || "access denied"}`
70
+ : status === 429
71
+ ? "Toolstile is rate limiting this agent; try again shortly."
72
+ : `Toolstile returned ${status}${text ? `: ${text}` : ""}`;
73
+ const deliver = (message, answered) => {
74
+ if (message.id !== undefined && message.result && typeof message.result === "object") {
75
+ const version = message.result.protocolVersion;
76
+ if (typeof version === "string")
77
+ protocolVersion = version;
78
+ }
79
+ if (message.method === undefined && message.id !== undefined)
80
+ answered?.add(message.id);
81
+ write(message);
82
+ };
83
+ async function readBody(response, answered, quiet = false) {
84
+ const type = response.headers.get("content-type") ?? "";
85
+ const take = (message) => (quiet ? answered.add(message.id) : deliver(message, answered));
86
+ if (!response.body)
87
+ return;
88
+ if (type.includes("text/event-stream")) {
89
+ for await (const event of events(response.body)) {
90
+ try {
91
+ take(JSON.parse(event.data));
92
+ }
93
+ catch {
94
+ options.log("Ignored a malformed message from Toolstile.");
95
+ }
96
+ }
97
+ return;
98
+ }
99
+ if (type.includes("application/json")) {
100
+ const body = (await response.json());
101
+ for (const message of Array.isArray(body) ? body : [body])
102
+ take(message);
103
+ }
104
+ }
105
+ const wait = (ms) => new Promise((resolve) => {
106
+ const timer = setTimeout(resolve, ms);
107
+ shutdown.signal.addEventListener("abort", () => (clearTimeout(timer), resolve()), { once: true });
108
+ });
109
+ /** Server-initiated messages (list changes, approvals) arrive on a long-lived GET stream. */
110
+ async function listen() {
111
+ if (listening || !sessionId)
112
+ return;
113
+ listening = true;
114
+ let lastEventId;
115
+ for (let attempt = 0; !closed && attempt < 8; attempt++) {
116
+ try {
117
+ const response = await request({
118
+ method: "GET",
119
+ headers: headers({ Accept: "text/event-stream", ...(lastEventId ? { "Last-Event-ID": lastEventId } : {}) }),
120
+ });
121
+ // 405: this server offers no stream; that is allowed.
122
+ if (response.status === 405 || !response.ok || !response.body)
123
+ break;
124
+ attempt = 0;
125
+ for await (const event of events(response.body)) {
126
+ if (event.id)
127
+ lastEventId = event.id;
128
+ try {
129
+ deliver(JSON.parse(event.data));
130
+ }
131
+ catch { }
132
+ }
133
+ }
134
+ catch {
135
+ if (closed)
136
+ break;
137
+ }
138
+ await wait(Math.min(30_000, 1000 * 2 ** attempt));
139
+ }
140
+ listening = false;
141
+ }
142
+ /**
143
+ * The gateway answers 404 for a session it no longer has (for example after a restart).
144
+ * Open a new one with the agent's original `initialize`, without showing its reply again.
145
+ */
146
+ async function reopen() {
147
+ if (!opening)
148
+ return false;
149
+ sessionId = undefined;
150
+ const response = await post(opening);
151
+ sessionId = response.headers.get("mcp-session-id") ?? undefined;
152
+ if (!response.ok)
153
+ return false;
154
+ await readBody(response, new Set(), true);
155
+ await post(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })).then((r) => r.body?.cancel());
156
+ void listen();
157
+ return true;
158
+ }
159
+ async function send(line) {
160
+ let messages;
161
+ try {
162
+ const parsed = JSON.parse(line);
163
+ messages = Array.isArray(parsed) ? parsed : [parsed];
164
+ }
165
+ catch {
166
+ write({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
167
+ return;
168
+ }
169
+ const requests = messages.filter((m) => m.method !== undefined && m.id !== undefined);
170
+ const answered = new Set();
171
+ const fail = (message) => {
172
+ for (const request of requests)
173
+ if (!answered.has(request.id))
174
+ write({ jsonrpc: "2.0", id: request.id, error: { code: -32000, message } });
175
+ };
176
+ if (messages.some((m) => m.method === "initialize"))
177
+ opening = line;
178
+ try {
179
+ let response = await post(line);
180
+ if (response.status === 404 && sessionId && !messages.some((m) => m.method === "initialize")) {
181
+ await response.body?.cancel();
182
+ if (await reopen())
183
+ response = await post(line);
184
+ }
185
+ const session = response.headers.get("mcp-session-id");
186
+ if (session)
187
+ sessionId = session;
188
+ if (!response.ok) {
189
+ const text = (await response.text().catch(() => "")).trim().slice(0, 300);
190
+ const message = explain(response.status, text);
191
+ options.log(message);
192
+ fail(message);
193
+ return;
194
+ }
195
+ await readBody(response, answered);
196
+ if (messages.some((m) => m.method === "notifications/initialized"))
197
+ void listen();
198
+ // A stream that ended early must still answer every request, or the agent waits forever.
199
+ fail("Toolstile closed the connection before answering. Try again.");
200
+ }
201
+ catch (error) {
202
+ if (closed)
203
+ return fail("The agent closed the connection.");
204
+ const message = `Could not reach Toolstile: ${error.message}`;
205
+ options.log(message);
206
+ fail(message);
207
+ }
208
+ }
209
+ return new Promise((resolve) => {
210
+ const lines = createInterface({ input: options.input, crlfDelay: Number.POSITIVE_INFINITY });
211
+ lines.on("line", (line) => {
212
+ if (!line.trim())
213
+ return;
214
+ const opens = /"method"\s*:\s*"initialize"/.test(line);
215
+ const task = (opens ? send(line) : initialized.then(() => send(line))).finally(() => pending.delete(task));
216
+ if (opens)
217
+ initialized = task;
218
+ pending.add(task);
219
+ });
220
+ lines.on("close", async () => {
221
+ // Let replies already on their way finish, but never hang on a long call or stream.
222
+ const grace = setTimeout(() => {
223
+ closed = true;
224
+ shutdown.abort();
225
+ }, CLOSE_GRACE_MS);
226
+ await Promise.allSettled([...pending]);
227
+ clearTimeout(grace);
228
+ closed = true;
229
+ shutdown.abort();
230
+ if (sessionId)
231
+ await fetcher(options.endpoint, { method: "DELETE", headers: headers(), redirect: "error" }).catch(() => undefined);
232
+ resolve();
233
+ });
234
+ });
235
+ }
package/dist/report.js CHANGED
@@ -60,7 +60,7 @@ export function renderReport(r, home, s, redacted = false) {
60
60
  const clientList = r.clients.map((c) => CLIENT_NAMES[c]).join(", ");
61
61
  lines.push(`Scanned ${plural(r.files.length, "file")} from ${clientList}: ${plural(r.servers, "MCP server")}.`);
62
62
  for (const u of r.unreadable) {
63
- lines.push(s.dim(`Skipped ${tildify(u.path, home)} (${CLIENT_NAMES[u.client]}): could not parse it.`));
63
+ lines.push(s.dim(`Skipped ${tildify(u.path, home)} (${CLIENT_NAMES[u.client]}): ${u.error}.`));
64
64
  }
65
65
  lines.push("");
66
66
  if (r.grade !== null) {
package/dist/secrets.js CHANGED
@@ -28,9 +28,9 @@ const PATTERNS = [
28
28
  [/\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, "JWT"],
29
29
  [/\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s@/]{3,}@/i, "password in connection string"],
30
30
  ];
31
- const SECRET_KEY = /(token|secret|passw(or)?d|passwd|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|credential|auth|bearer|session|cookie|\bpat\b|_pat$|^pat_)/i;
32
- /** Key suffixes that name configuration about a secret rather than the secret itself. */
33
- const NOT_SECRET_KEY = /(url|uri|endpoint|host|port|path|file|dir|type|mode|enabled|header|name|user(name)?|id|region|scope|scopes|method|env_var|_var)$/i;
31
+ const SECRET_KEY = /(token|secret|passw(or)?d|passwd|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|credential|auth|bearer|session|cookie|\bpat\b|_pat$|^pat_|(^|[_-])key$)/i;
32
+ /** Key suffixes that name configuration about a secret, or a public half, rather than the secret itself. */
33
+ const NOT_SECRET_KEY = /(url|uri|endpoint|host|port|path|file|dir|type|mode|enabled|header|name|user(name)?|id|region|scope|scopes|method|env_var|_var|public[_-]?key)$/i;
34
34
  const PLACEHOLDER = /^(<.*>|\{.*\}|\[.*\]|x{4,}|\*{3,}|\.{3,}|your[-_ ].*|change[-_ ]?me|todo|placeholder|example|redacted|none|null|undefined|true|false|test|secret|token)$/i;
35
35
  /** True for `${VAR}`, `${env:VAR}`, `$VAR`, `%VAR%` and `{env:VAR}` references. */
36
36
  export function isReference(value) {
package/dist/sources.d.ts CHANGED
@@ -7,6 +7,7 @@ export interface ScanOptions {
7
7
  env: Record<string, string | undefined>;
8
8
  }
9
9
  export declare const CLIENT_NAMES: Record<ClientId, string>;
10
+ export declare function appData({ home, platform, env }: Omit<ScanOptions, "cwd">): string;
10
11
  /** Appends a key to a pointer, quoting keys that are not plain identifiers. */
11
12
  export declare function pointerJoin(base: string, key: string): string;
12
13
  /**
package/dist/sources.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
- import { parseConfig } from "./parse.js";
3
+ import { ConfigParseError, parseConfig } from "./parse.js";
4
4
  export const CLIENT_NAMES = {
5
5
  "claude-code": "Claude Code",
6
6
  "claude-desktop": "Claude Desktop",
@@ -11,7 +11,7 @@ export const CLIENT_NAMES = {
11
11
  gemini: "Gemini CLI",
12
12
  cline: "Cline",
13
13
  };
14
- function appData({ home, platform, env }) {
14
+ export function appData({ home, platform, env }) {
15
15
  if (platform === "darwin")
16
16
  return join(home, "Library", "Application Support");
17
17
  if (platform === "win32")
@@ -149,6 +149,14 @@ function ancestorModes(path) {
149
149
  dir = parent;
150
150
  }
151
151
  }
152
+ /** A fixed description of a read failure; system messages are not passed through. */
153
+ function readError(code) {
154
+ if (code === "EACCES" || code === "EPERM")
155
+ return "permission denied";
156
+ if (code === "EISDIR")
157
+ return "is a directory";
158
+ return code && /^E[A-Z]+$/.test(code) ? `could not read it (${code})` : "could not read it";
159
+ }
152
160
  export function discover(opts) {
153
161
  const result = { files: [], unreadable: [], servers: [], approvals: [] };
154
162
  for (const c of candidates(opts)) {
@@ -161,7 +169,12 @@ export function discover(opts) {
161
169
  mode = opts.platform === "win32" ? undefined : stat.mode & 0o777;
162
170
  text = readFileSync(c.path, "utf8");
163
171
  }
164
- catch {
172
+ catch (error) {
173
+ const code = error.code;
174
+ // Absent files are normal; anything else means a config exists that we could not check.
175
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
176
+ result.unreadable.push({ client: c.client, path: c.path, error: readError(code) });
177
+ }
165
178
  continue;
166
179
  }
167
180
  let data;
@@ -169,7 +182,9 @@ export function discover(opts) {
169
182
  data = parseConfig(c.path, text);
170
183
  }
171
184
  catch (error) {
172
- result.unreadable.push({ client: c.client, path: c.path, error: error.message });
185
+ // parseConfig's messages are fixed text plus a position, never file contents.
186
+ const message = error instanceof ConfigParseError ? error.message : "could not parse it";
187
+ result.unreadable.push({ client: c.client, path: c.path, error: message });
173
188
  continue;
174
189
  }
175
190
  const exposure = mode === undefined ? undefined : exposureOf(mode, ancestorModes(c.path));
@@ -0,0 +1,48 @@
1
+ import type { ClientId } from "./types.js";
2
+ export declare const DEFAULT_SERVER = "https://toolstile.com";
3
+ export interface AgentLink {
4
+ /** The agent's authorization ID in Toolstile, valid in one workspace. */
5
+ id: string;
6
+ workspaceId: string;
7
+ /** The Toolstile origin that issued the token; the proxy sends it nowhere else. */
8
+ server: string;
9
+ label: string;
10
+ /** The agent's own gateway token; only `toolstile mcp proxy` reads it. */
11
+ token: string;
12
+ }
13
+ /** Everything `auth` and `mcp` keep on this machine, in one owner-only file. */
14
+ export interface State {
15
+ /** The origin `auth login` last signed in to. */
16
+ server: string;
17
+ session?: {
18
+ token: string;
19
+ email: string;
20
+ expiresAt: string;
21
+ server: string;
22
+ };
23
+ workspace?: {
24
+ id: string;
25
+ slug: string;
26
+ name: string;
27
+ };
28
+ device?: {
29
+ id: string;
30
+ label: string;
31
+ };
32
+ agents: Partial<Record<ClientId, AgentLink>>;
33
+ }
34
+ export interface StateEnv {
35
+ home?: string;
36
+ env?: Record<string, string | undefined>;
37
+ platform?: NodeJS.Platform;
38
+ }
39
+ /** `TOOLSTILE_CONFIG_DIR`, else the platform's per-user config folder. */
40
+ export declare function stateDir({ home, env, platform }?: StateEnv): string;
41
+ export declare const statePath: (opts?: StateEnv) => string;
42
+ /**
43
+ * The saved server always wins over `TOOLSTILE_URL`, which only picks the server for a new
44
+ * `auth login`: an environment variable set by a project must not redirect saved tokens.
45
+ */
46
+ export declare function loadState(opts?: StateEnv): State;
47
+ /** Written to a temporary file and renamed, so a crash never leaves half a file. */
48
+ export declare function saveState(state: State, opts?: StateEnv): void;
package/dist/state.js ADDED
@@ -0,0 +1,51 @@
1
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ export const DEFAULT_SERVER = "https://toolstile.com";
5
+ /** `TOOLSTILE_CONFIG_DIR`, else the platform's per-user config folder. */
6
+ export function stateDir({ home = homedir(), env = process.env, platform = process.platform } = {}) {
7
+ if (env.TOOLSTILE_CONFIG_DIR)
8
+ return env.TOOLSTILE_CONFIG_DIR;
9
+ if (platform === "win32")
10
+ return join(env.APPDATA ?? join(home, "AppData", "Roaming"), "toolstile");
11
+ return join(env.XDG_CONFIG_HOME ?? join(home, ".config"), "toolstile");
12
+ }
13
+ export const statePath = (opts) => join(stateDir(opts), "credentials.json");
14
+ /**
15
+ * The saved server always wins over `TOOLSTILE_URL`, which only picks the server for a new
16
+ * `auth login`: an environment variable set by a project must not redirect saved tokens.
17
+ */
18
+ export function loadState(opts) {
19
+ const fallback = { server: DEFAULT_SERVER, agents: {} };
20
+ let text;
21
+ try {
22
+ text = readFileSync(statePath(opts), "utf8");
23
+ }
24
+ catch (error) {
25
+ if (error.code === "ENOENT")
26
+ return fallback;
27
+ throw error;
28
+ }
29
+ try {
30
+ const data = JSON.parse(text);
31
+ return { ...fallback, ...data, agents: data.agents ?? {} };
32
+ }
33
+ catch {
34
+ throw new Error(`${statePath(opts)} is not valid JSON. Delete it and run toolstile auth login again.`);
35
+ }
36
+ }
37
+ /** Written to a temporary file and renamed, so a crash never leaves half a file. */
38
+ export function saveState(state, opts) {
39
+ const dir = stateDir(opts);
40
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
41
+ const path = statePath(opts);
42
+ const temp = `${path}.${process.pid}.tmp`;
43
+ try {
44
+ writeFileSync(temp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
45
+ chmodSync(temp, 0o600);
46
+ renameSync(temp, path);
47
+ }
48
+ finally {
49
+ rmSync(temp, { force: true });
50
+ }
51
+ }
package/dist/types.d.ts CHANGED
@@ -55,7 +55,10 @@ export interface AuditResult {
55
55
  client: ClientId;
56
56
  path: string;
57
57
  }[];
58
- /** Files that exist but could not be parsed. */
58
+ /**
59
+ * Files that exist but could not be read or parsed. `error` is a fixed description with at most
60
+ * a line and column, for example `invalid TOML at line 3, column 12`; it never quotes the file.
61
+ */
59
62
  unreadable: {
60
63
  client: ClientId;
61
64
  path: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toolstile",
3
- "version": "0.1.1",
4
- "description": "Audit the MCP server configs your AI agents use for plaintext secrets, unpinned packages, auto-approved tools and duplicated setups. Local and read-only.",
3
+ "version": "0.2.0",
4
+ "description": "Audit the MCP server configs your AI agents use for plaintext secrets, unpinned packages, auto-approved tools and duplicated setups, and connect those agents to Toolstile without putting tokens in their configs.",
5
5
  "keywords": [
6
6
  "mcp",
7
7
  "model-context-protocol",
@@ -10,7 +10,9 @@
10
10
  "claude",
11
11
  "cursor",
12
12
  "codex",
13
- "secrets"
13
+ "secrets",
14
+ "mcp-proxy",
15
+ "device-login"
14
16
  ],
15
17
  "homepage": "https://github.com/toolstile/cli#readme",
16
18
  "repository": {
@@ -37,8 +39,8 @@
37
39
  "scripts": {
38
40
  "build": "rm -rf dist && tsc -p tsconfig.build.json && chmod +x dist/cli.js",
39
41
  "typecheck": "tsc --noEmit",
40
- "test": "vitest run",
41
- "prepublishOnly": "pnpm typecheck && pnpm build && pnpm test"
42
+ "test": "pnpm build && vitest run",
43
+ "prepublishOnly": "pnpm typecheck && pnpm test"
42
44
  },
43
45
  "dependencies": {
44
46
  "smol-toml": "^1.9.1"