@thenavidm/slipway 0.1.11 → 0.1.13

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/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  What changed in Slipway, newest first.
4
4
 
5
+ ## 0.1.13, 2026-10-05: what Beehiiv needed
6
+
7
+ - **`jsonSchema(schema, { shareRepeats: true })` writes each repeated part of a contract schema once.** A schema generated from an API contract often spells one definition out everywhere it is used. Beehiiv's post body repeats its block styling in each of 33 block types and carries the body twice, as its own fields and as `payload`, so its create-post tool advertised 387 KB. With each repeated part under `$defs` and referred to with `$ref`, it is 40 KB, and Beehiiv's 117 tools together go from 1,684 KB to 308 KB. Nothing is lost: each part reads the same once the references are followed, Claude Code and Codex both read fields that appear only under `$defs`, and validation accepts and refuses the same arguments. Definitions take the name of the property or block type they came from, such as `paragraph` or `visual_settings`, and parts under 200 bytes stay inline. The work is linear in the schema's size and happens when the tool is first listed. `shareRepeats()` is exported for a schema built some other way.
8
+ - **`slipway check` names that fix wherever it would help.** A schema over the size budget whose shared form is at least a fifth smaller says how big it would be.
9
+ - **CLI flags read through `$ref`.** An array whose items were a reference became a repeatable text flag, so a list of objects could not be passed; it is a JSON flag again, and a property that is only a reference takes its type, choices and description from the definition. `slipway check` no longer warns that such a property has no description.
10
+ - **`doctor` tells a server that only reads apart.** "Writes: on" read as if it could change something; it now says every tool only reads. A setting doctor cannot read points at `login` for what to set, where it told the person to run the doctor they were running, and the verdict says a setting needs fixing rather than that nothing is configured.
11
+
12
+ ## 0.1.12, 2026-10-05: what the Meta Ad Library needed
13
+
14
+ - **The write switches appear only where they act.** `<PREFIX>_READ_ONLY` and `<PREFIX>_AUDIT_LOG` are listed in the general help, `agent-context` and the generated settings table only when a tool writes, and `<PREFIX>_ALLOW_DESTRUCTIVE` and `<PREFIX>_CONFIRM` only when a tool can be irreversible, spends money or needs confirming. The Meta Ad Library server only reads, and its help offered to "refuse the irreversible writes"; it is now 40 tokens shorter, 399 against 439. The switches still work if set.
15
+
5
16
  ## 0.1.11, 2026-10-05: what WordPress needed
6
17
 
7
18
  - **Records no longer advertise `propertyNames`.** Zod 4 gives every `z.record` `propertyNames: { type: "string" }`, which every JSON object key already is, and the Zod 3 converters never wrote it. Slipway leaves it out of what clients receive, as it does the safe-integer bounds, and validation still runs on the schema itself. Each one cost 8 tokens; WordPress has nine. An argument that happens to be named `propertyNames` is a name, so it stays.
package/README.md CHANGED
@@ -255,6 +255,8 @@ export const renameCourse = defineTool({
255
255
  });
256
256
  ```
257
257
 
258
+ A contract schema often spells one definition out everywhere it is used. `jsonSchema(schema, { shareRepeats: true })` advertises it with each repeated part written once under `$defs` and referred to with `$ref`. Beehiiv's create-post tool went from 387 KB to 40 KB this way, and nothing is lost: Claude Code and Codex both read fields that appear only under `$defs`, validation accepts and refuses the same arguments, and the CLI's flags read through the references. `slipway check` says when it would help, and by how much.
259
+
258
260
  ## 4. Safety
259
261
 
260
262
  Shipping no writes is not safety: it hands the work back to a person. Shipping them unguarded is worse. So every write works, and the irreversible ones need a confirmation the caller gives on purpose.
@@ -574,15 +576,18 @@ See [CHANGELOG.md](CHANGELOG.md).
574
576
  | --- | --- | --- |
575
577
  | [Apple Podcasts](https://github.com/thenavidm/apple-podcasts-mcp-cli) | [`@thenavidm/apple-podcasts-mcp-cli`](https://www.npmjs.com/package/@thenavidm/apple-podcasts-mcp-cli) 2.0.0 | The catalog, charts per country, reviews and feeds, your library on this Mac with its transcript excerpts, and analytics for a show you own |
576
578
  | [Bluesky](https://github.com/thenavidm/bluesky-mcp-cli) | [`@thenavidm/bluesky-mcp-cli`](https://www.npmjs.com/package/@thenavidm/bluesky-mcp-cli) 2.0.0 | Posting, threads, replies, the timeline, search, feeds, lists, notifications and the social graph |
579
+ | [Meta Ad Library](https://github.com/thenavidm/facebook-ad-library-mcp-cli) | [`@thenavidm/facebook-ad-library-mcp-cli`](https://www.npmjs.com/package/@thenavidm/facebook-ad-library-mcp-cli) 0.6.0 | Every ad running on Facebook, Instagram and Threads for any advertiser: copy, creatives, how long each has run, what changed, and EU spend and reach |
577
580
  | [Google Photos](https://github.com/thenavidm/google-photos-mcp-cli) | [`@thenavidm/google-photos-mcp-cli`](https://www.npmjs.com/package/@thenavidm/google-photos-mcp-cli) 2.0.0 | The photo picker, uploads, albums and their captions, places and maps, and media this server uploaded, across several Google accounts |
578
581
  | [Mastodon](https://github.com/thenavidm/mastodon-mcp-cli) | [`@thenavidm/mastodon-mcp-cli`](https://www.npmjs.com/package/@thenavidm/mastodon-mcp-cli) 2.0.0 | Posting, editing, threads, timelines, search, lists, notifications and following, on any instance |
579
582
  | [Midjourney](https://github.com/thenavidm/midjourney-mcp-cli) | [`@thenavidm/midjourney-mcp-cli`](https://www.npmjs.com/package/@thenavidm/midjourney-mcp-cli) 2.0.0 | Generating images and video, following jobs, downloads, moodboards, the account's library and the explore feeds, through a signed-in browser |
583
+ | [Podcast Index](https://github.com/thenavidm/podcastindex-mcp-cli) | [`@thenavidm/podcastindex-mcp-cli`](https://www.npmjs.com/package/@thenavidm/podcastindex-mcp-cli) 2.0.0 | The open podcast directory, with the transcripts and chapters it links to fetched and read, guest histories, feed health and value-for-value |
580
584
  | [Substack](https://github.com/thenavidm/substack-mcp-cli) | [`@thenavidm/substack-mcp-cli`](https://www.npmjs.com/package/@thenavidm/substack-mcp-cli) 3.0.0 | Drafts, publishing and scheduling, Notes, subscribers, analytics, tags, comments and researching other publications |
581
585
  | [Teachable](https://github.com/thenavidm/teachable-mcp-cli) | [`@thenavidm/teachable-mcp-cli`](https://www.npmjs.com/package/@thenavidm/teachable-mcp-cli) 3.0.0 | Courses, users, enrollments, pricing, coupons and transactions |
582
586
  | [Threads](https://github.com/thenavidm/threads-mcp-cli) | [`@thenavidm/threads-mcp-cli`](https://www.npmjs.com/package/@thenavidm/threads-mcp-cli) 2.0.0 | Posting, threads, carousels, replies and reply approvals, insights and keyword search |
583
587
  | [TikTok](https://github.com/thenavidm/tiktok-mcp-cli) | [`@thenavidm/tiktok-mcp-cli`](https://www.npmjs.com/package/@thenavidm/tiktok-mcp-cli) 2.0.0 | Your own account's profile and videos, ranked by any metric, posting and drafts, through TikTok's official API |
584
588
  | [ThriveCart](https://github.com/thenavidm/thrivecart-mcp-cli) | [`@thenavidm/thrivecart-mcp-cli`](https://www.npmjs.com/package/@thenavidm/thrivecart-mcp-cli) 3.0.0 | Products and pricing, transactions and revenue, customers, subscriptions and affiliates, across several carts |
585
- | [YouTube](https://github.com/thenavidm/youtube-mcp-cli) | [`@thenavidm/youtube-mcp-cli`](https://www.npmjs.com/package/@thenavidm/youtube-mcp-cli) 3.0.0 | Transcripts of any public video, channel research against each channel's own median, and your own channels' videos, comments and analytics |
589
+ | [WordPress](https://github.com/thenavidm/wordpress-mcp-cli) | [`@thenavidm/wordpress-mcp-cli`](https://www.npmjs.com/package/@thenavidm/wordpress-mcp-cli) 2.0.0 | Posts, pages, custom post types, media, terms, users and comments across several sites, plus Elementor, Rank Math, redirects and bulk edits through a helper plugin |
590
+ | [YouTube](https://github.com/thenavidm/youtube-mcp-cli) | [`@thenavidm/youtube-mcp-cli`](https://www.npmjs.com/package/@thenavidm/youtube-mcp-cli) 3.0.1 | Transcripts of any public video, channel research against each channel's own median, and your own channels' videos, comments and analytics |
586
591
 
587
592
  Each server was measured against its previous release before it moved: startup, what a client receives, CLI exit codes, and tokens in Claude Code and Codex. Its README has the numbers.
588
593
 
package/dist/check.js CHANGED
@@ -15,7 +15,7 @@ import { agentContext } from "./cli/context.js";
15
15
  import { flagsFor } from "./cli/flags.js";
16
16
  import { BUILTINS, GLOBAL_FLAGS, renderToolHelp } from "./cli/help.js";
17
17
  import { connectInMemory } from "./rpc.js";
18
- import { repeatedDefinitions, schemaBytes, validate, formatIssues } from "./schema.js";
18
+ import { repeatedDefinitions, resolveLocalRef, schemaBytes, shareRepeats, validate, formatIssues } from "./schema.js";
19
19
  import { REQUIRES_USER_INTERACTION } from "./server.js";
20
20
  const PROPERTY = /^[A-Za-z0-9_.-]{1,64}$/;
21
21
  const DEFAULT_BUDGET = { warnBytes: 16 * 1024, errorBytes: 128 * 1024 };
@@ -98,7 +98,7 @@ export async function checkApp(app, options = {}) {
98
98
  if (!PROPERTY.test(name))
99
99
  add("error", "schema", `Property '${name}' must be 1-64 letters, digits, '_', '.' or '-'.`, tool.name);
100
100
  }
101
- const undocumented = Object.entries(properties).filter(([name, prop]) => name !== "confirm" && !prop.description).map(([name]) => name);
101
+ const undocumented = Object.entries(properties).filter(([name, prop]) => name !== "confirm" && !resolveLocalRef(schema, prop).description).map(([name]) => name);
102
102
  if (undocumented.length)
103
103
  add("warn", "descriptions", `No description for: ${undocumented.join(", ")}.`, tool.name);
104
104
  const problem = meta?.(schema);
@@ -117,10 +117,13 @@ export async function checkApp(app, options = {}) {
117
117
  totalBytes += bytes;
118
118
  if (!largest || bytes > largest.bytes)
119
119
  largest = { name: tool.name, bytes };
120
+ // Sharing the repeated parts is the fix that loses nothing, so it is named first wherever it would help.
121
+ const shared = bytes > budget.warnBytes ? schemaBytes(shareRepeats(schema)) : bytes;
122
+ const share = shared < bytes * 0.8 ? ` jsonSchema(schema, { shareRepeats: true }) writes each repeated part once and brings it to ${Math.round(shared / 1024)} KB, with nothing lost.` : "";
120
123
  if (bytes > budget.errorBytes)
121
- add("error", "size", `The schema is ${Math.round(bytes / 1024)} KB. Advertise a short schema and validate the full one in the handler.`, tool.name);
124
+ add("error", "size", `The schema is ${Math.round(bytes / 1024)} KB.${share} ${share ? "Or advertise" : "Advertise"} a short schema and validate the full one in the handler.`, tool.name);
122
125
  else if (bytes > budget.warnBytes)
123
- add("warn", "size", `The schema is ${Math.round(bytes / 1024)} KB, which a model pays for every time it loads this tool.`, tool.name);
126
+ add("warn", "size", `The schema is ${Math.round(bytes / 1024)} KB, which a model pays for every time it loads this tool.${share}`, tool.name);
124
127
  const repeated = repeatedDefinitions(schema);
125
128
  if (repeated.length)
126
129
  add("warn", "size", `Definitions appear more than once: ${repeated.slice(0, 5).join(", ")}${repeated.length > 5 ? "…" : ""}.`, tool.name);
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { createRequire } from "node:module";
9
9
  import { EXIT } from "../errors.js";
10
- import { policyEnvNames } from "../policy.js";
10
+ import { policyEnvNames, switchesThatApply } from "../policy.js";
11
11
  import { outputJsonSchema } from "../schema.js";
12
12
  import { exampleCommand, GLOBAL_FLAGS } from "./help.js";
13
13
  import { flagsFor } from "./flags.js";
@@ -26,6 +26,7 @@ export const EXIT_MEANINGS = {
26
26
  export function agentContext(app, env, bin, options = {}) {
27
27
  const policy = app.policy(env);
28
28
  const names = policyEnvNames(app.envPrefix);
29
+ const applies = switchesThatApply(app.allTools);
29
30
  const tools = app.tools(env);
30
31
  const hidden = app.allTools.length - tools.length;
31
32
  // Terminal commands the app adds beside its tools, such as logout, and a sign-in flow that says what it takes.
@@ -78,15 +79,19 @@ export function agentContext(app, env, bin, options = {}) {
78
79
  ...(setting.secret ? { secret: true } : {}),
79
80
  description: setting.description,
80
81
  })),
81
- { env: names.readOnly, value: policy.readOnly, description: "hide and refuse every write" },
82
- { env: names.allowDestructive, value: policy.allowDestructive, description: app.allTools.some((tool) => tool.spends) ? "allow public or irreversible writes and paid calls" : "allow public or irreversible writes" },
82
+ ...(applies.readOnly ? [{ env: names.readOnly, value: policy.readOnly, description: "hide and refuse every write" }] : []),
83
+ ...(applies.allowDestructive
84
+ ? [{ env: names.allowDestructive, value: policy.allowDestructive, description: app.allTools.some((tool) => tool.spends) ? "allow public or irreversible writes and paid calls" : "allow public or irreversible writes" }]
85
+ : []),
83
86
  ...(app.allTools.some((tool) => tool.tags.length > 0)
84
87
  ? [{ env: names.toolsets, value: policy.toolsets === "all" ? "all" : [...policy.toolsets], description: "toolsets that are on" }]
85
88
  : []),
86
89
  { env: names.surface, value: policy.surface, description: "full tool list, or search for very large catalogs" },
87
- { env: names.auditLog, value: policy.auditLog ?? null, description: "file that records every attempted write" },
90
+ ...(applies.auditLog ? [{ env: names.auditLog, value: policy.auditLog ?? null, description: "file that records every attempted write" }] : []),
88
91
  { env: names.toolTimeoutMs, value: policy.toolTimeoutMs ?? null, description: "deadline for any tool" },
89
- { env: names.confirm, value: policy.confirm, description: "who confirms a confirmed call over MCP: human asks a person where the client can, model accepts confirm: true" },
92
+ ...(applies.confirm
93
+ ? [{ env: names.confirm, value: policy.confirm, description: "who confirms a confirmed call over MCP: human asks a person where the client can, model accepts confirm: true" }]
94
+ : []),
90
95
  { env: `${app.envPrefix}_HTTP_PORT`, value: env[`${app.envPrefix}_HTTP_PORT`] ?? null, description: `port for --http, ${app.definition.httpPort ?? 8787} when unset` },
91
96
  { env: `${app.envPrefix}_HTTP_HOST`, value: env[`${app.envPrefix}_HTTP_HOST`] ?? null, description: "address for --http, 127.0.0.1 when unset; any other needs a token" },
92
97
  { env: `${app.envPrefix}_HTTP_TOKEN`, set: Boolean(env[`${app.envPrefix}_HTTP_TOKEN`]), secret: true, description: "bearer token --http requires" },
@@ -5,7 +5,7 @@
5
5
  * one surface and not the other, and its help text is the description the
6
6
  * model reads.
7
7
  */
8
- import type { JsonSchema } from "../schema.js";
8
+ import { type JsonSchema } from "../schema.js";
9
9
  export type FlagKind = "string" | "number" | "integer" | "boolean" | "enum" | "json";
10
10
  export type Flag = {
11
11
  /** The property name: `reply_to`. */
package/dist/cli/flags.js CHANGED
@@ -7,28 +7,30 @@
7
7
  */
8
8
  import { readFileSync } from "node:fs";
9
9
  import { UsageError } from "../errors.js";
10
+ import { resolveLocalRef } from "../schema.js";
10
11
  import { didYouMean } from "../search.js";
11
- /** The first concrete type, looking through nullable unions and type lists. */
12
- function concrete(node) {
12
+ /** The first concrete type, looking through references, nullable unions and type lists. */
13
+ function concrete(raw, root) {
14
+ const node = resolveLocalRef(root, raw);
13
15
  const union = node.anyOf ?? node.oneOf;
14
16
  if (union) {
15
- const options = union.filter((option) => option.type !== "null");
17
+ const options = union.map((option) => resolveLocalRef(root, option)).filter((option) => option.type !== "null");
16
18
  // A union of plain literals is an enum in disguise, which a person types as a word.
17
19
  if (options.length > 1 && options.every((option) => option.const !== undefined)) {
18
20
  return { ...node, enum: options.map((option) => option.const) };
19
21
  }
20
- return concrete({ ...(options[0] ?? {}), description: node.description ?? options[0]?.description });
22
+ return concrete({ ...(options[0] ?? {}), description: node.description ?? options[0]?.description }, root);
21
23
  }
22
24
  if (Array.isArray(node.type))
23
25
  return { ...node, type: node.type.find((type) => type !== "null") ?? "string" };
24
26
  return node;
25
27
  }
26
- function kindOf(node) {
27
- const n = concrete(node);
28
+ function kindOf(node, root) {
29
+ const n = concrete(node, root);
28
30
  if (n.enum)
29
31
  return { kind: "enum", repeatable: false, choices: n.enum.map(String) };
30
32
  if (n.type === "array") {
31
- const item = concrete(n.items ?? {});
33
+ const item = concrete(n.items ?? {}, root);
32
34
  if (item.enum)
33
35
  return { kind: "enum", repeatable: true, choices: item.enum.map(String) };
34
36
  if (item.type === "object" || item.type === "array")
@@ -50,11 +52,11 @@ export function flagsFor(schema) {
50
52
  const properties = schema.properties ?? {};
51
53
  const required = new Set(schema.required ?? []);
52
54
  return Object.entries(properties).map(([key, node]) => {
53
- const n = concrete(node);
55
+ const n = concrete(node, schema);
54
56
  return {
55
57
  key,
56
58
  flag: flagName(key),
57
- ...kindOf(node),
59
+ ...kindOf(node, schema),
58
60
  required: required.has(key),
59
61
  help: (node.description ?? n.description ?? "").trim(),
60
62
  ...(node.default !== undefined ? { default: node.default } : n.default !== undefined ? { default: n.default } : {}),
package/dist/cli/help.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * shows what applies and points to the rest instead of repeating it.
6
6
  */
7
7
  import { EXIT } from "../errors.js";
8
- import { policyEnvNames, riskMark, visibility } from "../policy.js";
8
+ import { policyEnvNames, riskMark, switchesThatApply, visibility } from "../policy.js";
9
9
  import { firstSentence } from "../search.js";
10
10
  import { flagsFor } from "./flags.js";
11
11
  const COLUMN = 30;
@@ -205,6 +205,7 @@ function table(rows) {
205
205
  }
206
206
  export function renderGeneralHelp(app, bin) {
207
207
  const names = policyEnvNames(app.envPrefix);
208
+ const applies = switchesThatApply(app.allTools);
208
209
  const cache = app.allTools.some((tool) => tool.cache);
209
210
  const sync = app.allTools.some((tool) => tool.sync);
210
211
  const jobs = app.allTools.some((tool) => tool.job);
@@ -236,20 +237,24 @@ export function renderGeneralHelp(app, bin) {
236
237
  const tuning = [
237
238
  ...(app.definition.settings ?? []).filter((setting) => setting.tuning).map((setting) => setting.env),
238
239
  names.surface,
239
- names.auditLog,
240
+ ...(applies.auditLog ? [names.auditLog] : []),
240
241
  names.toolTimeoutMs,
241
- names.confirm,
242
+ ...(applies.confirm ? [names.confirm] : []),
242
243
  ...(cache ? [names.cache] : []),
243
244
  ...(cache || sync ? [names.dataDir] : []),
244
245
  `${app.envPrefix}_DEBUG`,
245
246
  ];
246
247
  const settings = [
247
248
  ...(app.definition.settings ?? []).filter((setting) => !setting.tuning).map((setting) => [setting.env, setting.description]),
248
- [`${names.readOnly}=1`, "hide and refuse every write"],
249
- [
250
- `${names.allowDestructive}=0`,
251
- `${app.definition.defaults?.destructiveOff === "hide" ? "hide and refuse" : "refuse"} the irreversible writes${app.allTools.some((tool) => tool.spends) ? " and paid calls" : ""}`,
252
- ],
249
+ ...(applies.readOnly ? [[`${names.readOnly}=1`, "hide and refuse every write"]] : []),
250
+ ...(applies.allowDestructive
251
+ ? [
252
+ [
253
+ `${names.allowDestructive}=0`,
254
+ `${app.definition.defaults?.destructiveOff === "hide" ? "hide and refuse" : "refuse"} the irreversible writes${app.allTools.some((tool) => tool.spends) ? " and paid calls" : ""}`,
255
+ ],
256
+ ]
257
+ : []),
253
258
  // With no tagged tool every tool is always on, so the switch would do nothing.
254
259
  ...(app.allTools.some((tool) => tool.tags.length > 0) ? [[`${names.toolsets}=a,b`, "only these toolsets, or all"]] : []),
255
260
  [`${app.envPrefix}_HTTP_PORT / _HOST / _TOKEN / _ALLOWED_ORIGINS`, "for --http"],
package/dist/docs.js CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { flagsFor } from "./cli/flags.js";
9
9
  import { exampleCommand } from "./cli/help.js";
10
- import { policyEnvNames } from "./policy.js";
10
+ import { policyEnvNames, switchesThatApply } from "./policy.js";
11
11
  function cell(text) {
12
12
  return text.replace(/\|/g, "\\|").replace(/\s*\n\s*/g, " ").trim() || "None";
13
13
  }
@@ -47,17 +47,22 @@ export function toolReference(app, tools, heading = "###") {
47
47
  }
48
48
  export function settingsTable(app) {
49
49
  const names = policyEnvNames(app.envPrefix);
50
+ const applies = switchesThatApply(app.allTools);
50
51
  return [
51
52
  "| Variable | What it does |",
52
53
  "|---|---|",
53
54
  ...(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${app.allTools.some((tool) => tool.spends) ? " and paid calls" : ""} |`,
55
+ ...(applies.readOnly ? [`| \`${names.readOnly}=1\` | Hide and refuse every write |`] : []),
56
+ ...(applies.allowDestructive
57
+ ? [`| \`${names.allowDestructive}=0\` | Keep writes, refuse the public or irreversible ones${app.allTools.some((tool) => tool.spends) ? " and paid calls" : ""} |`]
58
+ : []),
56
59
  `| \`${names.toolsets}\` | Comma-separated toolsets to turn on, or \`all\` |`,
57
60
  `| \`${names.surface}=search\` | List three tools that find, describe and run the rest |`,
58
- `| \`${names.auditLog}\` | File that records every attempted write |`,
61
+ ...(applies.auditLog ? [`| \`${names.auditLog}\` | File that records every attempted write |`] : []),
59
62
  `| \`${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 |`,
63
+ ...(applies.confirm
64
+ ? [`| \`${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 |`]
65
+ : []),
61
66
  ].join("\n");
62
67
  }
63
68
  export function renderDocs(app, env = process.env) {
package/dist/doctor.js CHANGED
@@ -17,7 +17,13 @@ export async function runDoctor(app, io, options) {
17
17
  checks.push({ name: "Node.js", ok: major >= 22, detail: `v${process.versions.node}`, ...(major >= 22 ? {} : { fix: "Install Node.js 22 or later." }) });
18
18
  checks.push({ name: "Version", ok: true, detail: `${app.name} ${app.version}` });
19
19
  const paid = app.allTools.some((tool) => tool.spends) ? " and paid" : "";
20
- const writes = policy.readOnly ? "off (read-only)" : policy.allowDestructive ? "on" : `on, irreversible${paid} ones refused`;
20
+ const writes = !app.allTools.some((tool) => tool.risk !== "read")
21
+ ? "none: every tool only reads"
22
+ : policy.readOnly
23
+ ? "off (read-only)"
24
+ : policy.allowDestructive
25
+ ? "on"
26
+ : `on, irreversible${paid} ones refused`;
21
27
  checks.push({ name: "Writes", ok: true, detail: writes });
22
28
  checks.push({
23
29
  name: "Tools",
@@ -51,14 +57,18 @@ export async function runDoctor(app, io, options) {
51
57
  });
52
58
  }
53
59
  let configured = true;
60
+ let unreadable = false;
54
61
  let ctx;
55
62
  try {
56
63
  ctx = await app.context(io.env);
57
64
  }
58
65
  catch (error) {
59
66
  configured = false;
67
+ unreadable = true;
60
68
  const e = error instanceof SlipwayError ? error : new NotConfiguredError(String(error?.message ?? error));
61
- checks.push({ name: "Setup", ok: false, detail: e.message, fix: e.hint ?? `Run \`${app.bins.cli} login\`.` });
69
+ // Outside doctor, a setting that cannot be read points here; in here, it points at how to set it.
70
+ const fix = e.hint && !e.hint.includes(`${app.bins.cli} doctor`) ? e.hint : `Run \`${app.bins.cli} login\` for what to set.`;
71
+ checks.push({ name: "Setup", ok: false, detail: e.message, fix });
62
72
  }
63
73
  if (ctx !== undefined && app.definition.configured) {
64
74
  configured = await app.definition.configured(ctx);
@@ -95,7 +105,8 @@ export async function runDoctor(app, io, options) {
95
105
  if (!check.ok && check.fix)
96
106
  lines.push(` ${" ".repeat(width)}${check.fix}`);
97
107
  }
98
- lines.push(``, code === EXIT.ok ? " Ready." : code === EXIT.notConfigured ? " Nothing is configured yet." : " Something needs fixing.", ``);
108
+ const verdict = code === EXIT.ok ? "Ready." : unreadable ? "A setting needs fixing." : code === EXIT.notConfigured ? "Nothing is configured yet." : "Something needs fixing.";
109
+ lines.push(``, ` ${verdict}`, ``);
99
110
  io.stdout(lines.join("\n"));
100
111
  }
101
112
  return code;
package/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@ export { slipway, stderrLogger } from "./app.js";
5
5
  export type { App, AppDefinition, CliIO, DoctorCheck, DryRun, InvokeOptions, PromptDefinition, ResourceDefinition, ServiceSetting, } from "./app.js";
6
6
  export { defineTool, isTool, toolkit } from "./tool.js";
7
7
  export type { CacheOptions, Logger, Paginate, Risk, RunContext, Surface, SyncOptions, Tool, ToolContext, ToolDefinition, ToolExample } from "./tool.js";
8
- export { emptyInput, inputJsonSchema, jsonSchema, outputJsonSchema, validate, CONFIRM_DESCRIPTION } from "./schema.js";
8
+ export { emptyInput, inputJsonSchema, jsonSchema, outputJsonSchema, shareRepeats, validate, CONFIRM_DESCRIPTION } from "./schema.js";
9
9
  export type { InferInput, InferOutput, JsonSchema, Schema } from "./schema.js";
10
10
  export { ApiError, AuthError, CanceledError, EXIT, NotConfiguredError, NotFoundError, RateLimitError, RefusedError, SlipwayError, TimeoutError, UsageError, httpError, toSlipwayError, } from "./errors.js";
11
11
  export type { ErrorCode, ErrorPayload } from "./errors.js";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export { slipway, stderrLogger } from "./app.js";
5
5
  export { defineTool, isTool, toolkit } from "./tool.js";
6
- export { emptyInput, inputJsonSchema, jsonSchema, outputJsonSchema, validate, CONFIRM_DESCRIPTION } from "./schema.js";
6
+ export { emptyInput, inputJsonSchema, jsonSchema, outputJsonSchema, shareRepeats, validate, CONFIRM_DESCRIPTION } from "./schema.js";
7
7
  export { ApiError, AuthError, CanceledError, EXIT, NotConfiguredError, NotFoundError, RateLimitError, RefusedError, SlipwayError, TimeoutError, UsageError, httpError, toSlipwayError, } from "./errors.js";
8
8
  export { audio, content, file, image, resourceLink, text } from "./result.js";
9
9
  export { readPolicy, policyEnvNames } from "./policy.js";
package/dist/policy.d.ts CHANGED
@@ -62,6 +62,19 @@ export type PolicyEnv = {
62
62
  cache: string;
63
63
  dataDir: string;
64
64
  };
65
+ /**
66
+ * Which of the write switches can change anything for these tools. A server
67
+ * whose tools all read has nothing for read-only mode to hide or the audit log
68
+ * to record, and one with no irreversible, paid or confirmed call has nothing
69
+ * for the destructive and confirm switches to act on, so help, agent-context
70
+ * and the generated docs leave those out. They still work if set.
71
+ */
72
+ export declare function switchesThatApply(tools: readonly Tool[]): {
73
+ readOnly: boolean;
74
+ allowDestructive: boolean;
75
+ auditLog: boolean;
76
+ confirm: boolean;
77
+ };
65
78
  export declare function policyEnvNames(prefix: string): PolicyEnv;
66
79
  export declare function readPolicy(env: NodeJS.ProcessEnv, prefix: string, defaults?: PolicyDefaults): Policy;
67
80
  export type Visibility = {
package/dist/policy.js CHANGED
@@ -6,6 +6,22 @@
6
6
  * block the irreversible ones, keep a log, load fewer tools. They live under
7
7
  * the server's own prefix, so two servers in one client never share a switch.
8
8
  */
9
+ /**
10
+ * Which of the write switches can change anything for these tools. A server
11
+ * whose tools all read has nothing for read-only mode to hide or the audit log
12
+ * to record, and one with no irreversible, paid or confirmed call has nothing
13
+ * for the destructive and confirm switches to act on, so help, agent-context
14
+ * and the generated docs leave those out. They still work if set.
15
+ */
16
+ export function switchesThatApply(tools) {
17
+ const writes = tools.some((tool) => tool.risk !== "read");
18
+ return {
19
+ readOnly: writes,
20
+ allowDestructive: tools.some((tool) => tool.risk === "destructive" || tool.spends),
21
+ auditLog: writes,
22
+ confirm: tools.some((tool) => tool.requireConfirm),
23
+ };
24
+ }
9
25
  export function policyEnvNames(prefix) {
10
26
  return {
11
27
  readOnly: `${prefix}_READ_ONLY`,
package/dist/schema.d.ts CHANGED
@@ -38,8 +38,13 @@ export type Issue = {
38
38
  * Compiling all 123 schemas of one server up front held its first answer back
39
39
  * by 118 ms, for tools most sessions never call. `slipway check` compiles every
40
40
  * one, so a schema that cannot compile still fails before release.
41
+ *
42
+ * `shareRepeats: true` advertises the schema with each repeated part written
43
+ * once, as `shareRepeats()` describes.
41
44
  */
42
- export declare function jsonSchema<T = Record<string, unknown>>(schema: JsonSchema): Schema<T, T>;
45
+ export declare function jsonSchema<T = Record<string, unknown>>(schema: JsonSchema, options?: {
46
+ shareRepeats?: boolean;
47
+ }): Schema<T, T>;
43
48
  /** The input of a tool that takes nothing. */
44
49
  export declare function emptyInput(): Schema<Record<string, never>>;
45
50
  export declare function isSchema(value: unknown): value is Schema;
@@ -101,4 +106,28 @@ export declare function schemaBytes(schema: JsonSchema): number;
101
106
  * often half repetition.
102
107
  */
103
108
  export declare function repeatedDefinitions(schema: JsonSchema): string[];
109
+ /**
110
+ * The same schema with each part that repeats written once, under `$defs`, and
111
+ * referred to with `$ref` everywhere it appeared.
112
+ *
113
+ * A schema generated from an API contract often spells one definition out
114
+ * everywhere it is used: one newsletter API's post body repeats the same
115
+ * styling block 33 times, and its create-post tool came to 378 KB, where
116
+ * sharing the repeats leaves 37 KB. Nothing is lost: each part reads the
117
+ * same, and Claude Code and Codex both read fields that appear only under
118
+ * `$defs`. Parts under 200 bytes stay inline, so the schema still reads top
119
+ * to bottom.
120
+ *
121
+ * A schema with a `$ref` to anything but its own definitions is returned as
122
+ * it is, since moving a part could break that reference. The work is linear in
123
+ * the schema's size: each distinct part is serialized once.
124
+ */
125
+ export declare function shareRepeats(schema: JsonSchema, options?: {
126
+ minBytes?: number;
127
+ }): JsonSchema;
128
+ /**
129
+ * A property that is only a reference into the schema's own definitions, read
130
+ * as the definition it points to, with its own words first.
131
+ */
132
+ export declare function resolveLocalRef<T extends Record<string, unknown>>(root: JsonSchema, node: T): T;
104
133
  export {};
package/dist/schema.js CHANGED
@@ -19,15 +19,21 @@ const TARGET = { target: "draft-2020-12" };
19
19
  * Compiling all 123 schemas of one server up front held its first answer back
20
20
  * by 118 ms, for tools most sessions never call. `slipway check` compiles every
21
21
  * one, so a schema that cannot compile still fails before release.
22
+ *
23
+ * `shareRepeats: true` advertises the schema with each repeated part written
24
+ * once, as `shareRepeats()` describes.
22
25
  */
23
- export function jsonSchema(schema) {
26
+ export function jsonSchema(schema, options = {}) {
24
27
  let compiled;
28
+ let shared;
29
+ // Shared on first use, like the validator, so a command that never lists this tool never pays for it.
30
+ const advertisedSchema = () => (options.shareRepeats ? (shared ??= shareRepeats(schema)) : schema);
25
31
  return {
26
32
  "~standard": {
27
33
  version: 1,
28
34
  vendor: "mcp",
29
- jsonSchema: { input: () => schema, output: () => schema },
30
- validate: (value) => (compiled ??= fromJsonSchema(schema))["~standard"].validate(value),
35
+ jsonSchema: { input: advertisedSchema, output: advertisedSchema },
36
+ validate: (value) => (compiled ??= fromJsonSchema(advertisedSchema()))["~standard"].validate(value),
31
37
  },
32
38
  };
33
39
  }
@@ -231,3 +237,204 @@ export function repeatedDefinitions(schema) {
231
237
  visit(schema);
232
238
  return [...seen.entries()].filter(([, count]) => count > 1).map(([fingerprint]) => fingerprint.split(":")[0]);
233
239
  }
240
+ /** Keywords whose value is one subschema. */
241
+ const ONE_SCHEMA = new Set(["items", "additionalItems", "additionalProperties", "contains", "not", "if", "then", "else", "propertyNames", "unevaluatedItems", "unevaluatedProperties", "contentSchema"]);
242
+ /** Keywords whose value is a list of subschemas. */
243
+ const SCHEMA_LIST = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]);
244
+ /** Keywords whose value maps names to subschemas. */
245
+ const SCHEMA_MAP = new Set(["properties", "patternProperties", "dependentSchemas", "$defs", "definitions"]);
246
+ /** A part that carries one of these stays where it is: something may point into it, or it changes how references inside it resolve. */
247
+ const ANCHORED = ["$id", "$anchor", "$dynamicAnchor", "$defs", "definitions"];
248
+ /** Smaller parts stay inline, so the schema still reads top to bottom. */
249
+ const SHARE_MIN_BYTES = 200;
250
+ const CHILD = Symbol("child");
251
+ function isRecord(value) {
252
+ return value !== null && typeof value === "object" && !Array.isArray(value);
253
+ }
254
+ /** Every `$ref` in the schema points into its own definitions, so moving any other part breaks none of them. */
255
+ function refsOnlyInto(node, prefix) {
256
+ if (Array.isArray(node))
257
+ return node.every((item) => refsOnlyInto(item, prefix));
258
+ if (!isRecord(node))
259
+ return true;
260
+ if (node.$ref !== undefined && !(typeof node.$ref === "string" && node.$ref.startsWith(prefix)))
261
+ return false;
262
+ return Object.values(node).every((value) => refsOnlyInto(value, prefix));
263
+ }
264
+ /** A definition name a JSON pointer carries as is: letters, digits, `_`, `.` and `-`. */
265
+ function definitionName(label) {
266
+ return label.replace(/[^A-Za-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64) || "shared";
267
+ }
268
+ /** What to call one of several options: its title, or the value its `type` property is fixed to, such as `paragraph`. */
269
+ function optionLabel(option, parent) {
270
+ if (!isRecord(option))
271
+ return `${parent}_option`;
272
+ if (typeof option.title === "string")
273
+ return option.title;
274
+ const discriminator = isRecord(option.properties) && isRecord(option.properties.type) ? option.properties.type : undefined;
275
+ const fixed = discriminator?.const ?? (Array.isArray(discriminator?.enum) && discriminator.enum.length === 1 ? discriminator.enum[0] : undefined);
276
+ return typeof fixed === "string" ? fixed : `${parent}_option`;
277
+ }
278
+ /**
279
+ * The same schema with each part that repeats written once, under `$defs`, and
280
+ * referred to with `$ref` everywhere it appeared.
281
+ *
282
+ * A schema generated from an API contract often spells one definition out
283
+ * everywhere it is used: one newsletter API's post body repeats the same
284
+ * styling block 33 times, and its create-post tool came to 378 KB, where
285
+ * sharing the repeats leaves 37 KB. Nothing is lost: each part reads the
286
+ * same, and Claude Code and Codex both read fields that appear only under
287
+ * `$defs`. Parts under 200 bytes stay inline, so the schema still reads top
288
+ * to bottom.
289
+ *
290
+ * A schema with a `$ref` to anything but its own definitions is returned as
291
+ * it is, since moving a part could break that reference. The work is linear in
292
+ * the schema's size: each distinct part is serialized once.
293
+ */
294
+ export function shareRepeats(schema, options = {}) {
295
+ const minBytes = options.minBytes ?? SHARE_MIN_BYTES;
296
+ const key = !("$defs" in schema) && "definitions" in schema ? "definitions" : "$defs";
297
+ if ("$defs" in schema && "definitions" in schema)
298
+ return schema;
299
+ if (!refsOnlyInto(schema, `#/${key}/`))
300
+ return schema;
301
+ // Each distinct part once, by a signature built from its children's ids, so serializing is linear.
302
+ const parts = [];
303
+ const ids = new Map();
304
+ const build = (node, label) => {
305
+ if (!isRecord(node)) {
306
+ const json = JSON.stringify(node);
307
+ const known = ids.get(json);
308
+ if (known !== undefined)
309
+ return known;
310
+ parts.push({ template: node, bytes: Buffer.byteLength(json), label, movable: false, parents: new Map() });
311
+ ids.set(json, parts.length - 1);
312
+ return parts.length - 1;
313
+ }
314
+ const own = typeof node.title === "string" ? node.title : label;
315
+ const template = {};
316
+ const signature = [];
317
+ const children = [];
318
+ let bytes = 2 + Math.max(0, Object.keys(node).length - 1);
319
+ const child = (value, childLabel) => {
320
+ const id = build(value, childLabel);
321
+ children.push(id);
322
+ return { mark: { [CHILD]: id }, sig: `#${id}`, bytes: parts[id].bytes };
323
+ };
324
+ for (const [name, value] of Object.entries(node)) {
325
+ let entry;
326
+ if (ONE_SCHEMA.has(name) && (isRecord(value) || typeof value === "boolean")) {
327
+ entry = child(value, name === "items" ? `${own}_item` : own);
328
+ }
329
+ else if ((SCHEMA_LIST.has(name) || name === "items") && Array.isArray(value)) {
330
+ const list = value.map((item) => child(item, optionLabel(item, own)));
331
+ entry = { mark: list.map((item) => item.mark), sig: `[${list.map((item) => item.sig).join(",")}]`, bytes: 2 + Math.max(0, list.length - 1) + list.reduce((sum, item) => sum + item.bytes, 0) };
332
+ }
333
+ else if (SCHEMA_MAP.has(name) && isRecord(value)) {
334
+ const map = Object.entries(value).map(([field, sub]) => [JSON.stringify(field), child(sub, field)]);
335
+ entry = {
336
+ mark: Object.fromEntries(map.map(([field, item]) => [JSON.parse(field), item.mark])),
337
+ sig: `{${map.map(([field, item]) => `${field}:${item.sig}`).join(",")}}`,
338
+ bytes: 2 + Math.max(0, map.length - 1) + map.reduce((sum, [field, item]) => sum + Buffer.byteLength(field) + 1 + item.bytes, 0),
339
+ };
340
+ }
341
+ else {
342
+ const json = JSON.stringify(value);
343
+ entry = { mark: value, sig: json, bytes: Buffer.byteLength(json) };
344
+ }
345
+ template[name] = entry.mark;
346
+ signature.push(`${JSON.stringify(name)}:${entry.sig}`);
347
+ bytes += Buffer.byteLength(JSON.stringify(name)) + 1 + entry.bytes;
348
+ }
349
+ const sig = `{${signature.join(",")}}`;
350
+ const known = ids.get(sig);
351
+ if (known !== undefined)
352
+ return known;
353
+ const id = parts.length;
354
+ parts.push({ template, bytes, label: own, movable: !ANCHORED.some((anchor) => anchor in node), parents: new Map() });
355
+ ids.set(sig, id);
356
+ for (const c of children)
357
+ parts[c].parents.set(id, (parts[c].parents.get(id) ?? 0) + 1);
358
+ return id;
359
+ };
360
+ const { [key]: existingDefs, ...rest } = schema;
361
+ const root = build(rest, "");
362
+ const names = new Map();
363
+ const taken = new Set();
364
+ const defsOrder = [];
365
+ // Two definitions with the same body keep both names: references in the schema use either.
366
+ const aliases = [];
367
+ for (const [name, body] of Object.entries(isRecord(existingDefs) ? existingDefs : {})) {
368
+ const id = build(body, name);
369
+ if (names.has(id))
370
+ aliases.push([name, id]);
371
+ else {
372
+ names.set(id, name);
373
+ defsOrder.push(id);
374
+ }
375
+ taken.add(name);
376
+ }
377
+ // How many times each part would be written out, deciding the larger ones first: a part is always larger than
378
+ // anything inside it, so every part that could contain this one is already decided.
379
+ const appear = new Array(parts.length).fill(0);
380
+ appear[root] = 1;
381
+ for (const id of defsOrder)
382
+ appear[id] = Math.max(appear[id], 1);
383
+ const order = parts.map((_, id) => id).sort((a, b) => parts[b].bytes - parts[a].bytes);
384
+ for (const id of order) {
385
+ const part = parts[id];
386
+ for (const [parent, times] of part.parents)
387
+ appear[id] += times * (names.has(parent) ? 1 : appear[parent]);
388
+ if (id === root || names.has(id) || !part.movable || part.bytes < minBytes || appear[id] < 2)
389
+ continue;
390
+ let name = definitionName(part.label);
391
+ for (let n = 2; taken.has(name); n++)
392
+ name = `${definitionName(part.label)}_${n}`;
393
+ const ref = Buffer.byteLength(JSON.stringify({ $ref: `#/${key}/${name}` }));
394
+ if ((appear[id] - 1) * part.bytes - appear[id] * ref - name.length - 4 <= 0)
395
+ continue;
396
+ names.set(id, name);
397
+ taken.add(name);
398
+ defsOrder.push(id);
399
+ }
400
+ if (!names.size)
401
+ return schema;
402
+ const fill = (template) => {
403
+ if (Array.isArray(template))
404
+ return template.map(fill);
405
+ if (!isRecord(template))
406
+ return template;
407
+ const marked = template[CHILD];
408
+ if (marked !== undefined)
409
+ return emit(marked, false);
410
+ return Object.fromEntries(Object.entries(template).map(([name, value]) => [name, fill(value)]));
411
+ };
412
+ const emit = (id, asDefinition) => {
413
+ const name = names.get(id);
414
+ return name !== undefined && !asDefinition ? { $ref: `#/${key}/${name}` } : fill(parts[id].template);
415
+ };
416
+ const out = fill(parts[root].template);
417
+ out[key] = Object.fromEntries([
418
+ ...defsOrder.map((id) => [names.get(id), emit(id, true)]),
419
+ ...aliases.map(([name, id]) => [name, { $ref: `#/${key}/${names.get(id)}` }]),
420
+ ]);
421
+ // Nothing repeated, or only a definition that every use already refers to: the schema stays the object it was.
422
+ return schemaBytes(out) < schemaBytes(schema) ? out : schema;
423
+ }
424
+ /**
425
+ * A property that is only a reference into the schema's own definitions, read
426
+ * as the definition it points to, with its own words first.
427
+ */
428
+ export function resolveLocalRef(root, node) {
429
+ let current = node;
430
+ for (let hop = 0; hop < 8 && typeof current.$ref === "string"; hop++) {
431
+ const match = /^#\/(\$defs|definitions)\/([^/]+)$/.exec(current.$ref);
432
+ const defs = match ? root[match[1]] : undefined;
433
+ const target = match && isRecord(defs) ? defs[match[2].replace(/~1/g, "/").replace(/~0/g, "~")] : undefined;
434
+ if (!isRecord(target))
435
+ break;
436
+ const { $ref: _ref, ...own } = current;
437
+ current = { ...target, ...own };
438
+ }
439
+ return current;
440
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thenavidm/slipway",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Slipway, the TypeScript framework for MCP servers and agent-native CLIs. One tool definition ships an MCP server and a CLI, with write safety, typed results and release checks built in.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",