@thenavidm/slipway 0.1.3 → 0.1.4

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,15 @@
2
2
 
3
3
  What changed in Slipway, newest first.
4
4
 
5
+ ## 0.1.4, 2026-10-04: faster starts, cheaper results in Codex
6
+
7
+ - **A JSON Schema compiles on its tool's first call.** `jsonSchema()` compiled its validator as soon as a tool was defined, so a server paid for every schema before it could answer. On Teachable's 123 contract tools that held the first answer back by 118 ms. Building Stripe's 611 OpenAPI tools took 1,745 ms and now takes 69; GitHub's 1,230 took 646 ms and now take 60 (medians of three runs on one Mac). A tool's first call now compiles its own schema, a median of 3 ms on Stripe's and under 1 ms on GitHub's.
8
+ - **`slipway check` compiles every schema.** A schema that cannot compile, such as one with a broken `$ref`, used to stop the server at startup. It now fails the check, before release, and names the tool.
9
+ - **`--version` prints the bare version.** It printed `notes 1.0.0 (slipway 0.1.3)`, where every server built before Slipway prints `1.0.0`, so a script comparing versions broke on migration. `agent-context` still names the framework and its version.
10
+ - **`structuredContent` only for a tool with an output schema.** An object result went out as JSON text and again as `structuredContent`. Codex hands a model the structured copy in place of the text, as one escaped string, so on a measured Teachable call it read 211 more tokens than for the same JSON as text, and the copy also hid a `render` text or an image. An untyped result is now text alone; a typed one still carries its validated copy. `resultData()` in `@thenavidm/slipway/testing` reads either.
11
+ - **A built-in command explains itself with `--help`.** `install --help` failed asking for a client; it now lists the clients and flags. Every other built-in prints the general help instead of running.
12
+ - **Shorter CLI screens, measured in tokens.** An agent pays for every line it reads. On Teachable's 26 commands, counted with OpenAI's tokenizer: the command list went from 413 tokens to 335, a command's `--help` from 368 to 230, the general help from 802 to 639, and `agent-context --brief` from 2,200 to 912. A command's help now shows only the output flags that command can use, and the command list names the setting that turns hidden commands on.
13
+
5
14
  ## 0.1.3, 2026-10-04: npx picks the server by name
6
15
 
7
16
  - **`slipway check` matches npm's real rule.** npx picks a binary named after the package only when the binaries point to different files. When they share one file it starts whichever one the registry lists first, and the registry does not keep the published order: 23 published servers listed their MCP binary first and still started the CLI. 0.1.2's order check could not catch that. The check now requires a binary named after the package on a file of its own, and the README shows the one-line `src/npx.ts` it runs.
package/README.md CHANGED
@@ -83,7 +83,7 @@ They are the same program reading the same tool definitions, so anything one can
83
83
  - **Local data.** Reads can opt in to a cache, kept per account and cleared by any write. `data sync`, `data search` and `data sql` keep an offline, searchable copy of any list, in one private SQLite file with nothing to install.
84
84
  - **OpenAPI to tools.** `fromOpenAPI()` turns every operation in a document into a tool, with the risk its method implies, its tags as toolsets, and a hash pin that refuses a changed document. It turns 611 of the 612 operations in Stripe's API into tools, and 1,230 of GitHub's 1,232; the rest are file uploads or raw text.
85
85
  - **One command to install.** `<cli> install codex`, or `claude-code`, `claude-desktop`, `cursor`, `vscode` or `gemini`, adds the server to that client's own configuration and passes credentials on instead of writing them down.
86
- - **Typed results.** Declare an output schema and results go out as validated `structuredContent`. Object results are structured even without one.
86
+ - **Typed results.** Declare an output schema and results also go out as validated `structuredContent`. Without one, a result is compact text alone: Codex reads a structured copy in place of the text, and on a measured call that cost 211 more tokens.
87
87
  - **Contract tools.** A tool built from a pinned JSON contract joins the same list as a hand-written Zod tool, with `jsonSchema({...})`.
88
88
  - **Toolsets and a search surface** for large catalogs, so a client loads only what a person turns on.
89
89
  - **Typed errors and exit codes.** Every error carries its exit code and a hint: JSON on stderr in a terminal, a readable error result over MCP.
@@ -196,7 +196,7 @@ npx picks a binary named after the package only when the binaries point to diffe
196
196
 
197
197
  `notes-mcp` with no arguments serves MCP over stdio and stays silent on stdout. `notes-cli` with no arguments lists the commands. Any argument on either binary is a command, so a typo is reported instead of starting a server that waits on stdin.
198
198
 
199
- The context is built on the first call that needs it, never at startup. `--help` works with nothing configured, and the server answers a client at once and explains what is missing instead of exiting.
199
+ The context is built on the first call that needs it, never at startup, and so is each tool's JSON Schema validator: Stripe's 611 generated tools are ready in about 70 ms. `--help` works with nothing configured, and the server answers a client at once and explains what is missing instead of exiting.
200
200
 
201
201
  ## 3. Tools
202
202
 
@@ -224,7 +224,7 @@ The context is built on the first call that needs it, never at startup. `--help`
224
224
  | `render` | Text for the result when JSON is not the best way to read it |
225
225
  | `handler` | `(args, ctx) => result`. `ctx` is your context plus `signal`, `surface`, `env`, `progress`, `log` and `secrets` |
226
226
 
227
- A handler returns plain data. An object goes out as compact JSON text and as typed `structuredContent`. Return `content([...], data)` with `image()`, `audio()`, `file()` or `resourceLink()` for anything that is not text.
227
+ A handler returns plain data. An object goes out as compact JSON text, and as typed `structuredContent` too when the tool declares `output`. Return `content([...], data)` with `image()`, `audio()`, `file()` or `resourceLink()` for anything that is not text.
228
228
 
229
229
  Throw one of the error classes and the caller gets its exit code. `httpError(status, message)` maps an HTTP status in one line, and `UsageError`, `NotFoundError`, `AuthError`, `RateLimitError`, `ApiError` and `NotConfiguredError` cover the rest.
230
230
 
@@ -383,7 +383,7 @@ Override any operation's name or risk with `names` and `risk`, keep a subset wit
383
383
  | `<cli> <command> --help` | Its flags, choices, defaults, examples and risk |
384
384
  | `<cli> which <words>` | Find the command for a task, by what it does |
385
385
  | `<cli> schema <command>` | The JSON Schema an MCP client receives. `--output` for the result's |
386
- | `<cli> agent-context` | Commands, flags, risk, examples, exit codes and settings as JSON. `--brief` for names only |
386
+ | `<cli> agent-context` | Commands, flags, risk, examples, exit codes and settings as JSON. `--brief` for just the commands, which ones write or need `--confirm`, and the exit codes |
387
387
  | `<cli> doctor` | Check the setup. `--network` also calls the service |
388
388
  | `<cli> login` | How to connect an account |
389
389
  | `<cli> install <client>` | Add the MCP server to a client. See [Add it to a client](#10-add-it-to-a-client) |
@@ -492,10 +492,10 @@ Parity runs on both protocol revisions a client may open with. `slipway docs dis
492
492
  ## 13. Testing
493
493
 
494
494
  ```ts
495
- import { checkApp, cli, connect } from "@thenavidm/slipway/testing";
495
+ import { checkApp, cli, connect, resultData } from "@thenavidm/slipway/testing";
496
496
 
497
497
  const mcp = await connect(app, { env: { NOTES_API_KEY: "test" } });
498
- const result = await mcp.callTool("get_note", { id: 7 });
498
+ const note = resultData(await mcp.callTool("get_note", { id: 7 }));
499
499
  await mcp.close();
500
500
 
501
501
  const { code } = await cli(app, ["delete-note", "7"], { env: {} });
@@ -504,7 +504,7 @@ const { code } = await cli(app, ["delete-note", "7"], { env: {} });
504
504
  const report = await checkApp(app, { env: {} });
505
505
  ```
506
506
 
507
- `connect` talks to the real server over an in-memory transport, through the same stdio entry the binary runs. `cli` runs the real CLI with captured output. To stub the network, build the app with a context that returns a fake client.
507
+ `connect` talks to the real server over an in-memory transport, through the same stdio entry the binary runs. `resultData` reads what a call returned, typed or not. `cli` runs the real CLI with captured output. To stub the network, build the app with a context that returns a fake client.
508
508
 
509
509
  To test approval by a person, give `connect` an `elicit` answer, and pass `era: "modern"` for the 2026-07-28 revision or `clientInfo` to be a particular client:
510
510
 
@@ -673,7 +673,7 @@ Return `content([image(bytes, "image/png")], data)`. `audio()`, `file()` and `re
673
673
  <details>
674
674
  <summary><b>Do I need an output schema?</b></summary>
675
675
 
676
- No. An object result already goes out as `structuredContent`. Declare `output` when you want the result validated before it leaves the server and its shape advertised to clients, which lets a client use the data without parsing text.
676
+ No. Without one, an object result goes out as compact JSON text, which every client reads. Declare `output` when you want the result validated before it leaves the server, its shape advertised, and a typed `structuredContent` copy sent for clients that use data without parsing text. Codex reads that copy in place of the text, so a schema is worth declaring when something uses the shape.
677
677
 
678
678
  </details>
679
679
 
package/SKILL.md CHANGED
@@ -59,7 +59,7 @@ For a tool generated from an API contract, pass the operation's JSON Schema thro
59
59
 
60
60
  ## Results and errors
61
61
 
62
- - Return plain data. An object goes out as compact JSON text and as `structuredContent`.
62
+ - Return plain data. An object goes out as compact JSON text; with `output` declared, also as validated `structuredContent`.
63
63
  - Add `output` when the shape is stable, so results are validated and typed for clients.
64
64
  - Return `content([image(bytes, "image/png")], data)` for images, audio, files and links.
65
65
  - Throw `httpError(status, message)` for upstream failures, or `UsageError`, `NotFoundError`, `AuthError`, `RateLimitError`, `ApiError`, `NotConfiguredError`. Each carries its exit code.
package/dist/check.js CHANGED
@@ -81,6 +81,15 @@ export async function checkApp(app, options = {}) {
81
81
  const problem = meta?.(schema);
82
82
  if (problem)
83
83
  add("error", "schema", `Not valid JSON Schema 2020-12: ${problem}`, tool.name);
84
+ // A JSON Schema validator compiles on the tool's first call, so compile each one here instead.
85
+ for (const [which, candidate] of [["input", tool.schema], ["output", tool.output]]) {
86
+ try {
87
+ await candidate?.["~standard"].validate({});
88
+ }
89
+ catch (error) {
90
+ add("error", "schema", `The ${which} schema does not compile: ${error.message}`, tool.name);
91
+ }
92
+ }
84
93
  const bytes = schemaBytes(schema);
85
94
  totalBytes += bytes;
86
95
  if (!largest || bytes > largest.bytes)
@@ -11,6 +11,26 @@ export declare const EXIT_MEANINGS: Record<number, string>;
11
11
  export declare function agentContext(app: App, env: NodeJS.ProcessEnv, bin: string, options?: {
12
12
  brief?: boolean;
13
13
  }): {
14
+ name: string;
15
+ version: string;
16
+ description?: string | undefined;
17
+ usage: {
18
+ run: string;
19
+ help: string;
20
+ note: string;
21
+ agent_mode?: undefined;
22
+ confirm_flag?: undefined;
23
+ };
24
+ exit_codes: Record<number, string>;
25
+ hidden_commands?: number | undefined;
26
+ toolsets?: Record<string, string> | undefined;
27
+ commands: {
28
+ command: string;
29
+ title: string;
30
+ risk?: "destructive" | "write" | undefined;
31
+ requires_confirm?: boolean | undefined;
32
+ }[];
33
+ } | {
14
34
  name: string;
15
35
  title: string;
16
36
  version: string;
@@ -59,12 +79,7 @@ export declare function agentContext(app: App, env: NodeJS.ProcessEnv, bin: stri
59
79
  })[];
60
80
  toolsets?: Record<string, string> | undefined;
61
81
  hidden_commands: number;
62
- commands: ({
63
- command: string;
64
- title: string;
65
- risk: import("../tool.js").Risk;
66
- requires_confirm: boolean;
67
- } | {
82
+ commands: {
68
83
  command: string;
69
84
  tool: string;
70
85
  title: string;
@@ -93,5 +108,5 @@ export declare function agentContext(app: App, env: NodeJS.ProcessEnv, bin: stri
93
108
  description: string;
94
109
  command: string;
95
110
  }[] | undefined;
96
- })[];
111
+ }[];
97
112
  };
@@ -27,6 +27,25 @@ export function agentContext(app, env, bin, options = {}) {
27
27
  const policy = app.policy(env);
28
28
  const names = policyEnvNames(app.envPrefix);
29
29
  const tools = app.tools(env);
30
+ const hidden = app.allTools.length - tools.length;
31
+ const note = "--agent never confirms a write. A command that requires --confirm runs only when it is passed explicitly.";
32
+ if (options.brief) {
33
+ // Enough to pick a command: what each one is, and which write or need --confirm. The full read adds flags and settings.
34
+ return {
35
+ name: app.name,
36
+ version: app.version,
37
+ ...(app.description ? { description: app.description } : {}),
38
+ usage: { run: `${bin} <command> [flags]`, help: `${bin} <command> --help`, note },
39
+ exit_codes: EXIT_MEANINGS,
40
+ ...(hidden ? { hidden_commands: hidden, ...(app.definition.toolsets ? { toolsets: app.definition.toolsets } : {}) } : {}),
41
+ commands: tools.map((tool) => ({
42
+ command: tool.command,
43
+ title: tool.title,
44
+ ...(tool.risk !== "read" ? { risk: tool.risk } : {}),
45
+ ...(tool.requireConfirm ? { requires_confirm: true } : {}),
46
+ })),
47
+ };
48
+ }
30
49
  return {
31
50
  name: app.name,
32
51
  title: app.title,
@@ -39,7 +58,7 @@ export function agentContext(app, env, bin, options = {}) {
39
58
  help: `${bin} <command> --help`,
40
59
  agent_mode: "--agent",
41
60
  confirm_flag: "--confirm",
42
- note: "--agent never confirms a write. A command that requires --confirm runs only when it is passed explicitly.",
61
+ note,
43
62
  },
44
63
  exit_codes: EXIT_MEANINGS,
45
64
  global_flags: GLOBAL_FLAGS.map(([flag, description]) => ({ flag, description })),
@@ -59,36 +78,34 @@ export function agentContext(app, env, bin, options = {}) {
59
78
  { 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" },
60
79
  ],
61
80
  ...(app.definition.toolsets ? { toolsets: app.definition.toolsets } : {}),
62
- hidden_commands: app.allTools.length - tools.length,
63
- commands: tools.map((tool) => options.brief
64
- ? { command: tool.command, title: tool.title, risk: tool.risk, requires_confirm: tool.requireConfirm }
65
- : {
66
- command: tool.command,
67
- tool: tool.name,
68
- title: tool.title,
69
- description: tool.description,
70
- risk: tool.risk,
71
- requires_confirm: tool.requireConfirm,
72
- ...(tool.tags.length ? { toolsets: tool.tags } : {}),
73
- ...(tool.positional.length ? { positional: tool.positional } : {}),
74
- flags: flagsFor(tool.jsonSchema)
75
- .filter((flag) => flag.key !== "confirm")
76
- .map((flag) => ({
77
- flag: flag.flag,
78
- type: flag.kind,
79
- required: flag.required,
80
- ...(flag.repeatable ? { repeatable: true } : {}),
81
- ...(flag.choices ? { choices: flag.choices } : {}),
82
- ...(flag.default !== undefined ? { default: flag.default } : {}),
83
- ...(flag.help ? { description: flag.help } : {}),
84
- })),
85
- ...(tool.output ? { output_schema: outputJsonSchema(tool.output) } : {}),
86
- ...(tool.paginate ? { paginates: true } : {}),
87
- ...(tool.job && !tool.statusOf ? { job: { status_command: `${tool.command}-status`, background: "background" in tool.job } } : {}),
88
- ...(tool.statusOf ? { checks_jobs_of: tool.statusOf.replace(/_/g, "-") } : {}),
89
- ...(tool.examples.length
90
- ? { examples: tool.examples.map((example) => ({ description: example.description, command: exampleCommand(bin, tool, example.args) })) }
91
- : {}),
92
- }),
81
+ hidden_commands: hidden,
82
+ commands: tools.map((tool) => ({
83
+ command: tool.command,
84
+ tool: tool.name,
85
+ title: tool.title,
86
+ description: tool.description,
87
+ risk: tool.risk,
88
+ requires_confirm: tool.requireConfirm,
89
+ ...(tool.tags.length ? { toolsets: tool.tags } : {}),
90
+ ...(tool.positional.length ? { positional: tool.positional } : {}),
91
+ flags: flagsFor(tool.jsonSchema)
92
+ .filter((flag) => flag.key !== "confirm")
93
+ .map((flag) => ({
94
+ flag: flag.flag,
95
+ type: flag.kind,
96
+ required: flag.required,
97
+ ...(flag.repeatable ? { repeatable: true } : {}),
98
+ ...(flag.choices ? { choices: flag.choices } : {}),
99
+ ...(flag.default !== undefined ? { default: flag.default } : {}),
100
+ ...(flag.help ? { description: flag.help } : {}),
101
+ })),
102
+ ...(tool.output ? { output_schema: outputJsonSchema(tool.output) } : {}),
103
+ ...(tool.paginate ? { paginates: true } : {}),
104
+ ...(tool.job && !tool.statusOf ? { job: { status_command: `${tool.command}-status`, background: "background" in tool.job } } : {}),
105
+ ...(tool.statusOf ? { checks_jobs_of: tool.statusOf.replace(/_/g, "-") } : {}),
106
+ ...(tool.examples.length
107
+ ? { examples: tool.examples.map((example) => ({ description: example.description, command: exampleCommand(bin, tool, example.args) })) }
108
+ : {}),
109
+ })),
93
110
  };
94
111
  }
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * What a person sees before they know what to type.
3
+ *
4
+ * An agent reads this too, and pays for every token of it, so each screen
5
+ * shows what applies and points to the rest instead of repeating it.
3
6
  */
4
7
  import type { App } from "../app.js";
5
8
  import type { Tool } from "../tool.js";
6
9
  /** The words the CLI owns, which no tool command may take. */
7
10
  export declare const BUILTINS: readonly ["help", "tools", "schema", "agent-context", "which", "doctor", "login", "completion", "version", "data", "install"];
8
11
  export declare const GLOBAL_FLAGS: Array<[string, string]>;
9
- export declare function renderList(app: App, tools: readonly Tool[], bin: string): string;
12
+ export declare function renderList(app: App, tools: readonly Tool[], bin: string, env?: NodeJS.ProcessEnv): string;
10
13
  /** An example's arguments as the command someone would type. */
11
14
  export declare function exampleCommand(bin: string, tool: Tool, args: Record<string, unknown>): string;
12
15
  export declare function renderToolHelp(tool: Tool, bin: string): string;
package/dist/cli/help.js CHANGED
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * What a person sees before they know what to type.
3
+ *
4
+ * An agent reads this too, and pays for every token of it, so each screen
5
+ * shows what applies and points to the rest instead of repeating it.
3
6
  */
4
7
  import { EXIT } from "../errors.js";
5
- import { policyEnvNames, riskMark } from "../policy.js";
8
+ import { policyEnvNames, riskMark, visibility } from "../policy.js";
6
9
  import { firstSentence } from "../search.js";
7
10
  import { flagsFor } from "./flags.js";
8
11
  const COLUMN = 30;
@@ -29,12 +32,52 @@ function line(left, help) {
29
32
  return left.length < COLUMN ? [`${left.padEnd(COLUMN)}${help}`] : [left, `${" ".repeat(COLUMN)}${help}`];
30
33
  }
31
34
  function riskWords(tool) {
32
- const base = tool.risk === "read" ? "read only" : tool.risk === "write" ? "writes, reversible" : "public or irreversible";
33
- return tool.requireConfirm ? `${base}, runs only with --confirm` : base;
35
+ return tool.risk === "read" ? "read only" : tool.risk === "write" ? "writes, reversible" : "public or irreversible";
34
36
  }
35
- export function renderList(app, tools, bin) {
37
+ /** The output flags a command can use: the four every command takes, and those its kind adds. */
38
+ function outputFlagsFor(tool) {
39
+ const wanted = new Set(["--json", "--compact", "--select <a,b.c>", "--agent"]);
40
+ if (tool.risk !== "read")
41
+ wanted.add("--dry-run");
42
+ if (tool.cache)
43
+ wanted.add("--refresh");
44
+ if (tool.paginate || tool.sync)
45
+ for (const flag of ["--jsonl", "--csv / --tsv", "--quiet"])
46
+ wanted.add(flag);
47
+ return GLOBAL_FLAGS.filter(([flag]) => wanted.has(flag));
48
+ }
49
+ /** Why some commands are not listed, and the setting that lists them. */
50
+ function hiddenNote(app, env) {
51
+ const policy = app.policy(env);
52
+ const names = policyEnvNames(app.envPrefix);
53
+ const off = new Set();
54
+ let byToolset = 0;
55
+ let byReadOnly = 0;
56
+ for (const tool of app.allTools) {
57
+ const seen = visibility(tool, policy);
58
+ if (seen.visible)
59
+ continue;
60
+ if (seen.reason === "read-only")
61
+ byReadOnly += 1;
62
+ else {
63
+ byToolset += 1;
64
+ for (const tag of tool.tags)
65
+ if (policy.toolsets === "all" || !policy.toolsets.has(tag))
66
+ off.add(tag);
67
+ }
68
+ }
69
+ const lines = [];
70
+ if (byToolset) {
71
+ const sets = [...off].sort();
72
+ lines.push(` ${byToolset} more ${byToolset === 1 ? "command is" : "commands are"} in ${sets.join(", ")}, off: ${names.toolsets}=${sets.join(",")} turns ${byToolset === 1 ? "it" : "them"} on.`);
73
+ }
74
+ if (byReadOnly)
75
+ lines.push(` ${byReadOnly} ${byReadOnly === 1 ? "write is" : "writes are"} hidden by ${names.readOnly}=1.`);
76
+ return lines.length ? [...lines, ``] : [];
77
+ }
78
+ export function renderList(app, tools, bin, env = process.env) {
36
79
  const width = Math.max(10, ...tools.map((tool) => tool.command.length)) + 2;
37
- const lines = [``, `${app.title} ${app.version}${app.description ? `: ${app.description}` : ""}`, ``];
80
+ const lines = [``, `${bin} ${app.version}: ${tools.length} ${tools.length === 1 ? "command" : "commands"}`];
38
81
  const toolsets = app.definition.toolsets ?? {};
39
82
  const groups = new Map();
40
83
  for (const tool of tools) {
@@ -43,19 +86,15 @@ export function renderList(app, tools, bin) {
43
86
  }
44
87
  const ordered = [...groups.entries()].sort(([a], [b]) => (a === "" ? -1 : b === "" ? 1 : a.localeCompare(b)));
45
88
  const grouped = ordered.length > 1 || (ordered[0]?.[0] ?? "") !== "";
46
- lines.push(`Commands (${tools.length})`);
89
+ if (!grouped)
90
+ lines.push(``);
47
91
  for (const [group, members] of ordered) {
48
92
  if (grouped)
49
93
  lines.push(``, ` ${group || "general"}${group && toolsets[group] ? `: ${toolsets[group]}` : ""}`);
50
94
  for (const tool of members)
51
95
  lines.push(` ${riskMark(tool.risk)} ${tool.command.padEnd(width)}${tool.title}`);
52
96
  }
53
- lines.push(``, ` * writes ! public or irreversible`, ``, ` ${bin} <command> --help what a command takes, with examples`, ` ${bin} which <words> find the command for a task`, ` ${bin} schema <command> the JSON Schema an MCP client sees`, ` ${bin} agent-context everything above, as JSON for an agent`, ` ${bin} doctor check the setup`, ` ${bin} install <client> add it to an MCP client: codex, claude-code, cursor…`, ``);
54
- const hidden = app.allTools.length - tools.length;
55
- if (hidden > 0) {
56
- const names = policyEnvNames(app.envPrefix);
57
- lines.push(` ${hidden} more ${hidden === 1 ? "command is" : "commands are"} off: see ${names.readOnly} and ${names.toolsets} in \`${bin} help\`.`, ``);
58
- }
97
+ lines.push(``, ` * writes ! public or irreversible`, ``, ` ${bin} <command> --help what one takes, with examples`, ` ${bin} which <words> find the command for a task`, ` ${bin} --help flags, settings and setup`, ``, ...hiddenNote(app, env));
59
98
  return lines.join("\n");
60
99
  }
61
100
  function shellQuote(value) {
@@ -108,9 +147,10 @@ export function renderToolHelp(tool, bin) {
108
147
  ]
109
148
  .filter(Boolean)
110
149
  .join(" ");
111
- const lines = [``, `${tool.title}`, ``, tool.description, ``, `Usage:`, ` ${usage}`, ``];
112
- const describe = (list, heading) => {
113
- if (!list.length)
150
+ // The title is already on the command list; the description says more.
151
+ const lines = [``, tool.description, ``, `Usage:`, ` ${usage}`, ``];
152
+ const describe = (list, heading, after = []) => {
153
+ if (!list.length && !after.length)
114
154
  return;
115
155
  lines.push(`${heading}:`);
116
156
  for (const flag of list) {
@@ -119,13 +159,10 @@ export function renderToolHelp(tool, bin) {
119
159
  .join(" ");
120
160
  lines.push(...line(` ${flag.flag}${placeholder(flag)}`, [flag.help, extra].filter(Boolean).join(" ")));
121
161
  }
122
- lines.push(``);
162
+ lines.push(...after, ``);
123
163
  };
124
- describe(required, "Required");
164
+ describe(required, "Required", tool.requireConfirm ? line(" --confirm", "it runs only with this; --agent never adds it") : []);
125
165
  describe(optional, "Options");
126
- if (tool.requireConfirm) {
127
- lines.push(`Safety:`, ...line(" --confirm", "required: this runs only when you mean it"), ``);
128
- }
129
166
  if (tool.paginate) {
130
167
  lines.push(`Pages:`, ...line(" --all", "follow every page and print all items"), ...line(" --max-items <n>", "stop after this many items"), ``);
131
168
  }
@@ -139,68 +176,64 @@ export function renderToolHelp(tool, bin) {
139
176
  lines.push(` # ${example.description}`, ` ${exampleCommand(bin, tool, example.args)}`, ``);
140
177
  }
141
178
  lines.push(`Output:`);
142
- for (const [flag, help] of GLOBAL_FLAGS)
179
+ for (const [flag, help] of outputFlagsFor(tool))
143
180
  lines.push(...line(` ${flag}`, help));
144
181
  lines.push(``, `Risk: ${riskWords(tool)}`, ``);
145
182
  return lines.join("\n");
146
183
  }
147
184
  export function renderGeneralHelp(app, bin) {
148
185
  const names = policyEnvNames(app.envPrefix);
186
+ const cache = app.allTools.some((tool) => tool.cache);
187
+ const sync = app.allTools.some((tool) => tool.sync);
188
+ const jobs = app.allTools.some((tool) => tool.job);
189
+ const commands = [
190
+ [bin, "list the commands"],
191
+ [`${bin} <command> --help`, "what one takes, with examples"],
192
+ [`${bin} which <words>`, "find the command for a task"],
193
+ [`${bin} schema <command>`, "its JSON Schema; --output for the result's"],
194
+ [`${bin} agent-context`, "all of this as JSON; --brief for less"],
195
+ [`${bin} doctor [--network]`, "check the setup and say what is wrong"],
196
+ [`${bin} login`, "how to connect an account"],
197
+ [`${bin} install <client>`, "add the server to claude-code, codex, claude-desktop, cursor, vscode or gemini"],
198
+ [`${bin} completion <shell>`, "tab completion for bash, zsh or fish"],
199
+ ...(cache || sync ? [[`${bin} data`, "what is kept on this machine; data clear [<command>] deletes it"]] : []),
200
+ ...(sync
201
+ ? [
202
+ [`${bin} data sync <command>`, "copy every page of a list to this machine"],
203
+ [`${bin} data search <words>`, "search synced records offline (--in <command>)"],
204
+ [`${bin} data sql "<select>"`, "query local data with read-only SQL"],
205
+ ]
206
+ : []),
207
+ [app.bins.mcp, "the MCP server over stdio; --http [--port N] for HTTP"],
208
+ ];
209
+ const settings = [
210
+ ...(app.definition.settings ?? []).map((setting) => [setting.env, setting.description]),
211
+ [`${names.readOnly}=1`, "hide and refuse every write"],
212
+ [`${names.allowDestructive}=0`, "keep writes, refuse the irreversible ones"],
213
+ [`${names.toolsets}=a,b`, "only these toolsets, or all"],
214
+ [`${names.surface}=search`, "MCP serves three tools that find, describe and run the rest"],
215
+ [`${names.auditLog}=<file>`, "log every attempted write to this file"],
216
+ [`${names.toolTimeoutMs}=<ms>`, "give up on any tool after this long"],
217
+ [`${names.confirm}=model`, "confirm: true alone confirms, for an agent with no person to ask"],
218
+ ...(cache ? [[`${names.cache}=0`, "never answer from the local cache"]] : []),
219
+ ...(cache || sync ? [[`${names.dataDir}=<dir>`, "keep local data in this folder"]] : []),
220
+ ];
221
+ // Flags that cannot apply here (jobs, the cache) are left out; agent-context lists every one.
222
+ const flags = GLOBAL_FLAGS.map(([flag]) => flag).filter((flag) => flag !== "--agent" && (flag !== "--wait" || jobs) && (flag !== "--refresh" || cache));
223
+ const width = Math.max(...[...commands, ...settings].map(([left]) => left.length)) + 3;
224
+ const row = ([left, help]) => ` ${left.padEnd(width)}${help}`;
149
225
  const lines = [
150
226
  ``,
151
227
  `${app.title} ${app.version}${app.description ? `: ${app.description}` : ""}`,
152
228
  ``,
153
- `Usage:`,
154
- ` ${app.bins.mcp} run the MCP server over stdio (what an MCP client launches)`,
155
- ` ${app.bins.mcp} --http [--port N] run it over HTTP`,
156
- ` ${bin} list every command`,
157
- ` ${bin} <command> [flags] run one`,
229
+ ...commands.map(row),
158
230
  ``,
159
- `Commands:`,
160
- ...line(" <command> --help", "what a command takes, with examples"),
161
- ...line(" which <words>", "find the command for a task"),
162
- ...line(" schema <command>", "the JSON Schema an MCP client sees (--output for the result's)"),
163
- ...line(" agent-context", "commands, flags, risk, exit codes and settings as JSON"),
164
- ...line(" doctor [--network]", "check the setup and say what is wrong"),
165
- ...line(" login", "how to connect an account"),
166
- ...line(" install <client>", "add this server to claude-code, codex, claude-desktop, cursor, vscode or gemini"),
167
- ...line(" completion <shell>", "tab completion for bash, zsh or fish"),
168
- ...line(" version", "print the version"),
231
+ `Flags: ${flags.join(", ")}, and --agent for compact JSON with no prompts, which never confirms a write.`,
169
232
  ``,
170
- ...(app.allTools.some((tool) => tool.cache || tool.sync)
171
- ? [
172
- `Local data:`,
173
- ...line(" data", "what is kept on this machine, and where"),
174
- ...(app.allTools.some((tool) => tool.sync)
175
- ? [
176
- ...line(" data sync <command>", "copy every page of a list to this machine"),
177
- ...line(" data search <words>", "search synced records offline (--in <command>)"),
178
- ...line(' data sql "<select>"', "query local data with read-only SQL"),
179
- ]
180
- : []),
181
- ...line(" data clear [<command>]", "delete this account's local data (--cache for cached results only)"),
182
- ``,
183
- ]
184
- : []),
185
- `Output flags, on any command:`,
186
- ...GLOBAL_FLAGS.flatMap(([flag, help]) => line(` ${flag}`, help)),
187
- ``,
188
- ...(app.definition.settings?.length
189
- ? [`${app.title} settings:`, ...app.definition.settings.flatMap((setting) => line(` ${setting.env}`, setting.description)), ``]
190
- : []),
191
233
  `Settings:`,
192
- ...line(` ${names.readOnly}=1`, "hide and refuse every write"),
193
- ...line(` ${names.allowDestructive}=0`, "keep writes, refuse the irreversible ones"),
194
- ...line(` ${names.toolsets}=a,b`, "only these toolsets (or all)"),
195
- ...line(` ${names.surface}=search`, "MCP lists three tools that find, describe and run the rest"),
196
- ...line(` ${names.auditLog}=<file>`, "append every attempted write to this file"),
197
- ...line(` ${names.toolTimeoutMs}=<ms>`, "give up on any tool after this long"),
198
- ...line(` ${names.confirm}=model`, "let confirm: true alone confirm, for an agent with no person to ask"),
199
- ...(app.allTools.some((tool) => tool.cache) ? line(` ${names.cache}=0`, "never answer from the local cache") : []),
200
- ...(app.allTools.some((tool) => tool.cache || tool.sync) ? line(` ${names.dataDir}=<dir>`, "keep local data in this folder") : []),
234
+ ...settings.map(row),
201
235
  ``,
202
- `Exit codes:`,
203
- ` ${EXIT.ok} ok ${EXIT.usage} usage or refused write ${EXIT.notFound} not found ${EXIT.auth} auth ${EXIT.api} API ${EXIT.rateLimited} rate limited ${EXIT.notConfigured} nothing configured`,
236
+ `Exit codes: ${EXIT.ok} ok, ${EXIT.error} unexpected error, ${EXIT.usage} usage or refused write, ${EXIT.notFound} not found, ${EXIT.auth} auth, ${EXIT.api} API, ${EXIT.rateLimited} rate limited, ${EXIT.notConfigured} nothing configured`,
204
237
  ``,
205
238
  ];
206
239
  if (app.definition.links?.repository)
@@ -3,6 +3,8 @@
3
3
  */
4
4
  import type { App, CliIO } from "../app.js";
5
5
  import type { Format } from "./output.js";
6
+ /** What `install --help` prints. */
7
+ export declare function installHelp(app: App, bin: string): string;
6
8
  export declare function runInstall(app: App, io: CliIO, tokens: string[], options: {
7
9
  agent: boolean;
8
10
  dryRun: boolean;
@@ -21,6 +21,25 @@ function describe(plan) {
21
21
  lines.push(...(typeof plan.entry === "string" ? plan.entry : JSON.stringify({ [plan.name]: plan.entry }, null, 2)).split("\n").map((line) => ` ${line}`));
22
22
  return lines.join("\n");
23
23
  }
24
+ /** What `install --help` prints. */
25
+ export function installHelp(app, bin) {
26
+ return [
27
+ ``,
28
+ `Usage: ${bin} install <client> [flags]`,
29
+ ``,
30
+ `Adds ${app.title} to an MCP client in that client's own format, and keeps a backup of any file it changes.`,
31
+ ``,
32
+ `Clients: ${Object.keys(CLIENTS).join(", ")}`,
33
+ ``,
34
+ `Flags:`,
35
+ ` --scope user|project where the client keeps it, for a client that has both`,
36
+ ` --name <name> the server's name in the client (default: ${app.name})`,
37
+ ` --copy-env copy credentials into the client's file, for a client that sees no environment`,
38
+ ` --local start this copy on disk instead of the published package`,
39
+ ` --dry-run show the change without making it`,
40
+ ``,
41
+ ].join("\n");
42
+ }
24
43
  export async function runInstall(app, io, tokens, options) {
25
44
  const client = tokens.find((token) => !token.startsWith("--") && !["--scope", "--name"].includes(tokens[tokens.indexOf(token) - 1] ?? ""));
26
45
  const ids = Object.keys(CLIENTS);
package/dist/cli/run.js CHANGED
@@ -14,7 +14,7 @@ import { visibility, policyEnvNames } from "../policy.js";
14
14
  import { outputJsonSchema } from "../schema.js";
15
15
  import { didYouMean, searchTools } from "../search.js";
16
16
  import { completionScript } from "./completion.js";
17
- import { agentContext, SLIPWAY_VERSION } from "./context.js";
17
+ import { agentContext } from "./context.js";
18
18
  import { flagsFor, missingRequired, parseJsonValue, parseToolArgs } from "./flags.js";
19
19
  import { BUILTINS, renderGeneralHelp, renderList, renderToolHelp, toolLine } from "./help.js";
20
20
  import { formatOutput } from "./output.js";
@@ -151,7 +151,7 @@ export async function runCli(app, argv, partial = {}) {
151
151
  return printVersion(app, io);
152
152
  if (globals.help)
153
153
  return print(io, renderGeneralHelp(app, io.bin));
154
- return print(io, renderList(app, app.tools(io.env), io.bin));
154
+ return print(io, renderList(app, app.tools(io.env), io.bin, io.env));
155
155
  }
156
156
  if (builtin)
157
157
  return await runBuiltin(app, io, command, rest, globals);
@@ -185,17 +185,23 @@ function print(io, text) {
185
185
  io.stdout(text.endsWith("\n") ? text : `${text}\n`);
186
186
  return EXIT.ok;
187
187
  }
188
+ /** The bare version, which scripts compare. `agent-context` names the framework and its version. */
188
189
  function printVersion(app, io) {
189
- return print(io, `${app.name} ${app.version} (slipway ${SLIPWAY_VERSION})`);
190
+ return print(io, app.version);
190
191
  }
191
192
  function json(globals, value) {
192
193
  return globals.format === "compact" ? JSON.stringify(value) : JSON.stringify(value, null, 2);
193
194
  }
194
195
  async function runBuiltin(app, io, command, rest, globals) {
196
+ // `<built-in> --help` explains the command instead of running it.
197
+ if (globals.help && command === "install")
198
+ return print(io, (await import("./install.js")).installHelp(app, io.bin));
199
+ if (globals.help && command !== "help")
200
+ return print(io, renderGeneralHelp(app, io.bin));
195
201
  const target = rest.find((token) => !token.startsWith("-"));
196
202
  switch (command) {
197
203
  case "tools":
198
- return print(io, renderList(app, app.tools(io.env), io.bin));
204
+ return print(io, renderList(app, app.tools(io.env), io.bin, io.env));
199
205
  case "version":
200
206
  return printVersion(app, io);
201
207
  case "help": {
package/dist/result.d.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * Turning what a handler returns into what a client receives.
3
3
  *
4
- * A handler returns plain data. An object goes out twice: as compact JSON text,
5
- * which every client can show a model, and as `structuredContent`, which a
6
- * client can use without parsing text. Compact, because the reader of that text
7
- * is usually a model paying for every character, not a person.
4
+ * A handler returns plain data. An object goes out as compact JSON text, which
5
+ * every client can show a model. Compact, because the reader of that text is
6
+ * usually a model paying for every character, not a person.
7
+ *
8
+ * Only a tool that declares an output schema also sends `structuredContent`.
9
+ * Codex hands a model the structured copy in place of the text, as one escaped
10
+ * string: on a 700-character result that cost 211 more tokens than the text,
11
+ * and it hides a `render` text or an image the same way. With a schema the
12
+ * copy is typed and validated data a client can use; without one it only costs.
8
13
  */
9
14
  import type { CallToolResult, ContentBlock } from "@modelcontextprotocol/server";
10
15
  import type { SlipwayError } from "./errors.js";
package/dist/result.js CHANGED
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * Turning what a handler returns into what a client receives.
3
3
  *
4
- * A handler returns plain data. An object goes out twice: as compact JSON text,
5
- * which every client can show a model, and as `structuredContent`, which a
6
- * client can use without parsing text. Compact, because the reader of that text
7
- * is usually a model paying for every character, not a person.
4
+ * A handler returns plain data. An object goes out as compact JSON text, which
5
+ * every client can show a model. Compact, because the reader of that text is
6
+ * usually a model paying for every character, not a person.
7
+ *
8
+ * Only a tool that declares an output schema also sends `structuredContent`.
9
+ * Codex hands a model the structured copy in place of the text, as one escaped
10
+ * string: on a 700-character result that cost 211 more tokens than the text,
11
+ * and it hides a `render` text or an image the same way. With a schema the
12
+ * copy is typed and validated data a client can use; without one it only costs.
8
13
  */
9
14
  const CONTENT = Symbol.for("slipway.content");
10
15
  export function content(parts, data) {
@@ -48,7 +53,8 @@ export function toCallToolResult(tool, value, secrets) {
48
53
  if (isContentResult(value)) {
49
54
  const parts = value.parts.map((part) => part.type === "text" ? { ...part, text: secrets.redact(part.text) } : part);
50
55
  const data = secrets.redactDeep(value.data);
51
- const structured = data !== undefined && (isPlainObject(data) || tool.output !== undefined);
56
+ // Without a schema the parts are the result, and the data is for a terminal.
57
+ const structured = data !== undefined && tool.output !== undefined;
52
58
  return structured ? { content: parts, structuredContent: data } : { content: parts };
53
59
  }
54
60
  const data = secrets.redactDeep(value);
@@ -62,12 +68,9 @@ export function toCallToolResult(tool, value, secrets) {
62
68
  : { content: [text(rendered ?? data)] };
63
69
  }
64
70
  const body = rendered ?? (typeof data === "object" ? JSON.stringify(data) : String(data));
65
- // An object is always typed data. Anything else only is when the tool declares
66
- // an output schema, because the protocol needs a schema to describe it.
67
- if (isPlainObject(data) || tool.output !== undefined) {
68
- return { content: [text(body)], structuredContent: data };
69
- }
70
- return { content: [text(body)] };
71
+ return tool.output !== undefined
72
+ ? { content: [text(body)], structuredContent: data }
73
+ : { content: [text(body)] };
71
74
  }
72
75
  export function errorResult(error, secrets) {
73
76
  return { isError: true, content: [text(secrets.redact(JSON.stringify(secrets.redactDeep(error.toJSON()))))] };
package/dist/schema.d.ts CHANGED
@@ -33,6 +33,11 @@ export type Issue = {
33
33
  *
34
34
  * This is how a tool generated from an OpenAPI document or a pinned contract
35
35
  * joins the same tool list as hand-written ones.
36
+ *
37
+ * The validator compiles on the tool's first call, not when the server starts.
38
+ * Compiling all 123 schemas of one server up front held its first answer back
39
+ * by 118 ms, for tools most sessions never call. `slipway check` compiles every
40
+ * one, so a schema that cannot compile still fails before release.
36
41
  */
37
42
  export declare function jsonSchema<T = Record<string, unknown>>(schema: JsonSchema): Schema<T, T>;
38
43
  /** The input of a tool that takes nothing. */
package/dist/schema.js CHANGED
@@ -14,9 +14,22 @@ const TARGET = { target: "draft-2020-12" };
14
14
  *
15
15
  * This is how a tool generated from an OpenAPI document or a pinned contract
16
16
  * joins the same tool list as hand-written ones.
17
+ *
18
+ * The validator compiles on the tool's first call, not when the server starts.
19
+ * Compiling all 123 schemas of one server up front held its first answer back
20
+ * by 118 ms, for tools most sessions never call. `slipway check` compiles every
21
+ * one, so a schema that cannot compile still fails before release.
17
22
  */
18
23
  export function jsonSchema(schema) {
19
- return fromJsonSchema(schema);
24
+ let compiled;
25
+ return {
26
+ "~standard": {
27
+ version: 1,
28
+ vendor: "mcp",
29
+ jsonSchema: { input: () => schema, output: () => schema },
30
+ validate: (value) => (compiled ??= fromJsonSchema(schema))["~standard"].validate(value),
31
+ },
32
+ };
20
33
  }
21
34
  /** The input of a tool that takes nothing. */
22
35
  export function emptyInput() {
package/dist/server.js CHANGED
@@ -151,7 +151,7 @@ function registerSearchSurface(server, app, env, policy) {
151
151
  summary: firstSentence(tool.description),
152
152
  }));
153
153
  const data = { query, count: matches.length, tools: matches };
154
- return { content: [{ type: "text", text: JSON.stringify(data) }], structuredContent: data };
154
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
155
155
  });
156
156
  register("describe_tool", {
157
157
  title: "Describe a tool",
@@ -172,7 +172,7 @@ function registerSearchSurface(server, app, env, policy) {
172
172
  ...(tool.output ? { output_schema: outputJsonSchema(tool.output) } : {}),
173
173
  ...(tool.examples.length ? { examples: tool.examples } : {}),
174
174
  };
175
- return { content: [{ type: "text", text: JSON.stringify(data) }], structuredContent: data };
175
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
176
176
  });
177
177
  register("call_tool", {
178
178
  title: "Run a tool",
package/dist/testing.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Helpers for testing an app the way its users reach it.
3
3
  *
4
- * import { connect, cli } from "@thenavidm/slipway/testing";
4
+ * import { connect, cli, resultData } from "@thenavidm/slipway/testing";
5
5
  *
6
6
  * const mcp = await connect(app, { env });
7
- * const result = await mcp.callTool("get_profile", { actor: "alice" });
7
+ * const profile = resultData(await mcp.callTool("get_profile", { actor: "alice" }));
8
8
  *
9
9
  * const { code, stdout } = await cli(app, ["get-profile", "alice", "--json"], { env });
10
10
  */
@@ -21,6 +21,17 @@ export type { ConnectOptions, ElicitAnswer, ElicitRequest, ListedTool, RpcClient
21
21
  export declare function connect(app: App, options?: ConnectOptions & {
22
22
  env?: NodeJS.ProcessEnv;
23
23
  }): Promise<RpcClient>;
24
+ /**
25
+ * The data a tool call returned: its `structuredContent` when the tool declares
26
+ * an output schema, otherwise the JSON in its first text block.
27
+ */
28
+ export declare function resultData<T = any>(result: {
29
+ content?: ReadonlyArray<{
30
+ type: string;
31
+ text?: string;
32
+ }>;
33
+ structuredContent?: unknown;
34
+ }): T;
24
35
  export type CliRun = {
25
36
  code: number;
26
37
  stdout: string;
package/dist/testing.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Helpers for testing an app the way its users reach it.
3
3
  *
4
- * import { connect, cli } from "@thenavidm/slipway/testing";
4
+ * import { connect, cli, resultData } from "@thenavidm/slipway/testing";
5
5
  *
6
6
  * const mcp = await connect(app, { env });
7
- * const result = await mcp.callTool("get_profile", { actor: "alice" });
7
+ * const profile = resultData(await mcp.callTool("get_profile", { actor: "alice" }));
8
8
  *
9
9
  * const { code, stdout } = await cli(app, ["get-profile", "alice", "--json"], { env });
10
10
  */
@@ -20,6 +20,18 @@ export function connect(app, options = {}) {
20
20
  const { env, ...rest } = options;
21
21
  return connectInMemory(app, env ?? {}, rest);
22
22
  }
23
+ /**
24
+ * The data a tool call returned: its `structuredContent` when the tool declares
25
+ * an output schema, otherwise the JSON in its first text block.
26
+ */
27
+ export function resultData(result) {
28
+ if (result.structuredContent !== undefined)
29
+ return result.structuredContent;
30
+ const first = result.content?.find((part) => part.type === "text");
31
+ if (first?.text === undefined)
32
+ throw new Error("The result has no structured content and no text.");
33
+ return JSON.parse(first.text);
34
+ }
23
35
  /** Run the CLI with captured output. Nothing reaches the real terminal. */
24
36
  export async function cli(app, argv, options = {}) {
25
37
  let stdout = "";
package/dist/tool.d.ts CHANGED
@@ -131,7 +131,7 @@ export type ToolDefinition<Ctx, I extends Schema, O extends Schema | undefined>
131
131
  cache?: CacheOptions;
132
132
  /** This read lists records worth keeping locally: `data sync` copies every page, `data search` finds them. */
133
133
  sync?: SyncOptions;
134
- /** Text for the result, when JSON is not the best way to read it. Typed data still goes out as `structuredContent`. */
134
+ /** Text for the result, when JSON is not the best way to read it. With `output` declared, the data also goes out as `structuredContent`. */
135
135
  render?: (result: Returns<O>) => string;
136
136
  handler: (args: InferOutput<I>, ctx: ToolContext<Ctx>) => Returns<O> | Promise<Returns<O>>;
137
137
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thenavidm/slipway",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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",