@thenavidm/slipway 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +757 -0
  5. package/SECURITY.md +33 -0
  6. package/SKILL.md +136 -0
  7. package/dist/app.d.ts +210 -0
  8. package/dist/app.js +364 -0
  9. package/dist/bin.d.ts +14 -0
  10. package/dist/bin.js +178 -0
  11. package/dist/check.d.ts +52 -0
  12. package/dist/check.js +313 -0
  13. package/dist/cli/completion.d.ts +5 -0
  14. package/dist/cli/completion.js +72 -0
  15. package/dist/cli/context.d.ts +97 -0
  16. package/dist/cli/context.js +94 -0
  17. package/dist/cli/data.d.ts +17 -0
  18. package/dist/cli/data.js +120 -0
  19. package/dist/cli/flags.d.ts +35 -0
  20. package/dist/cli/flags.js +208 -0
  21. package/dist/cli/help.d.ts +15 -0
  22. package/dist/cli/help.js +213 -0
  23. package/dist/cli/install.d.ts +10 -0
  24. package/dist/cli/install.js +65 -0
  25. package/dist/cli/output.d.ts +22 -0
  26. package/dist/cli/output.js +159 -0
  27. package/dist/cli/run.d.ts +10 -0
  28. package/dist/cli/run.js +343 -0
  29. package/dist/confirm.d.ts +90 -0
  30. package/dist/confirm.js +166 -0
  31. package/dist/data.d.ts +109 -0
  32. package/dist/data.js +324 -0
  33. package/dist/docs.d.ts +13 -0
  34. package/dist/docs.js +66 -0
  35. package/dist/doctor.d.ts +12 -0
  36. package/dist/doctor.js +100 -0
  37. package/dist/entry.d.ts +11 -0
  38. package/dist/entry.js +34 -0
  39. package/dist/errors.d.ts +96 -0
  40. package/dist/errors.js +153 -0
  41. package/dist/guard.d.ts +46 -0
  42. package/dist/guard.js +89 -0
  43. package/dist/index.d.ts +27 -0
  44. package/dist/index.js +16 -0
  45. package/dist/install.d.ts +98 -0
  46. package/dist/install.js +325 -0
  47. package/dist/jobs.d.ts +143 -0
  48. package/dist/jobs.js +247 -0
  49. package/dist/openapi.d.ts +127 -0
  50. package/dist/openapi.js +549 -0
  51. package/dist/pages.d.ts +22 -0
  52. package/dist/pages.js +61 -0
  53. package/dist/policy.d.ts +66 -0
  54. package/dist/policy.js +74 -0
  55. package/dist/redact.d.ts +18 -0
  56. package/dist/redact.js +60 -0
  57. package/dist/result.d.ts +44 -0
  58. package/dist/result.js +74 -0
  59. package/dist/rpc.d.ts +69 -0
  60. package/dist/rpc.js +120 -0
  61. package/dist/schema.d.ts +99 -0
  62. package/dist/schema.js +192 -0
  63. package/dist/search.d.ts +18 -0
  64. package/dist/search.js +119 -0
  65. package/dist/serve.d.ts +30 -0
  66. package/dist/serve.js +149 -0
  67. package/dist/server.d.ts +20 -0
  68. package/dist/server.js +244 -0
  69. package/dist/sync.d.ts +35 -0
  70. package/dist/sync.js +119 -0
  71. package/dist/testing.d.ts +35 -0
  72. package/dist/testing.js +41 -0
  73. package/dist/tool.d.ts +194 -0
  74. package/dist/tool.js +151 -0
  75. package/dist/util.d.ts +12 -0
  76. package/dist/util.js +35 -0
  77. package/package.json +89 -0
@@ -0,0 +1,120 @@
1
+ /**
2
+ * `data`: the local data file from a terminal.
3
+ *
4
+ * data what is kept, where, and for which account
5
+ * data sync <command> [flags] copy every page of a list to this machine
6
+ * data search <words> search synced records offline
7
+ * data sql "<select>" query it with read-only SQL
8
+ * data clear [<command>] delete this account's local data
9
+ */
10
+ import { EXIT, UsageError } from "../errors.js";
11
+ import { syncTool } from "../sync.js";
12
+ import { flagsFor, parseToolArgs } from "./flags.js";
13
+ import { formatOutput } from "./output.js";
14
+ const SUBCOMMANDS = ["status", "sync", "search", "sql", "clear"];
15
+ /** The value of `--name value` or `--name=value` in a list of tokens, and the tokens without it. */
16
+ function take(tokens, name) {
17
+ const rest = [];
18
+ let value;
19
+ for (let i = 0; i < tokens.length; i++) {
20
+ const token = tokens[i];
21
+ if (token === name) {
22
+ value = tokens[++i];
23
+ if (value === undefined)
24
+ throw new UsageError(`${name} expects a value.`);
25
+ }
26
+ else if (token.startsWith(`${name}=`))
27
+ value = token.slice(name.length + 1);
28
+ else
29
+ rest.push(token);
30
+ }
31
+ return { value, rest };
32
+ }
33
+ function print(io, value, options) {
34
+ io.stdout(formatOutput(value, undefined, { format: options.format === "auto" ? "json" : options.format, select: options.select }));
35
+ return EXIT.ok;
36
+ }
37
+ export async function runData(app, io, tokens, options) {
38
+ const [first, ...rest] = tokens;
39
+ const sub = first ?? "status";
40
+ if (!SUBCOMMANDS.includes(sub)) {
41
+ throw new UsageError(`data has no '${sub}'. It takes: ${SUBCOMMANDS.join(", ")}.`, { hint: `Run \`${io.bin} help\` for what each does.` });
42
+ }
43
+ const store = await app.localData(io.env);
44
+ const scope = app.dataScope(await app.context(io.env));
45
+ switch (sub) {
46
+ case "status": {
47
+ const synced = store.synced(scope).map((entry) => ({ ...entry, command: app.find(entry.tool)?.command ?? entry.tool }));
48
+ return print(io, {
49
+ file: store.file,
50
+ account: scope,
51
+ cached_results: store.cacheEntries(scope),
52
+ synced,
53
+ can_sync: app.allTools.filter((tool) => tool.sync).map((tool) => tool.command),
54
+ }, options);
55
+ }
56
+ case "sync": {
57
+ const [command, ...flags] = rest;
58
+ if (!command)
59
+ throw new UsageError("data sync expects the command to copy: data sync <command>.");
60
+ const tool = app.find(command);
61
+ if (!tool)
62
+ throw new UsageError(`Unknown command '${command}'.`, { hint: `Run \`${io.bin}\` to list commands.` });
63
+ if (!tool.sync) {
64
+ const lists = app.allTools.filter((candidate) => candidate.sync).map((candidate) => candidate.command);
65
+ throw new UsageError(`${tool.command} is not a list that can be synced.`, { hint: `Lists that can: ${lists.join(", ") || "none"}.` });
66
+ }
67
+ const args = await app.parse(tool, parseToolArgs(flags, flagsFor(tool.jsonSchema), tool.positional));
68
+ const report = await syncTool(app, tool, args, {
69
+ surface: "cli",
70
+ env: io.env,
71
+ ...(io.isTTY && !options.agent ? { onProgress: ({ message }) => io.stderr(`… ${message ?? ""}\n`) } : {}),
72
+ });
73
+ return print(io, report, options);
74
+ }
75
+ case "search": {
76
+ const where = take(rest, "--in");
77
+ const limit = take(where.rest, "--limit");
78
+ const words = limit.rest.filter((token) => !token.startsWith("--")).join(" ");
79
+ if (!words)
80
+ throw new UsageError("data search expects the words to find: data search <words>.");
81
+ const tool = where.value ? app.find(where.value) : undefined;
82
+ if (where.value && !tool)
83
+ throw new UsageError(`Unknown command '${where.value}'.`);
84
+ const count = limit.value === undefined ? 20 : Number(limit.value);
85
+ if (!Number.isInteger(count) || count < 1 || count > 100)
86
+ throw new UsageError(`--limit expects a whole number from 1 to 100, got '${limit.value}'.`);
87
+ const hits = store.search(scope, words, { ...(tool ? { tool: tool.name } : {}), limit: count });
88
+ if (options.format === "auto" && io.isTTY && !options.select?.length) {
89
+ if (!hits.length) {
90
+ io.stdout(`Nothing synced matches '${words}'. Sync a list first: ${io.bin} data sync <command>.\n`);
91
+ return EXIT.ok;
92
+ }
93
+ for (const hit of hits)
94
+ io.stdout(`${app.find(hit.tool)?.command ?? hit.tool} ${hit.id} ${hit.snippet.replace(/\s+/g, " ")}\n`);
95
+ return EXIT.ok;
96
+ }
97
+ return print(io, { query: words, count: hits.length, results: hits }, options);
98
+ }
99
+ case "sql": {
100
+ const sql = rest.join(" ").trim();
101
+ if (!sql)
102
+ throw new UsageError(`data sql expects one SELECT statement: ${io.bin} data sql "select tool, count(*) from records group by tool"`);
103
+ const rows = store.query(sql);
104
+ return print(io, rows, options);
105
+ }
106
+ case "clear": {
107
+ const cacheOnly = rest.includes("--cache");
108
+ const command = rest.find((token) => !token.startsWith("--"));
109
+ const tool = command ? app.find(command) : undefined;
110
+ if (command && !tool)
111
+ throw new UsageError(`Unknown command '${command}'.`);
112
+ const cleared = {
113
+ cached_results: tool ? 0 : store.cacheClear(scope),
114
+ records: cacheOnly ? 0 : store.clearRecords(scope, tool?.name),
115
+ };
116
+ return print(io, cleared, options);
117
+ }
118
+ }
119
+ return EXIT.ok;
120
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Flags derived from the JSON Schema an MCP client receives.
3
+ *
4
+ * The terminal and the model read the same schema, so a flag cannot exist on
5
+ * one surface and not the other, and its help text is the description the
6
+ * model reads.
7
+ */
8
+ import type { JsonSchema } from "../schema.js";
9
+ export type FlagKind = "string" | "number" | "integer" | "boolean" | "enum" | "json";
10
+ export type Flag = {
11
+ /** The property name: `reply_to`. */
12
+ key: string;
13
+ /** The long flag: `--reply-to`. */
14
+ flag: string;
15
+ kind: FlagKind;
16
+ required: boolean;
17
+ repeatable: boolean;
18
+ choices?: string[];
19
+ help: string;
20
+ default?: unknown;
21
+ };
22
+ export declare function flagName(key: string): string;
23
+ export declare function flagsFor(schema: JsonSchema): Flag[];
24
+ /** JSON inline, or `@path` to read it from a file, which spares a person quoting a large body. */
25
+ export declare function parseJsonValue(raw: string, label: string): unknown;
26
+ /**
27
+ * Parse argv against a tool's flags.
28
+ *
29
+ * Accepts `--flag value`, `--flag=value`, the underscore spelling, `--no-flag`
30
+ * for a boolean, repeated or comma-separated lists, and bare words for the
31
+ * positional arguments. The schema validates the result afterwards; this only
32
+ * gets values into the right types and reports mistakes in terms of flags.
33
+ */
34
+ export declare function parseToolArgs(argv: readonly string[], flags: readonly Flag[], positional: readonly string[]): Record<string, unknown>;
35
+ export declare function missingRequired(flags: readonly Flag[], args: Record<string, unknown>): Flag[];
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Flags derived from the JSON Schema an MCP client receives.
3
+ *
4
+ * The terminal and the model read the same schema, so a flag cannot exist on
5
+ * one surface and not the other, and its help text is the description the
6
+ * model reads.
7
+ */
8
+ import { readFileSync } from "node:fs";
9
+ import { UsageError } from "../errors.js";
10
+ import { didYouMean } from "../search.js";
11
+ /** The first concrete type, looking through nullable unions and type lists. */
12
+ function concrete(node) {
13
+ const union = node.anyOf ?? node.oneOf;
14
+ if (union) {
15
+ const options = union.filter((option) => option.type !== "null");
16
+ // A union of plain literals is an enum in disguise, which a person types as a word.
17
+ if (options.length > 1 && options.every((option) => option.const !== undefined)) {
18
+ return { ...node, enum: options.map((option) => option.const) };
19
+ }
20
+ return concrete({ ...(options[0] ?? {}), description: node.description ?? options[0]?.description });
21
+ }
22
+ if (Array.isArray(node.type))
23
+ return { ...node, type: node.type.find((type) => type !== "null") ?? "string" };
24
+ return node;
25
+ }
26
+ function kindOf(node) {
27
+ const n = concrete(node);
28
+ if (n.enum)
29
+ return { kind: "enum", repeatable: false, choices: n.enum.map(String) };
30
+ if (n.type === "array") {
31
+ const item = concrete(n.items ?? {});
32
+ if (item.enum)
33
+ return { kind: "enum", repeatable: true, choices: item.enum.map(String) };
34
+ if (item.type === "object" || item.type === "array")
35
+ return { kind: "json", repeatable: true };
36
+ if (item.type === "number" || item.type === "integer" || item.type === "boolean")
37
+ return { kind: item.type, repeatable: true };
38
+ return { kind: "string", repeatable: true };
39
+ }
40
+ if (n.type === "object")
41
+ return { kind: "json", repeatable: false };
42
+ if (n.type === "number" || n.type === "integer" || n.type === "boolean")
43
+ return { kind: n.type, repeatable: false };
44
+ return { kind: "string", repeatable: false };
45
+ }
46
+ export function flagName(key) {
47
+ return `--${key.replace(/_/g, "-")}`;
48
+ }
49
+ export function flagsFor(schema) {
50
+ const properties = schema.properties ?? {};
51
+ const required = new Set(schema.required ?? []);
52
+ return Object.entries(properties).map(([key, node]) => {
53
+ const n = concrete(node);
54
+ return {
55
+ key,
56
+ flag: flagName(key),
57
+ ...kindOf(node),
58
+ required: required.has(key),
59
+ help: (node.description ?? n.description ?? "").trim(),
60
+ ...(node.default !== undefined ? { default: node.default } : n.default !== undefined ? { default: n.default } : {}),
61
+ };
62
+ });
63
+ }
64
+ function coerce(flag, raw) {
65
+ switch (flag.kind) {
66
+ case "number":
67
+ case "integer": {
68
+ const value = Number(raw);
69
+ if (raw.trim() === "" || !Number.isFinite(value) || (flag.kind === "integer" && !Number.isInteger(value))) {
70
+ throw new UsageError(`${flag.flag} expects ${flag.kind === "integer" ? "a whole number" : "a number"}, got '${raw}'.`);
71
+ }
72
+ return value;
73
+ }
74
+ case "boolean":
75
+ if (/^(true|1|yes)$/i.test(raw))
76
+ return true;
77
+ if (/^(false|0|no)$/i.test(raw))
78
+ return false;
79
+ throw new UsageError(`${flag.flag} expects true or false, got '${raw}'.`);
80
+ case "enum":
81
+ if (flag.choices && !flag.choices.includes(raw)) {
82
+ throw new UsageError(`${flag.flag} expects one of: ${flag.choices.join(", ")}. Got '${raw}'.`);
83
+ }
84
+ return raw;
85
+ case "json":
86
+ return parseJsonValue(raw, flag.flag);
87
+ default:
88
+ return raw;
89
+ }
90
+ }
91
+ /** JSON inline, or `@path` to read it from a file, which spares a person quoting a large body. */
92
+ export function parseJsonValue(raw, label) {
93
+ let text = raw;
94
+ if (raw.startsWith("@") && raw.length > 1) {
95
+ try {
96
+ text = readFileSync(raw.slice(1), "utf8");
97
+ }
98
+ catch (error) {
99
+ throw new UsageError(`${label} could not read ${raw.slice(1)}: ${error.message}`);
100
+ }
101
+ }
102
+ try {
103
+ return JSON.parse(text);
104
+ }
105
+ catch {
106
+ throw new UsageError(`${label} expects JSON${raw.startsWith("@") ? " in that file" : ""}, got '${text.slice(0, 60)}'.`);
107
+ }
108
+ }
109
+ /** Lists of numbers and fixed choices may be written once with commas; free text never is, since it may contain commas. */
110
+ function splits(flag) {
111
+ return flag.repeatable && (flag.kind === "enum" || flag.kind === "number" || flag.kind === "integer");
112
+ }
113
+ /**
114
+ * Parse argv against a tool's flags.
115
+ *
116
+ * Accepts `--flag value`, `--flag=value`, the underscore spelling, `--no-flag`
117
+ * for a boolean, repeated or comma-separated lists, and bare words for the
118
+ * positional arguments. The schema validates the result afterwards; this only
119
+ * gets values into the right types and reports mistakes in terms of flags.
120
+ */
121
+ export function parseToolArgs(argv, flags, positional) {
122
+ const out = {};
123
+ const bare = [];
124
+ const byName = new Map();
125
+ for (const flag of flags) {
126
+ byName.set(flag.flag, flag);
127
+ byName.set(`--${flag.key}`, flag);
128
+ }
129
+ const assign = (flag, value) => {
130
+ if (flag.repeatable) {
131
+ const values = Array.isArray(value) ? value : [value];
132
+ out[flag.key] = [...(out[flag.key] ?? []), ...values];
133
+ }
134
+ else {
135
+ out[flag.key] = value;
136
+ }
137
+ };
138
+ for (let i = 0; i < argv.length; i++) {
139
+ const token = argv[i];
140
+ if (token === "--") {
141
+ bare.push(...argv.slice(i + 1));
142
+ break;
143
+ }
144
+ if (!token.startsWith("--") || token === "-") {
145
+ bare.push(token);
146
+ continue;
147
+ }
148
+ const eq = token.indexOf("=");
149
+ const name = eq === -1 ? token : token.slice(0, eq);
150
+ let flag = byName.get(name);
151
+ if (!flag && name.startsWith("--no-")) {
152
+ const positive = byName.get(`--${name.slice(5)}`);
153
+ if (positive?.kind === "boolean" && eq === -1) {
154
+ assign(positive, false);
155
+ continue;
156
+ }
157
+ }
158
+ if (!flag) {
159
+ const guess = didYouMean(name, flags.map((f) => f.flag));
160
+ throw new UsageError(`Unknown option ${name}.${guess ? ` Did you mean ${guess}?` : ""}`);
161
+ }
162
+ let raw = eq === -1 ? undefined : token.slice(eq + 1);
163
+ if (raw === undefined && flag.kind === "boolean") {
164
+ // A bare boolean is true. Only an explicit true or false after it is
165
+ // taken as its value, so a positional word after a switch is not swallowed.
166
+ const next = argv[i + 1];
167
+ if (next !== undefined && /^(true|false)$/i.test(next)) {
168
+ raw = next;
169
+ i++;
170
+ }
171
+ else {
172
+ assign(flag, true);
173
+ continue;
174
+ }
175
+ }
176
+ if (raw === undefined)
177
+ raw = argv[++i];
178
+ if (raw === undefined)
179
+ throw new UsageError(`${flag.flag} expects a value.`);
180
+ if (splits(flag) && raw.includes(","))
181
+ assign(flag, raw.split(",").map((part) => coerce(flag, part.trim())));
182
+ else
183
+ assign(flag, coerce(flag, raw));
184
+ }
185
+ if (bare.length > 0) {
186
+ const byKey = new Map(flags.map((flag) => [flag.key, flag]));
187
+ const slots = positional.length > 0 ? positional.map((key) => byKey.get(key)).filter(Boolean) : [];
188
+ if (slots.length === 0) {
189
+ // With nothing declared, one bare word fills the first required argument,
190
+ // so `search-posts cats` works before anyone reads the help.
191
+ const target = flags.find((flag) => flag.required && out[flag.key] === undefined);
192
+ if (target)
193
+ slots.push(target);
194
+ }
195
+ for (const [index, word] of bare.entries()) {
196
+ const slot = slots[index];
197
+ if (!slot)
198
+ throw new UsageError(`Unexpected argument '${word}'.`);
199
+ if (out[slot.key] !== undefined && !slot.repeatable)
200
+ throw new UsageError(`${slot.flag} was given twice.`);
201
+ assign(slot, coerce(slot, word));
202
+ }
203
+ }
204
+ return out;
205
+ }
206
+ export function missingRequired(flags, args) {
207
+ return flags.filter((flag) => flag.required && args[flag.key] === undefined);
208
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * What a person sees before they know what to type.
3
+ */
4
+ import type { App } from "../app.js";
5
+ import type { Tool } from "../tool.js";
6
+ /** The words the CLI owns, which no tool command may take. */
7
+ export declare const BUILTINS: readonly ["help", "tools", "schema", "agent-context", "which", "doctor", "login", "completion", "version", "data", "install"];
8
+ export declare const GLOBAL_FLAGS: Array<[string, string]>;
9
+ export declare function renderList(app: App, tools: readonly Tool[], bin: string): string;
10
+ /** An example's arguments as the command someone would type. */
11
+ export declare function exampleCommand(bin: string, tool: Tool, args: Record<string, unknown>): string;
12
+ export declare function renderToolHelp(tool: Tool, bin: string): string;
13
+ export declare function renderGeneralHelp(app: App, bin: string): string;
14
+ /** One line per tool, for search results and listings. */
15
+ export declare function toolLine(tool: Tool): string;
@@ -0,0 +1,213 @@
1
+ /**
2
+ * What a person sees before they know what to type.
3
+ */
4
+ import { EXIT } from "../errors.js";
5
+ import { policyEnvNames, riskMark } from "../policy.js";
6
+ import { firstSentence } from "../search.js";
7
+ import { flagsFor } from "./flags.js";
8
+ const COLUMN = 30;
9
+ /** The words the CLI owns, which no tool command may take. */
10
+ export const BUILTINS = ["help", "tools", "schema", "agent-context", "which", "doctor", "login", "completion", "version", "data", "install"];
11
+ export const GLOBAL_FLAGS = [
12
+ ["--json", "pretty JSON"],
13
+ ["--compact", "one-line JSON"],
14
+ ["--jsonl", "one JSON value per line, for lists"],
15
+ ["--csv / --tsv", "a table, for lists of records"],
16
+ ["--quiet", "one value per line: ids, or the one --select field"],
17
+ ["--select <a,b.c>", "keep only these fields; dotted paths descend"],
18
+ ["--agent", "JSON, compact, no prompts, no color. Never confirms anything"],
19
+ ["--out <file>", "write the output to a new file instead of stdout"],
20
+ ["--input <json|@file|->", "all arguments as one JSON object; flags override it"],
21
+ ["--dry-run", "check everything and print what would run, without running it"],
22
+ ["--wait", "for a job: wait until it finishes, however long that takes"],
23
+ ["--refresh", "skip the local cache and fetch again"],
24
+ ["--timeout <ms>", "give up after this long"],
25
+ ];
26
+ function line(left, help) {
27
+ if (!help)
28
+ return [left];
29
+ return left.length < COLUMN ? [`${left.padEnd(COLUMN)}${help}`] : [left, `${" ".repeat(COLUMN)}${help}`];
30
+ }
31
+ 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;
34
+ }
35
+ export function renderList(app, tools, bin) {
36
+ const width = Math.max(10, ...tools.map((tool) => tool.command.length)) + 2;
37
+ const lines = [``, `${app.title} ${app.version}${app.description ? `: ${app.description}` : ""}`, ``];
38
+ const toolsets = app.definition.toolsets ?? {};
39
+ const groups = new Map();
40
+ for (const tool of tools) {
41
+ const group = tool.tags[0] ?? "";
42
+ groups.set(group, [...(groups.get(group) ?? []), tool]);
43
+ }
44
+ const ordered = [...groups.entries()].sort(([a], [b]) => (a === "" ? -1 : b === "" ? 1 : a.localeCompare(b)));
45
+ const grouped = ordered.length > 1 || (ordered[0]?.[0] ?? "") !== "";
46
+ lines.push(`Commands (${tools.length})`);
47
+ for (const [group, members] of ordered) {
48
+ if (grouped)
49
+ lines.push(``, ` ${group || "general"}${group && toolsets[group] ? `: ${toolsets[group]}` : ""}`);
50
+ for (const tool of members)
51
+ lines.push(` ${riskMark(tool.risk)} ${tool.command.padEnd(width)}${tool.title}`);
52
+ }
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
+ }
59
+ return lines.join("\n");
60
+ }
61
+ function shellQuote(value) {
62
+ return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
63
+ }
64
+ /** An example's arguments as the command someone would type. */
65
+ export function exampleCommand(bin, tool, args) {
66
+ const parts = [bin, tool.command];
67
+ const rest = { ...args };
68
+ for (const key of tool.positional) {
69
+ const value = rest[key];
70
+ if (value === undefined || typeof value === "object")
71
+ break;
72
+ parts.push(shellQuote(String(value)));
73
+ delete rest[key];
74
+ }
75
+ for (const [key, value] of Object.entries(rest)) {
76
+ const flag = `--${key.replace(/_/g, "-")}`;
77
+ if (value === true)
78
+ parts.push(flag);
79
+ else if (value === false)
80
+ parts.push(`${flag}=false`);
81
+ else if (Array.isArray(value) && value.every((item) => typeof item !== "object"))
82
+ for (const item of value)
83
+ parts.push(flag, shellQuote(String(item)));
84
+ else if (value !== null && typeof value === "object")
85
+ parts.push(flag, shellQuote(JSON.stringify(value)));
86
+ else if (value !== undefined)
87
+ parts.push(flag, shellQuote(String(value)));
88
+ }
89
+ return parts.join(" ");
90
+ }
91
+ function placeholder(flag) {
92
+ if (flag.kind === "boolean")
93
+ return "";
94
+ if (flag.choices)
95
+ return flag.choices.length <= 6 ? ` <${flag.choices.join("|")}>` : " <choice>";
96
+ return ` <${flag.kind === "json" ? "json|@file" : flag.kind}>`;
97
+ }
98
+ export function renderToolHelp(tool, bin) {
99
+ const flags = flagsFor(tool.jsonSchema).filter((flag) => flag.key !== "confirm");
100
+ const required = flags.filter((flag) => flag.required);
101
+ const optional = flags.filter((flag) => !flag.required);
102
+ const positional = tool.positional.length ? tool.positional : required[0] ? [required[0].key] : [];
103
+ const usage = [
104
+ `${bin} ${tool.command}`,
105
+ ...required.map((flag) => (positional.includes(flag.key) ? `<${flag.key}>` : `${flag.flag}${placeholder(flag)}`)),
106
+ optional.length ? "[options]" : "",
107
+ tool.requireConfirm ? "--confirm" : "",
108
+ ]
109
+ .filter(Boolean)
110
+ .join(" ");
111
+ const lines = [``, `${tool.title}`, ``, tool.description, ``, `Usage:`, ` ${usage}`, ``];
112
+ const describe = (list, heading) => {
113
+ if (!list.length)
114
+ return;
115
+ lines.push(`${heading}:`);
116
+ for (const flag of list) {
117
+ const extra = [flag.repeatable ? "Repeatable." : "", flag.default !== undefined ? `Default ${JSON.stringify(flag.default)}.` : ""]
118
+ .filter(Boolean)
119
+ .join(" ");
120
+ lines.push(...line(` ${flag.flag}${placeholder(flag)}`, [flag.help, extra].filter(Boolean).join(" ")));
121
+ }
122
+ lines.push(``);
123
+ };
124
+ describe(required, "Required");
125
+ describe(optional, "Options");
126
+ if (tool.requireConfirm) {
127
+ lines.push(`Safety:`, ...line(" --confirm", "required: this runs only when you mean it"), ``);
128
+ }
129
+ if (tool.paginate) {
130
+ lines.push(`Pages:`, ...line(" --all", "follow every page and print all items"), ...line(" --max-items <n>", "stop after this many items"), ``);
131
+ }
132
+ if (tool.job) {
133
+ const check = tool.statusOf ? "" : `${tool.command}-status <job-id>`;
134
+ lines.push(`Job:`, ...line(" --wait", "wait until the job finishes"), ...line(" --wait-seconds <n>", "wait this long, then print the job to check later"), ...(check ? line(` ${check}`, "check on it later") : []), ``);
135
+ }
136
+ if (tool.examples.length) {
137
+ lines.push(`Examples:`);
138
+ for (const example of tool.examples)
139
+ lines.push(` # ${example.description}`, ` ${exampleCommand(bin, tool, example.args)}`, ``);
140
+ }
141
+ lines.push(`Output:`);
142
+ for (const [flag, help] of GLOBAL_FLAGS)
143
+ lines.push(...line(` ${flag}`, help));
144
+ lines.push(``, `Risk: ${riskWords(tool)}`, ``);
145
+ return lines.join("\n");
146
+ }
147
+ export function renderGeneralHelp(app, bin) {
148
+ const names = policyEnvNames(app.envPrefix);
149
+ const lines = [
150
+ ``,
151
+ `${app.title} ${app.version}${app.description ? `: ${app.description}` : ""}`,
152
+ ``,
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`,
158
+ ``,
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"),
169
+ ``,
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
+ `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") : []),
201
+ ``,
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`,
204
+ ``,
205
+ ];
206
+ if (app.definition.links?.repository)
207
+ lines.push(app.definition.links.repository, ``);
208
+ return lines.join("\n");
209
+ }
210
+ /** One line per tool, for search results and listings. */
211
+ export function toolLine(tool) {
212
+ return `${riskMark(tool.risk)} ${tool.command} ${tool.title}: ${firstSentence(tool.description, 80)}`;
213
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `install <client>` from a terminal: plan the change, show it, make it.
3
+ */
4
+ import type { App, CliIO } from "../app.js";
5
+ import type { Format } from "./output.js";
6
+ export declare function runInstall(app: App, io: CliIO, tokens: string[], options: {
7
+ agent: boolean;
8
+ dryRun: boolean;
9
+ format: Format;
10
+ }): Promise<number>;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `install <client>` from a terminal: plan the change, show it, make it.
3
+ */
4
+ import { EXIT, UsageError } from "../errors.js";
5
+ import { applyInstall, CLIENTS, planInstall } from "../install.js";
6
+ function value(tokens, name) {
7
+ const at = tokens.findIndex((token) => token === name || token.startsWith(`${name}=`));
8
+ if (at === -1)
9
+ return undefined;
10
+ const token = tokens[at];
11
+ const found = token.includes("=") ? token.slice(name.length + 1) : tokens[at + 1];
12
+ if (found === undefined || found.startsWith("--"))
13
+ throw new UsageError(`${name} expects a value.`);
14
+ return found;
15
+ }
16
+ function describe(plan) {
17
+ const lines = [];
18
+ if (plan.run)
19
+ lines.push(` ${[plan.run.command, ...plan.run.args.map((arg) => (/^[A-Za-z0-9_./:@=-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`))].join(" ")}`);
20
+ else
21
+ lines.push(...(typeof plan.entry === "string" ? plan.entry : JSON.stringify({ [plan.name]: plan.entry }, null, 2)).split("\n").map((line) => ` ${line}`));
22
+ return lines.join("\n");
23
+ }
24
+ export async function runInstall(app, io, tokens, options) {
25
+ const client = tokens.find((token) => !token.startsWith("--") && !["--scope", "--name"].includes(tokens[tokens.indexOf(token) - 1] ?? ""));
26
+ const ids = Object.keys(CLIENTS);
27
+ if (!client || !ids.includes(client)) {
28
+ throw new UsageError(`install expects one of: ${ids.join(", ")}.`, { hint: `${io.bin} install codex --dry-run shows the change without making it.` });
29
+ }
30
+ const scope = (value(tokens, "--scope") ?? CLIENTS[client].scopes[0]);
31
+ if (scope !== "user" && scope !== "project")
32
+ throw new UsageError(`--scope expects user or project, got '${scope}'.`);
33
+ const name = value(tokens, "--name") ?? app.name;
34
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(name))
35
+ throw new UsageError("--name expects letters, digits, '-' or '_'.");
36
+ const plan = planInstall(app, { client, scope, name, copyEnv: tokens.includes("--copy-env"), local: tokens.includes("--local") }, { env: io.env, cwd: io.cwd ?? process.cwd() });
37
+ const title = CLIENTS[client].title;
38
+ const machine = options.agent || options.format !== "auto";
39
+ if (options.dryRun) {
40
+ if (machine)
41
+ io.stdout(`${JSON.stringify({ dry_run: true, ...plan, text: undefined })}\n`);
42
+ else {
43
+ const where = plan.run ? "would run" : `would write ${plan.file}`;
44
+ io.stdout([``, `${title} (${scope}): ${where}`, ``, describe(plan), ``, ...plan.notes.map((note) => `${note}`), ``].join("\n"));
45
+ }
46
+ return EXIT.ok;
47
+ }
48
+ const result = await applyInstall(plan, io);
49
+ if (result.ran && result.ran.code !== 0) {
50
+ const missing = result.ran.code === 127;
51
+ const exists = /already exists/i.test(result.ran.output);
52
+ throw new UsageError(missing ? "The claude command is not on PATH, so nothing was added." : exists ? `${name} is already in Claude Code (${scope}).` : `claude mcp add-json failed: ${result.ran.output}`, { hint: missing ? `Install Claude Code, or run it yourself:\n${describe(plan)}` : exists ? `Remove it first with \`claude mcp remove ${name} --scope ${scope}\`, then run install again.` : undefined });
53
+ }
54
+ if (machine) {
55
+ io.stdout(`${JSON.stringify({ installed: true, client, scope, name, ...(plan.file ? { file: plan.file } : {}), ...(result.backup ? { backup: result.backup } : {}), env: plan.env, notes: plan.notes })}\n`);
56
+ }
57
+ else {
58
+ const lines = [``, `Added ${name} to ${title} (${scope})${plan.file ? `: ${plan.file}` : "."}`];
59
+ if (result.backup)
60
+ lines.push(`The previous file is saved as ${result.backup}.`);
61
+ lines.push(``, describe(plan), ``, ...plan.notes, `Restart ${title} to load it.`, ``);
62
+ io.stdout(lines.join("\n"));
63
+ }
64
+ return EXIT.ok;
65
+ }