@thenavidm/slipway 0.1.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +757 -0
  5. package/SECURITY.md +33 -0
  6. package/SKILL.md +136 -0
  7. package/dist/app.d.ts +210 -0
  8. package/dist/app.js +364 -0
  9. package/dist/bin.d.ts +14 -0
  10. package/dist/bin.js +178 -0
  11. package/dist/check.d.ts +52 -0
  12. package/dist/check.js +313 -0
  13. package/dist/cli/completion.d.ts +5 -0
  14. package/dist/cli/completion.js +72 -0
  15. package/dist/cli/context.d.ts +97 -0
  16. package/dist/cli/context.js +94 -0
  17. package/dist/cli/data.d.ts +17 -0
  18. package/dist/cli/data.js +120 -0
  19. package/dist/cli/flags.d.ts +35 -0
  20. package/dist/cli/flags.js +208 -0
  21. package/dist/cli/help.d.ts +15 -0
  22. package/dist/cli/help.js +213 -0
  23. package/dist/cli/install.d.ts +10 -0
  24. package/dist/cli/install.js +65 -0
  25. package/dist/cli/output.d.ts +22 -0
  26. package/dist/cli/output.js +159 -0
  27. package/dist/cli/run.d.ts +10 -0
  28. package/dist/cli/run.js +343 -0
  29. package/dist/confirm.d.ts +90 -0
  30. package/dist/confirm.js +166 -0
  31. package/dist/data.d.ts +109 -0
  32. package/dist/data.js +324 -0
  33. package/dist/docs.d.ts +13 -0
  34. package/dist/docs.js +66 -0
  35. package/dist/doctor.d.ts +12 -0
  36. package/dist/doctor.js +100 -0
  37. package/dist/entry.d.ts +11 -0
  38. package/dist/entry.js +34 -0
  39. package/dist/errors.d.ts +96 -0
  40. package/dist/errors.js +153 -0
  41. package/dist/guard.d.ts +46 -0
  42. package/dist/guard.js +89 -0
  43. package/dist/index.d.ts +27 -0
  44. package/dist/index.js +16 -0
  45. package/dist/install.d.ts +98 -0
  46. package/dist/install.js +325 -0
  47. package/dist/jobs.d.ts +143 -0
  48. package/dist/jobs.js +247 -0
  49. package/dist/openapi.d.ts +127 -0
  50. package/dist/openapi.js +549 -0
  51. package/dist/pages.d.ts +22 -0
  52. package/dist/pages.js +61 -0
  53. package/dist/policy.d.ts +66 -0
  54. package/dist/policy.js +74 -0
  55. package/dist/redact.d.ts +18 -0
  56. package/dist/redact.js +60 -0
  57. package/dist/result.d.ts +44 -0
  58. package/dist/result.js +74 -0
  59. package/dist/rpc.d.ts +69 -0
  60. package/dist/rpc.js +120 -0
  61. package/dist/schema.d.ts +99 -0
  62. package/dist/schema.js +192 -0
  63. package/dist/search.d.ts +18 -0
  64. package/dist/search.js +119 -0
  65. package/dist/serve.d.ts +30 -0
  66. package/dist/serve.js +149 -0
  67. package/dist/server.d.ts +20 -0
  68. package/dist/server.js +244 -0
  69. package/dist/sync.d.ts +35 -0
  70. package/dist/sync.js +119 -0
  71. package/dist/testing.d.ts +35 -0
  72. package/dist/testing.js +41 -0
  73. package/dist/tool.d.ts +194 -0
  74. package/dist/tool.js +151 -0
  75. package/dist/util.d.ts +12 -0
  76. package/dist/util.js +35 -0
  77. package/package.json +89 -0
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Confirmation a model cannot fake.
3
+ *
4
+ * `confirm: true` is something the model types, so on its own it proves only
5
+ * that the model meant it. Where the client can put a person in front of the
6
+ * call, Slipway asks the person instead, and the model's flag stops counting:
7
+ *
8
+ * - Claude Code shows its own approval prompt for a tool marked as needing a
9
+ * person, on every call and in every permission mode. A call that arrives
10
+ * from it has already been approved, so nothing else is asked.
11
+ * - Any other client that supports elicitation is sent an approval form. The
12
+ * tool runs only on an explicit yes.
13
+ * - A client that can do neither falls back to `confirm: true`.
14
+ *
15
+ * An approval form's answer comes back from the client as data, and a client
16
+ * could attach an answer to a call nobody was asked about. So an answer only
17
+ * counts next to the signed state Slipway minted when it asked, which names
18
+ * the exact tool and arguments and can be used once.
19
+ */
20
+ import { randomBytes, randomUUID } from "node:crypto";
21
+ import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, createRequestStateCodec, inputRequired, inputResponse, } from "@modelcontextprotocol/server";
22
+ import { RefusedError } from "./errors.js";
23
+ import { consequence, Guard } from "./guard.js";
24
+ import { phrase, sha256, stableJson, versionAtLeast } from "./util.js";
25
+ /** The key of Slipway's approval form among a call's input requests. */
26
+ export const APPROVAL_KEY = "slipway_approval";
27
+ /** The client Claude Code identifies as, and the first version known to honor a tool's request for a person. */
28
+ const PROMPTING_CLIENT = { name: "claude-code", since: [2, 1, 246] };
29
+ /**
30
+ * Who is calling and what it can do. On the 2026-07-28 revision every request
31
+ * carries this itself; on earlier revisions it was said once, at initialize.
32
+ */
33
+ export function clientView(server, ctx) {
34
+ const envelope = ctx.mcpReq.envelope;
35
+ const info = (envelope?.[CLIENT_INFO_META_KEY] ?? server.server.getClientVersion());
36
+ const capabilities = (envelope?.[CLIENT_CAPABILITIES_META_KEY] ?? server.server.getClientCapabilities());
37
+ return {
38
+ ...(typeof info?.name === "string" ? { name: info.name } : {}),
39
+ ...(typeof info?.version === "string" ? { version: info.version } : {}),
40
+ ...(capabilities ? { capabilities } : {}),
41
+ };
42
+ }
43
+ /**
44
+ * Whether the client can show a person a form. A bare `elicitation: {}` is the
45
+ * 2025 way of saying so; naming only `url` mode is not.
46
+ */
47
+ export function canAskPerson(client) {
48
+ const elicitation = client.capabilities?.elicitation;
49
+ if (!elicitation || typeof elicitation !== "object")
50
+ return false;
51
+ return "form" in elicitation || !("url" in elicitation);
52
+ }
53
+ /** Whether the client shows its own approval prompt for a tool that asks for a person. */
54
+ export function promptsItself(client) {
55
+ return client.name === PROMPTING_CLIENT.name && versionAtLeast(client.version, PROMPTING_CLIENT.since);
56
+ }
57
+ export function confirmRoute(mode, client, listedForPerson) {
58
+ if (mode === "model")
59
+ return "flag";
60
+ if (listedForPerson && promptsItself(client))
61
+ return "client";
62
+ if (canAskPerson(client))
63
+ return "person";
64
+ return "flag";
65
+ }
66
+ /**
67
+ * Signs the state that travels with an approval form. The key lives as long
68
+ * as the process, which is every round of an approval on stdio and on one
69
+ * HTTP server; state from a restarted process fails verification, and the
70
+ * call is refused rather than run.
71
+ */
72
+ const codec = createRequestStateCodec({ key: randomBytes(32), ttlSeconds: 600 });
73
+ /** For `ServerOptions.requestState.verify`: rejects state Slipway did not sign, or signed more than ten minutes ago. */
74
+ export const verifyApprovalState = codec.verify;
75
+ /** Nonces of approvals already used, with when each expires. Bounded, so a long-lived server cannot grow it forever. */
76
+ const used = new Map();
77
+ const USED_LIMIT = 10_000;
78
+ function useOnce(nonce) {
79
+ const now = Date.now();
80
+ if (used.has(nonce))
81
+ return false;
82
+ for (const [key, expires] of used) {
83
+ if (expires > now && used.size < USED_LIMIT)
84
+ break;
85
+ used.delete(key);
86
+ }
87
+ used.set(nonce, now + 11 * 60_000);
88
+ return true;
89
+ }
90
+ function callHash(tool, args) {
91
+ return sha256(`${tool}\0${stableJson(args)}`);
92
+ }
93
+ /**
94
+ * The words and the one field an approval form shows.
95
+ *
96
+ * The field is required, starts unticked, and only an explicit true counts.
97
+ * Accepting is not enough on its own: a client with nobody to ask may accept a
98
+ * form by itself (Codex accepts a form that has no fields), and one that fills
99
+ * in defaults would otherwise approve with them.
100
+ */
101
+ export function approvalForm(appTitle, tool, summary) {
102
+ return {
103
+ message: `${appTitle} wants to ${phrase(summary)}.\n\nThis ${consequence(tool)}.`,
104
+ requestedSchema: {
105
+ type: "object",
106
+ properties: {
107
+ approve: { type: "boolean", title: "Approve", description: "Tick to run it, then accept. Decline to stop it.", default: false },
108
+ },
109
+ required: ["approve"],
110
+ },
111
+ };
112
+ }
113
+ /**
114
+ * Ask a person to approve the call, or read their answer.
115
+ *
116
+ * Returns the input-required result to send while the person has not answered
117
+ * yet, and nothing once they approved. Throws a refusal for a no, a closed
118
+ * form, or an answer that does not belong to this exact call.
119
+ */
120
+ export async function personApproval(app, tool, rawArgs, ctx, env) {
121
+ const { confirm: _flag, ...args } = rawArgs;
122
+ const guard = new Guard(app.policy(env), "mcp", app.envPrefix);
123
+ const state = ctx.mcpReq.requestState();
124
+ const answer = inputResponse(ctx.mcpReq.inputResponses, APPROVAL_KEY);
125
+ // No signed state means nobody was asked yet, whatever answer came along with the call.
126
+ if (!state || typeof state !== "object" || answer.kind === "missing") {
127
+ const summary = app.preflight(tool, rawArgs, { surface: "mcp", env });
128
+ guard.record(tool, summary, "asked a person");
129
+ const form = approvalForm(app.title, tool, summary);
130
+ return inputRequired({
131
+ requestState: await codec.mint({ t: tool.name, h: callHash(tool.name, args), n: randomUUID() }),
132
+ inputRequests: { [APPROVAL_KEY]: inputRequired.elicit(form) },
133
+ });
134
+ }
135
+ const summary = app.preflight(tool, rawArgs, { surface: "mcp", env });
136
+ if (state.t !== tool.name || state.h !== callHash(tool.name, args)) {
137
+ guard.record(tool, summary, "blocked: approval invalid");
138
+ throw new RefusedError(`That approval was for a different call, so ${tool.name} did not run.`, {
139
+ hint: "Call the tool again to ask for a new approval.",
140
+ });
141
+ }
142
+ if (!useOnce(state.n)) {
143
+ guard.record(tool, summary, "blocked: approval invalid");
144
+ throw new RefusedError(`That approval was already used, so ${tool.name} did not run again.`, {
145
+ hint: "Call the tool again to ask for a new approval.",
146
+ });
147
+ }
148
+ if (answer.kind === "elicit" && answer.action === "accept" && answer.content?.approve === true)
149
+ return undefined;
150
+ if (answer.kind === "elicit" && answer.action === "accept") {
151
+ guard.record(tool, summary, "blocked: person declined");
152
+ throw new RefusedError(`The approval form was accepted without ticking Approve, so ${tool.name} did not run. About to: ${summary}.`, {
153
+ hint: "Ask the user whether they want this, and call again only if they do.",
154
+ });
155
+ }
156
+ if (answer.kind === "elicit" && answer.action === "cancel") {
157
+ guard.record(tool, summary, "blocked: no answer");
158
+ throw new RefusedError(`The approval form was closed without an answer, so ${tool.name} did not run. About to: ${summary}.`, {
159
+ hint: "Ask the user whether they want this, and call again only if they do.",
160
+ });
161
+ }
162
+ guard.record(tool, summary, "blocked: person declined");
163
+ throw new RefusedError(`The approval was declined, so ${tool.name} did not run. About to: ${summary}.`, {
164
+ hint: "Do not call it again unless the user asks for it.",
165
+ });
166
+ }
package/dist/data.d.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Local data: a response cache, synced copies of lists, and search over them.
3
+ *
4
+ * An agent that asks the same question twice should not pay the service twice,
5
+ * and a person who wants to search ten thousand records should not page
6
+ * through an API to do it. So each app keeps one SQLite file on this machine,
7
+ * readable only by its owner, holding:
8
+ *
9
+ * - cached results of read tools that opted in, per account, cleared by any
10
+ * write through the same app;
11
+ * - records synced from list tools, searchable offline with full-text search
12
+ * and queryable with read-only SQL.
13
+ *
14
+ * It uses `node:sqlite`, which ships with Node.js 22.13 and later, so there is
15
+ * nothing to install. Where it is missing, the cache quietly stays off and the
16
+ * data commands say what to upgrade. Nothing here ever fails a tool call: a
17
+ * cache that cannot be read is a cache miss.
18
+ */
19
+ import { SlipwayError } from "./errors.js";
20
+ type Sqlite = typeof import("node:sqlite");
21
+ /**
22
+ * Load `node:sqlite` once. Node prints an experimental-feature warning when it
23
+ * loads, which would land in every CLI command's output, so only that one
24
+ * warning is held back while it loads.
25
+ */
26
+ export declare function loadSqlite(): Promise<Sqlite | undefined>;
27
+ export declare class NoSqliteError extends SlipwayError {
28
+ constructor();
29
+ }
30
+ /** Where each operating system keeps an app's own data. */
31
+ export declare function dataRoot(env: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string;
32
+ /** The folder for one app's data: `<PREFIX>_DATA_DIR`, or `slipway/<name>` in the system's data folder. */
33
+ export declare function dataDir(appName: string, envPrefix: string, env: NodeJS.ProcessEnv): string;
34
+ export type SearchHit = {
35
+ tool: string;
36
+ id: string;
37
+ snippet: string;
38
+ record: unknown;
39
+ };
40
+ export type SyncedTool = {
41
+ tool: string;
42
+ records: number;
43
+ last_sync?: string;
44
+ complete?: boolean;
45
+ };
46
+ /** One app's local data file. */
47
+ export declare class DataStore {
48
+ readonly file: string;
49
+ private readonly db;
50
+ private readonly sqlite;
51
+ private constructor();
52
+ /** Open the file, creating its folder and the file itself readable by their owner only. */
53
+ static open(dir: string): Promise<DataStore>;
54
+ close(): void;
55
+ private transaction;
56
+ cacheGet(scope: string, tool: string, key: string, now?: number): {
57
+ value: unknown;
58
+ storedAt: number;
59
+ } | undefined;
60
+ cachePut(scope: string, tool: string, key: string, value: unknown, ttlSeconds: number, now?: number): void;
61
+ /** Forget every cached result for one account, after anything changed it. */
62
+ cacheClear(scope?: string): number;
63
+ /** Store a page of records. Each is replaced whole, and indexed for search. */
64
+ putRecords(scope: string, tool: string, records: Array<{
65
+ id: string;
66
+ data: unknown;
67
+ }>, syncedAt: number): void;
68
+ /** After a complete sync, drop the records the service no longer lists. */
69
+ removeUnseen(scope: string, tool: string, syncedBefore: number): number;
70
+ recordSync(scope: string, tool: string, filters: string, run: {
71
+ startedAt: number;
72
+ records: number;
73
+ pages: number;
74
+ complete: boolean;
75
+ }): void;
76
+ search(scope: string, words: string, options?: {
77
+ tool?: string;
78
+ limit?: number;
79
+ }): SearchHit[];
80
+ synced(scope: string): SyncedTool[];
81
+ cacheEntries(scope: string, now?: number): number;
82
+ /** Delete one account's synced records, for one tool or all of them. */
83
+ clearRecords(scope: string, tool?: string): number;
84
+ /**
85
+ * Run one read-only statement on its own connection, which SQLite itself
86
+ * refuses to write through, whatever the statement says.
87
+ */
88
+ query(sql: string): Array<Record<string, unknown>>;
89
+ }
90
+ /** The open store for a folder, shared by every call in this process. */
91
+ export declare function storeAt(dir: string): Promise<DataStore>;
92
+ /** Close every open store. For tests and for a server shutting down. */
93
+ export declare function closeStores(): Promise<void>;
94
+ /**
95
+ * Which account data belongs to. Cached results and synced records are only
96
+ * ever read back for the account they came from, so switching keys never shows
97
+ * one account another's data.
98
+ */
99
+ export declare function scopeOf(credentials: ReadonlyArray<string | undefined | null>, explicit?: string): string;
100
+ /** The cache key for one call: the tool's own arguments, in a stable order. */
101
+ export declare function cacheKey(args: Record<string, unknown>): string;
102
+ /** Every string and number in a record, which is what full-text search reads. */
103
+ export declare function searchText(value: unknown): string;
104
+ /**
105
+ * Words a person typed, as an FTS5 query that cannot be a syntax error: every
106
+ * word must appear, the last may be the start of a word.
107
+ */
108
+ export declare function matchQuery(words: string): string;
109
+ export {};
package/dist/data.js ADDED
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Local data: a response cache, synced copies of lists, and search over them.
3
+ *
4
+ * An agent that asks the same question twice should not pay the service twice,
5
+ * and a person who wants to search ten thousand records should not page
6
+ * through an API to do it. So each app keeps one SQLite file on this machine,
7
+ * readable only by its owner, holding:
8
+ *
9
+ * - cached results of read tools that opted in, per account, cleared by any
10
+ * write through the same app;
11
+ * - records synced from list tools, searchable offline with full-text search
12
+ * and queryable with read-only SQL.
13
+ *
14
+ * It uses `node:sqlite`, which ships with Node.js 22.13 and later, so there is
15
+ * nothing to install. Where it is missing, the cache quietly stays off and the
16
+ * data commands say what to upgrade. Nothing here ever fails a tool call: a
17
+ * cache that cannot be read is a cache miss.
18
+ */
19
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, statSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { join } from "node:path";
22
+ import { SlipwayError, UsageError, EXIT } from "./errors.js";
23
+ import { sha256, stableJson } from "./util.js";
24
+ let loading;
25
+ /**
26
+ * Load `node:sqlite` once. Node prints an experimental-feature warning when it
27
+ * loads, which would land in every CLI command's output, so only that one
28
+ * warning is held back while it loads.
29
+ */
30
+ export function loadSqlite() {
31
+ loading ??= (async () => {
32
+ const original = process.emitWarning;
33
+ process.emitWarning = function (warning, ...rest) {
34
+ const text = typeof warning === "string" ? warning : warning?.message ?? "";
35
+ if (/sqlite/i.test(text))
36
+ return;
37
+ return original.call(process, warning, ...rest);
38
+ };
39
+ try {
40
+ return (await import("node:sqlite"));
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ finally {
46
+ process.emitWarning = original;
47
+ }
48
+ })();
49
+ return loading;
50
+ }
51
+ export class NoSqliteError extends SlipwayError {
52
+ constructor() {
53
+ super(`Local data needs Node.js 22.13 or later, which includes SQLite. This is Node.js ${process.versions.node}.`, "not_configured", EXIT.notConfigured, {
54
+ hint: "Install a current Node.js LTS release, then run the command again.",
55
+ });
56
+ }
57
+ }
58
+ /** Where each operating system keeps an app's own data. */
59
+ export function dataRoot(env, platform = process.platform) {
60
+ const home = env.HOME || env.USERPROFILE || homedir();
61
+ if (platform === "darwin")
62
+ return join(home, "Library", "Application Support");
63
+ if (platform === "win32")
64
+ return env.LOCALAPPDATA || join(home, "AppData", "Local");
65
+ return env.XDG_DATA_HOME || join(home, ".local", "share");
66
+ }
67
+ /** The folder for one app's data: `<PREFIX>_DATA_DIR`, or `slipway/<name>` in the system's data folder. */
68
+ export function dataDir(appName, envPrefix, env) {
69
+ return env[`${envPrefix}_DATA_DIR`]?.trim() || join(dataRoot(env), "slipway", appName);
70
+ }
71
+ const SCHEMA_VERSION = 1;
72
+ const SCHEMA = `
73
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
74
+ CREATE TABLE IF NOT EXISTS cache (
75
+ scope TEXT NOT NULL, tool TEXT NOT NULL, key TEXT NOT NULL,
76
+ value TEXT NOT NULL, stored_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
77
+ PRIMARY KEY (scope, tool, key)
78
+ );
79
+ CREATE INDEX IF NOT EXISTS cache_expires ON cache (expires_at);
80
+ CREATE TABLE IF NOT EXISTS records (
81
+ scope TEXT NOT NULL, tool TEXT NOT NULL, id TEXT NOT NULL,
82
+ data TEXT NOT NULL, synced_at INTEGER NOT NULL,
83
+ PRIMARY KEY (scope, tool, id)
84
+ );
85
+ CREATE VIRTUAL TABLE IF NOT EXISTS records_fts USING fts5 (
86
+ text, scope UNINDEXED, tool UNINDEXED, id UNINDEXED,
87
+ tokenize = 'unicode61 remove_diacritics 2'
88
+ );
89
+ CREATE TABLE IF NOT EXISTS syncs (
90
+ scope TEXT NOT NULL, tool TEXT NOT NULL, filters TEXT NOT NULL,
91
+ started_at INTEGER NOT NULL, finished_at INTEGER NOT NULL,
92
+ records INTEGER NOT NULL, pages INTEGER NOT NULL, complete INTEGER NOT NULL,
93
+ PRIMARY KEY (scope, tool, filters)
94
+ );
95
+ `;
96
+ /** The longest cached result kept. Anything bigger costs more to store than to fetch again. */
97
+ const MAX_CACHED_BYTES = 1024 * 1024;
98
+ /** The most text from one record that search indexes. */
99
+ const MAX_INDEXED_CHARS = 64 * 1024;
100
+ /** One app's local data file. */
101
+ export class DataStore {
102
+ file;
103
+ db;
104
+ sqlite;
105
+ constructor(file, db, sqlite) {
106
+ this.file = file;
107
+ this.db = db;
108
+ this.sqlite = sqlite;
109
+ }
110
+ /** Open the file, creating its folder and the file itself readable by their owner only. */
111
+ static async open(dir) {
112
+ const sqlite = await loadSqlite();
113
+ if (!sqlite)
114
+ throw new NoSqliteError();
115
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
116
+ const file = join(dir, "data.db");
117
+ // SQLite gives its journal files the database file's own permissions, so
118
+ // creating the file private first keeps all three private.
119
+ if (!existsSync(file))
120
+ closeSync(openSync(file, "a", 0o600));
121
+ if (process.platform !== "win32" && (statSync(file).mode & 0o077) !== 0)
122
+ chmodSync(file, 0o600);
123
+ const db = new sqlite.DatabaseSync(file);
124
+ db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA synchronous = NORMAL;");
125
+ db.exec(SCHEMA);
126
+ db.prepare("INSERT OR IGNORE INTO meta (key, value) VALUES ('schema', ?)").run(String(SCHEMA_VERSION));
127
+ return new DataStore(file, db, sqlite);
128
+ }
129
+ close() {
130
+ this.db.close();
131
+ }
132
+ transaction(work) {
133
+ this.db.exec("BEGIN IMMEDIATE");
134
+ try {
135
+ const result = work();
136
+ this.db.exec("COMMIT");
137
+ return result;
138
+ }
139
+ catch (error) {
140
+ this.db.exec("ROLLBACK");
141
+ throw error;
142
+ }
143
+ }
144
+ cacheGet(scope, tool, key, now = Date.now()) {
145
+ const row = this.db.prepare("SELECT value, stored_at FROM cache WHERE scope = ? AND tool = ? AND key = ? AND expires_at > ?").get(scope, tool, key, now);
146
+ return row ? { value: JSON.parse(row.value), storedAt: Number(row.stored_at) } : undefined;
147
+ }
148
+ cachePut(scope, tool, key, value, ttlSeconds, now = Date.now()) {
149
+ const text = JSON.stringify(value);
150
+ if (text === undefined || text.length > MAX_CACHED_BYTES)
151
+ return;
152
+ this.db.prepare("DELETE FROM cache WHERE expires_at <= ?").run(now);
153
+ this.db
154
+ .prepare("INSERT OR REPLACE INTO cache (scope, tool, key, value, stored_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)")
155
+ .run(scope, tool, key, text, now, now + Math.round(ttlSeconds * 1000));
156
+ }
157
+ /** Forget every cached result for one account, after anything changed it. */
158
+ cacheClear(scope) {
159
+ const result = scope === undefined ? this.db.prepare("DELETE FROM cache").run() : this.db.prepare("DELETE FROM cache WHERE scope = ?").run(scope);
160
+ return Number(result.changes);
161
+ }
162
+ /** Store a page of records. Each is replaced whole, and indexed for search. */
163
+ putRecords(scope, tool, records, syncedAt) {
164
+ const upsert = this.db.prepare("INSERT OR REPLACE INTO records (scope, tool, id, data, synced_at) VALUES (?, ?, ?, ?, ?)");
165
+ const unindex = this.db.prepare("DELETE FROM records_fts WHERE scope = ? AND tool = ? AND id = ?");
166
+ const index = this.db.prepare("INSERT INTO records_fts (text, scope, tool, id) VALUES (?, ?, ?, ?)");
167
+ this.transaction(() => {
168
+ for (const record of records) {
169
+ upsert.run(scope, tool, record.id, JSON.stringify(record.data) ?? "null", syncedAt);
170
+ unindex.run(scope, tool, record.id);
171
+ index.run(searchText(record.data), scope, tool, record.id);
172
+ }
173
+ });
174
+ }
175
+ /** After a complete sync, drop the records the service no longer lists. */
176
+ removeUnseen(scope, tool, syncedBefore) {
177
+ return this.transaction(() => {
178
+ const gone = this.db.prepare("SELECT id FROM records WHERE scope = ? AND tool = ? AND synced_at < ?").all(scope, tool, syncedBefore);
179
+ const unindex = this.db.prepare("DELETE FROM records_fts WHERE scope = ? AND tool = ? AND id = ?");
180
+ for (const row of gone)
181
+ unindex.run(scope, tool, row.id);
182
+ this.db.prepare("DELETE FROM records WHERE scope = ? AND tool = ? AND synced_at < ?").run(scope, tool, syncedBefore);
183
+ return gone.length;
184
+ });
185
+ }
186
+ recordSync(scope, tool, filters, run) {
187
+ this.db
188
+ .prepare("INSERT OR REPLACE INTO syncs (scope, tool, filters, started_at, finished_at, records, pages, complete) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
189
+ .run(scope, tool, filters, run.startedAt, Date.now(), run.records, run.pages, run.complete ? 1 : 0);
190
+ }
191
+ search(scope, words, options = {}) {
192
+ const query = matchQuery(words);
193
+ if (!query)
194
+ return [];
195
+ const limit = Math.max(1, Math.min(100, options.limit ?? 20));
196
+ const rows = this.db
197
+ .prepare(`SELECT f.tool AS tool, f.id AS id, snippet(records_fts, 0, '[', ']', '…', 12) AS snippet, r.data AS data
198
+ FROM records_fts f JOIN records r ON r.scope = f.scope AND r.tool = f.tool AND r.id = f.id
199
+ WHERE records_fts MATCH ? AND f.scope = ? ${options.tool ? "AND f.tool = ?" : ""}
200
+ ORDER BY rank LIMIT ?`)
201
+ .all(...[query, scope, ...(options.tool ? [options.tool] : []), limit]);
202
+ return rows.map((row) => ({ tool: row.tool, id: row.id, snippet: row.snippet, record: JSON.parse(row.data) }));
203
+ }
204
+ synced(scope) {
205
+ const counts = this.db.prepare("SELECT tool, COUNT(*) AS n FROM records WHERE scope = ? GROUP BY tool ORDER BY tool").all(scope);
206
+ const last = this.db.prepare("SELECT tool, MAX(finished_at) AS at, MAX(complete) AS complete FROM syncs WHERE scope = ? GROUP BY tool").all(scope);
207
+ const tools = new Set([...counts.map((row) => row.tool), ...last.map((row) => row.tool)]);
208
+ return [...tools].sort().map((tool) => {
209
+ const count = counts.find((row) => row.tool === tool);
210
+ const sync = last.find((row) => row.tool === tool);
211
+ return {
212
+ tool,
213
+ records: Number(count?.n ?? 0),
214
+ ...(sync ? { last_sync: new Date(Number(sync.at)).toISOString(), complete: Boolean(sync.complete) } : {}),
215
+ };
216
+ });
217
+ }
218
+ cacheEntries(scope, now = Date.now()) {
219
+ return Number(this.db.prepare("SELECT COUNT(*) AS n FROM cache WHERE scope = ? AND expires_at > ?").get(scope, now).n);
220
+ }
221
+ /** Delete one account's synced records, for one tool or all of them. */
222
+ clearRecords(scope, tool) {
223
+ return this.transaction(() => {
224
+ const where = tool ? "scope = ? AND tool = ?" : "scope = ?";
225
+ const params = tool ? [scope, tool] : [scope];
226
+ const removed = Number(this.db.prepare(`DELETE FROM records WHERE ${where}`).run(...params).changes);
227
+ this.db.prepare(`DELETE FROM records_fts WHERE ${where}`).run(...params);
228
+ this.db.prepare(`DELETE FROM syncs WHERE ${where}`).run(...params);
229
+ return removed;
230
+ });
231
+ }
232
+ /**
233
+ * Run one read-only statement on its own connection, which SQLite itself
234
+ * refuses to write through, whatever the statement says.
235
+ */
236
+ query(sql) {
237
+ const reader = new this.sqlite.DatabaseSync(this.file, { readOnly: true });
238
+ try {
239
+ reader.exec("PRAGMA query_only = 1");
240
+ let statement;
241
+ try {
242
+ statement = reader.prepare(sql);
243
+ }
244
+ catch (error) {
245
+ throw new UsageError(`SQL error: ${error.message}`, { hint: "Tables: records (scope, tool, id, data, synced_at), cache, syncs. data is JSON: json_extract(data, '$.title')." });
246
+ }
247
+ const wanted = sql.trim().replace(/;\s*$/, "").length;
248
+ const compiled = statement.sourceSQL.trim().replace(/;\s*$/, "").length;
249
+ if (compiled < wanted)
250
+ throw new UsageError("Run one statement at a time.");
251
+ try {
252
+ return statement.all().map((row) => ({ ...row }));
253
+ }
254
+ catch (error) {
255
+ throw new UsageError(`SQL error: ${error.message}`);
256
+ }
257
+ }
258
+ finally {
259
+ reader.close();
260
+ }
261
+ }
262
+ }
263
+ const stores = new Map();
264
+ /** The open store for a folder, shared by every call in this process. */
265
+ export function storeAt(dir) {
266
+ let store = stores.get(dir);
267
+ if (!store) {
268
+ store = DataStore.open(dir);
269
+ store.catch(() => stores.delete(dir));
270
+ stores.set(dir, store);
271
+ }
272
+ return store;
273
+ }
274
+ /** Close every open store. For tests and for a server shutting down. */
275
+ export async function closeStores() {
276
+ const open = [...stores.values()];
277
+ stores.clear();
278
+ for (const store of open)
279
+ (await store.catch(() => undefined))?.close();
280
+ }
281
+ /**
282
+ * Which account data belongs to. Cached results and synced records are only
283
+ * ever read back for the account they came from, so switching keys never shows
284
+ * one account another's data.
285
+ */
286
+ export function scopeOf(credentials, explicit) {
287
+ if (explicit)
288
+ return `id:${sha256(explicit).slice(0, 16)}`;
289
+ const values = credentials.filter((value) => typeof value === "string" && value.length > 0).sort();
290
+ return values.length ? `key:${sha256(values.join("\0")).slice(0, 16)}` : "default";
291
+ }
292
+ /** The cache key for one call: the tool's own arguments, in a stable order. */
293
+ export function cacheKey(args) {
294
+ return sha256(stableJson(args));
295
+ }
296
+ /** Every string and number in a record, which is what full-text search reads. */
297
+ export function searchText(value) {
298
+ const parts = [];
299
+ let size = 0;
300
+ const visit = (node) => {
301
+ if (size > MAX_INDEXED_CHARS)
302
+ return;
303
+ if (typeof node === "string") {
304
+ parts.push(node);
305
+ size += node.length;
306
+ }
307
+ else if (typeof node === "number" && Number.isFinite(node))
308
+ parts.push(String(node));
309
+ else if (Array.isArray(node))
310
+ node.forEach(visit);
311
+ else if (node && typeof node === "object")
312
+ Object.values(node).forEach(visit);
313
+ };
314
+ visit(value);
315
+ return parts.join("\n").slice(0, MAX_INDEXED_CHARS);
316
+ }
317
+ /**
318
+ * Words a person typed, as an FTS5 query that cannot be a syntax error: every
319
+ * word must appear, the last may be the start of a word.
320
+ */
321
+ export function matchQuery(words) {
322
+ const terms = words.split(/\s+/).map((term) => term.trim()).filter(Boolean);
323
+ return terms.map((term, i) => `"${term.replace(/"/g, '""')}"${i === terms.length - 1 ? "*" : ""}`).join(" ");
324
+ }
package/dist/docs.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Reference documentation generated from the tool list.
3
+ *
4
+ * A hand-written table of tools and arguments goes stale the day a tool is
5
+ * added. This one is printed from the same definitions the server and CLI use,
6
+ * so pasting it into a README is the last time anyone edits it by hand.
7
+ */
8
+ import type { App } from "./app.js";
9
+ import type { Tool } from "./tool.js";
10
+ export declare function toolTable(app: App, tools: readonly Tool[]): string;
11
+ export declare function toolReference(app: App, tools: readonly Tool[], heading?: string): string;
12
+ export declare function settingsTable(app: App): string;
13
+ export declare function renderDocs(app: App, env?: NodeJS.ProcessEnv): string;
package/dist/docs.js ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Reference documentation generated from the tool list.
3
+ *
4
+ * A hand-written table of tools and arguments goes stale the day a tool is
5
+ * added. This one is printed from the same definitions the server and CLI use,
6
+ * so pasting it into a README is the last time anyone edits it by hand.
7
+ */
8
+ import { flagsFor } from "./cli/flags.js";
9
+ import { exampleCommand } from "./cli/help.js";
10
+ import { policyEnvNames } from "./policy.js";
11
+ function cell(text) {
12
+ return text.replace(/\|/g, "\\|").replace(/\s*\n\s*/g, " ").trim() || "None";
13
+ }
14
+ function riskWords(tool) {
15
+ const base = tool.risk === "read" ? "Read" : tool.risk === "write" ? "Write" : "Irreversible";
16
+ return tool.requireConfirm ? `${base}, needs confirm` : base;
17
+ }
18
+ export function toolTable(app, tools) {
19
+ const lines = ["| Command | What it does | Risk |", "|---|---|---|"];
20
+ for (const tool of tools)
21
+ lines.push(`| \`${tool.command}\` | ${cell(tool.title)} | ${riskWords(tool)} |`);
22
+ return lines.join("\n");
23
+ }
24
+ export function toolReference(app, tools, heading = "###") {
25
+ const sections = [];
26
+ for (const tool of tools) {
27
+ const flags = flagsFor(tool.jsonSchema).filter((flag) => flag.key !== "confirm");
28
+ const lines = [`${heading} \`${tool.command}\``, ``, `**${cell(tool.title)}.** ${tool.description}`, ``];
29
+ if (flags.length) {
30
+ lines.push("| Argument | Type | Required | Description |", "|---|---|---|---|");
31
+ for (const flag of flags) {
32
+ const type = flag.choices ? flag.choices.map((choice) => `\`${choice}\``).join(", ") : flag.repeatable ? `${flag.kind} list` : flag.kind;
33
+ lines.push(`| \`${flag.key}\` | ${type} | ${flag.required ? "Yes" : "No"} | ${cell(flag.help)} |`);
34
+ }
35
+ lines.push(``);
36
+ }
37
+ lines.push(`Risk: ${riskWords(tool)}.`, ``);
38
+ if (tool.examples.length) {
39
+ lines.push("```bash");
40
+ for (const example of tool.examples)
41
+ lines.push(`# ${example.description}`, exampleCommand(app.bins.cli, tool, example.args));
42
+ lines.push("```", ``);
43
+ }
44
+ sections.push(lines.join("\n"));
45
+ }
46
+ return sections.join("\n");
47
+ }
48
+ export function settingsTable(app) {
49
+ const names = policyEnvNames(app.envPrefix);
50
+ return [
51
+ "| Variable | What it does |",
52
+ "|---|---|",
53
+ ...(app.definition.settings ?? []).map((setting) => `| \`${setting.env}\` | ${cell(setting.description)}${setting.secret ? " Keep it private." : ""} |`),
54
+ `| \`${names.readOnly}=1\` | Hide and refuse every write |`,
55
+ `| \`${names.allowDestructive}=0\` | Keep writes, refuse the public or irreversible ones |`,
56
+ `| \`${names.toolsets}\` | Comma-separated toolsets to turn on, or \`all\` |`,
57
+ `| \`${names.surface}=search\` | List three tools that find, describe and run the rest |`,
58
+ `| \`${names.auditLog}\` | File that records every attempted write |`,
59
+ `| \`${names.toolTimeoutMs}\` | Give up on any tool after this many milliseconds |`,
60
+ `| \`${names.confirm}=model\` | Let \`confirm: true\` alone confirm a call, for an agent with no person to ask. The default, \`human\`, asks a person wherever the client can |`,
61
+ ].join("\n");
62
+ }
63
+ export function renderDocs(app, env = process.env) {
64
+ const tools = app.tools(env);
65
+ return [`## Commands`, ``, toolTable(app, tools), ``, `## Reference`, ``, toolReference(app, tools), `## Settings`, ``, settingsTable(app), ``].join("\n");
66
+ }