@typeship-ax/cli 0.6.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 (69) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +40 -0
  3. package/api.json +5163 -0
  4. package/api.md +512 -0
  5. package/dist/cli-agent.d.ts +204 -0
  6. package/dist/cli-agent.d.ts.map +1 -0
  7. package/dist/cli-agent.js +525 -0
  8. package/dist/cli.d.ts +3 -0
  9. package/dist/cli.d.ts.map +1 -0
  10. package/dist/cli.js +2811 -0
  11. package/dist/core/http.d.ts +303 -0
  12. package/dist/core/http.d.ts.map +1 -0
  13. package/dist/core/http.js +770 -0
  14. package/dist/core/pagination.d.ts +51 -0
  15. package/dist/core/pagination.d.ts.map +1 -0
  16. package/dist/core/pagination.js +154 -0
  17. package/dist/dates.d.ts +33 -0
  18. package/dist/dates.d.ts.map +1 -0
  19. package/dist/dates.js +136 -0
  20. package/dist/errors.d.ts +81 -0
  21. package/dist/errors.d.ts.map +1 -0
  22. package/dist/errors.js +103 -0
  23. package/dist/index.d.ts +92 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +86 -0
  26. package/dist/ops.d.ts +115 -0
  27. package/dist/ops.d.ts.map +1 -0
  28. package/dist/ops.js +79 -0
  29. package/dist/resources/account.d.ts +18 -0
  30. package/dist/resources/account.d.ts.map +1 -0
  31. package/dist/resources/account.js +26 -0
  32. package/dist/resources/api-keys.d.ts +37 -0
  33. package/dist/resources/api-keys.d.ts.map +1 -0
  34. package/dist/resources/api-keys.js +67 -0
  35. package/dist/resources/generate.d.ts +25 -0
  36. package/dist/resources/generate.d.ts.map +1 -0
  37. package/dist/resources/generate.js +41 -0
  38. package/dist/resources/generations.d.ts +31 -0
  39. package/dist/resources/generations.d.ts.map +1 -0
  40. package/dist/resources/generations.js +56 -0
  41. package/dist/resources/projects.d.ts +110 -0
  42. package/dist/resources/projects.d.ts.map +1 -0
  43. package/dist/resources/projects.js +220 -0
  44. package/dist/resources/spec-revisions.d.ts +47 -0
  45. package/dist/resources/spec-revisions.d.ts.map +1 -0
  46. package/dist/resources/spec-revisions.js +90 -0
  47. package/dist/schemas.d.ts +6 -0
  48. package/dist/schemas.d.ts.map +1 -0
  49. package/dist/schemas.js +88 -0
  50. package/dist/types.d.ts +759 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +37 -0
  53. package/package.json +43 -0
  54. package/src/cli-agent.ts +685 -0
  55. package/src/cli.ts +2695 -0
  56. package/src/core/http.ts +1008 -0
  57. package/src/core/pagination.ts +195 -0
  58. package/src/dates.ts +126 -0
  59. package/src/errors.ts +117 -0
  60. package/src/index.ts +153 -0
  61. package/src/ops.ts +174 -0
  62. package/src/resources/account.ts +43 -0
  63. package/src/resources/api-keys.ts +105 -0
  64. package/src/resources/generate.ts +69 -0
  65. package/src/resources/generations.ts +100 -0
  66. package/src/resources/projects.ts +391 -0
  67. package/src/resources/spec-revisions.ts +150 -0
  68. package/src/schemas.ts +90 -0
  69. package/src/types.ts +825 -0
@@ -0,0 +1,685 @@
1
+ /**
2
+ * The agent contract of a generated CLI. Generated by typeship — https://typeship.dev
3
+ *
4
+ * Everything a coding agent needs from a command-line tool and a human does
5
+ * not: a JSON error envelope with stable codes and next steps, agent-mode
6
+ * detection, MCP client registration across the agents on a machine, an
7
+ * AGENTS.md block, `agent-guide` and `doctor`. Pure helpers over node:fs;
8
+ * cli.ts wires them to the commands. Zero dependencies.
9
+ */
10
+ import { spawnSync } from "node:child_process";
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join, resolve } from "node:path";
14
+
15
+ // ---- error envelope ---------------------------------------------------------
16
+
17
+ /**
18
+ * Stable codes an agent can branch on. The message is for people; the code
19
+ * is the contract. Additive only.
20
+ */
21
+ export type IssueCode =
22
+ | "NO_AUTH" // no credential resolved and the API said 401
23
+ | "AUTH_INVALID" // a credential was sent and the API said 401/403
24
+ | "PLAN_LIMIT" // 402: the account's plan stops here
25
+ | "NOT_FOUND" // 404
26
+ | "INVALID_REQUEST" // 400/422 the API rejected the input
27
+ | "SPEC_INVALID" // 422 with the API's spec_error (typeship's own API)
28
+ | "RATE_LIMITED" // 429
29
+ | "SERVER_ERROR" // 5xx
30
+ | "NETWORK_ERROR" // no response: DNS, TLS, timeout, refused
31
+ | "VALIDATION_FAILED" // --validate found the body does not match the schema
32
+ | "TTY_REQUIRED" // a prompt was needed and there is no terminal
33
+ | "CONFIRMATION_REQUIRED" // a destructive command needs --force
34
+ | "INVALID_USAGE" // wrong flags or arguments
35
+ | "UNKNOWN_COMMAND"
36
+ | "UNKNOWN_FLAG"
37
+ | "MISSING_ARGUMENT"
38
+ | "COMMAND_FAILED"; // anything else
39
+
40
+ export interface Issue {
41
+ code: IssueCode;
42
+ message: string;
43
+ }
44
+
45
+ export interface Envelope {
46
+ /** error: it failed. action_required: it stopped on purpose and next_steps says what unblocks it. */
47
+ status: "error" | "action_required";
48
+ issues: Issue[];
49
+ /** Where to read more; the docs site when one is configured. */
50
+ docs_url?: string;
51
+ /** Ordered, concrete, copy-pastable. Empty when there is nothing to suggest. */
52
+ next_steps: string[];
53
+ /** The API's own error body, the transport error, or the validation violations. */
54
+ detail?: unknown;
55
+ }
56
+
57
+ export interface EnvelopeInput {
58
+ status?: Envelope["status"];
59
+ code: IssueCode;
60
+ message: string;
61
+ docsUrl?: string | null;
62
+ nextSteps?: string[];
63
+ detail?: unknown;
64
+ }
65
+
66
+ export function envelope(input: EnvelopeInput): Envelope {
67
+ return {
68
+ status: input.status ?? "error",
69
+ issues: [{ code: input.code, message: input.message }],
70
+ ...(input.docsUrl ? { docs_url: input.docsUrl } : {}),
71
+ next_steps: input.nextSteps ?? [],
72
+ ...(input.detail !== undefined ? { detail: input.detail } : {}),
73
+ };
74
+ }
75
+
76
+ /** Exit code convention: 0 ok, 1 the request or command failed, 2 usage. */
77
+ export function exitCodeFor(code: IssueCode): 1 | 2 {
78
+ switch (code) {
79
+ case "INVALID_USAGE":
80
+ case "UNKNOWN_COMMAND":
81
+ case "UNKNOWN_FLAG":
82
+ case "MISSING_ARGUMENT":
83
+ case "TTY_REQUIRED":
84
+ case "CONFIRMATION_REQUIRED":
85
+ return 2;
86
+ default:
87
+ return 1;
88
+ }
89
+ }
90
+
91
+ /** Interpret an SDK error result: HTTP status, body, transport, validation. */
92
+ export function classifyApiError(
93
+ error: unknown,
94
+ context: { bin: string; hadCredential: boolean; docsUrl: string | null },
95
+ ): EnvelopeInput {
96
+ const e = (error ?? {}) as { name?: string; message?: string; status?: number; body?: unknown; violations?: unknown };
97
+ // The message is the API's own words when it sent any, else the SDK's
98
+ // (the spec's response description). The error class name rides in
99
+ // detail, not in front of the message: "NotFoundError: No such account
100
+ // (no such account)" said one thing three times.
101
+ const base = e.message ? e.message : String(error);
102
+ if (e.violations !== undefined) {
103
+ return { code: "VALIDATION_FAILED", message: base, detail: { ...(e.name ? { error: e.name } : {}), violations: e.violations }, nextSteps: ["Fix the fields named in detail.violations, or drop --validate to send the body as is."] };
104
+ }
105
+ if (e.name === "TransportError" || (typeof e.status !== "number" && /fetch|ECONN|ENOTFOUND|timed out|TLS|abort/i.test(base))) {
106
+ return {
107
+ code: "NETWORK_ERROR",
108
+ message: base,
109
+ nextSteps: [
110
+ "Check the base URL (--base-url, the " + context.bin.toUpperCase().replace(/[^A-Z0-9]/g, "_") + "_BASE_URL variable, or '" + context.bin + " config base-url') and the network.",
111
+ "Retry once with backoff; do not loop.",
112
+ ],
113
+ };
114
+ }
115
+ const status = typeof e.status === "number" ? e.status : 0;
116
+ const body = e.body as { errors?: { code?: string; message?: string }[]; error?: unknown; message?: unknown } | undefined;
117
+ const apiCode = body?.errors?.[0]?.code;
118
+ const apiMessage = body?.errors?.[0]?.message ?? (typeof body?.message === "string" ? body.message : undefined) ?? (typeof body?.error === "string" ? body.error : undefined);
119
+ const detail = { status, ...(e.name ? { error: e.name } : {}), ...(e.body !== undefined ? { body: e.body } : {}) };
120
+ const same = apiMessage !== undefined && (apiMessage.toLowerCase() === base.toLowerCase() || base.toLowerCase().includes(apiMessage.toLowerCase()) || apiMessage.toLowerCase().includes(base.toLowerCase()));
121
+ const message = apiMessage === undefined ? base : same ? apiMessage : apiMessage + " (" + base + ")";
122
+ const upgradeUrl = extractUrl(e.body, ["upgrade_url", "upgradeUrl", "signup_url", "claim_url"]);
123
+ if (status === 401) {
124
+ return context.hadCredential
125
+ ? { code: "AUTH_INVALID", message, detail, nextSteps: ["The credential was rejected. Check it is current: '" + context.bin + " auth check', then '" + context.bin + " login --help' to store a new one."] }
126
+ : { status: "action_required", code: "NO_AUTH", message, detail, nextSteps: ["No credential was sent. Set the auth env var, pass --token, or run '" + context.bin + " login'.", "'" + context.bin + " auth check' shows what the CLI would send."] };
127
+ }
128
+ if (status === 403) return { code: "AUTH_INVALID", message, detail, nextSteps: ["The credential lacks access to this operation."] };
129
+ if (status === 402) {
130
+ return { status: "action_required", code: "PLAN_LIMIT", message, detail, nextSteps: [upgradeUrl ? "Lift the limit at " + upgradeUrl + ", then run the same command again." : "The account's plan stops here; upgrade it, then run the same command again.", "Do not retry the same call as is."] };
131
+ }
132
+ if (status === 404) return { code: "NOT_FOUND", message, detail, nextSteps: notFoundNextSteps(message) };
133
+ if (status === 429) {
134
+ const retryAfter = extractRetryAfter(e);
135
+ return { status: "action_required", code: "RATE_LIMITED", message, detail, nextSteps: [retryAfter ? "Wait " + retryAfter + " seconds, then run the same command again." : "Back off and retry once; the SDK already retried with the server's Retry-After."] };
136
+ }
137
+ if (status === 422 && apiCode === "spec_error") return { code: "SPEC_INVALID", message, detail, nextSteps: ["The API rejected the spec it was given; the message says why.", context.docsUrl ? "Look the message up: '" + context.bin + " docs search \"" + (apiMessage ?? "").slice(0, 60).replace(/"/g, "'") + "\"'." : "Fix the spec and run again."] };
138
+ if (status === 400 || status === 422 || status === 409 || status === 413) return { code: "INVALID_REQUEST", message, detail, nextSteps: ["Read detail.body for the field the API named; run the command with --help for its flags."] };
139
+ if (status >= 500) return { code: "SERVER_ERROR", message, detail, nextSteps: ["Retry once with backoff. If it persists, report the request id in detail.body."] };
140
+ return { code: "COMMAND_FAILED", message, detail };
141
+ }
142
+
143
+ /** A file lookup needs path guidance, not the generic advice for a missing
144
+ * resource id. Prefer the API's own index when its message names one. */
145
+ function notFoundNextSteps(message: string): string[] {
146
+ if (/\bfiles_index\b/i.test(message)) {
147
+ return ["Read files_index on the generation, choose an exact path it lists, then run the command again with that path."];
148
+ }
149
+ if (/\bfile\b/i.test(message) && /\bpaths?\b/i.test(message)) {
150
+ return ["Check the requested file path against the API's file listing, then run the command again with an exact path."];
151
+ }
152
+ return ["Check the resource id; list the resource first."];
153
+ }
154
+
155
+ function extractUrl(body: unknown, keys: string[]): string | undefined {
156
+ if (!body || typeof body !== "object") return undefined;
157
+ const record = body as Record<string, unknown>;
158
+ for (const key of keys) {
159
+ const direct = record[key];
160
+ if (typeof direct === "string") return direct;
161
+ for (const value of Object.values(record)) {
162
+ if (value && typeof value === "object" && typeof (value as Record<string, unknown>)[key] === "string") return (value as Record<string, string>)[key];
163
+ }
164
+ }
165
+ return undefined;
166
+ }
167
+
168
+ function extractRetryAfter(e: { headers?: unknown; body?: unknown }): string | undefined {
169
+ const headers = e.headers as { get?: (name: string) => string | null } | undefined;
170
+ const fromHeader = headers?.get?.("retry-after");
171
+ if (fromHeader) return fromHeader;
172
+ const match = /retry after (\d+)s/i.exec(JSON.stringify(e.body ?? ""));
173
+ return match?.[1];
174
+ }
175
+
176
+ // ---- agent mode -------------------------------------------------------------
177
+
178
+ export interface AgentModeInput {
179
+ flagMode: string | boolean | undefined;
180
+ envMode: string | undefined;
181
+ stdoutIsTTY: boolean;
182
+ stdinIsTTY: boolean;
183
+ }
184
+
185
+ /**
186
+ * --mode agent beats <PREFIX>_MODE=agent beats "no terminal on either end".
187
+ * In agent mode nothing prompts, browsers are not opened, and every stop is
188
+ * an action_required envelope with next_steps.
189
+ */
190
+ export function agentMode(input: AgentModeInput): boolean {
191
+ if (input.flagMode === "agent") return true;
192
+ if (input.flagMode === "human" || input.flagMode === "interactive") return false;
193
+ if (input.envMode === "agent") return true;
194
+ if (input.envMode === "human" || input.envMode === "interactive") return false;
195
+ return !input.stdoutIsTTY && !input.stdinIsTTY;
196
+ }
197
+
198
+ /** Which agent harness is running us, from its environment; analytics and AGENTS.md placement only. */
199
+ export function detectHarness(env: NodeJS.ProcessEnv = process.env): string | null {
200
+ if (env.CLAUDECODE || env.CLAUDE_CODE || env.CLAUDE_CODE_ENTRYPOINT) return "claude-code";
201
+ if (env.CURSOR_TRACE_ID || env.CURSOR_AGENT) return "cursor";
202
+ if (env.CODEX_SANDBOX || env.CODEX_CI || env.OPENAI_CODEX) return "codex";
203
+ if (env.GEMINI_CLI || env.GEMINI_API_KEY && env.GEMINI_SANDBOX) return "gemini-cli";
204
+ if (env.WINDSURF_SESSION || env.CODEIUM_AGENT) return "windsurf";
205
+ if (env.OPENCODE || env.OPENCODE_SESSION) return "opencode";
206
+ if (env.AMP_SESSION || env.AMP_AGENT) return "amp";
207
+ if (env.DEVIN_SESSION) return "devin";
208
+ return null;
209
+ }
210
+
211
+ // ---- MCP clients ------------------------------------------------------------
212
+
213
+ export type McpClientId = "claude-code" | "cursor" | "codex" | "vscode" | "windsurf" | "gemini-cli" | "opencode" | "zed" | "claude-desktop";
214
+
215
+ export interface McpEntry {
216
+ /** A hosted endpoint: {type:"http", url, headers?} */
217
+ url?: string;
218
+ /** Header name → value; values may be env references like ${VAR}. */
219
+ headers?: Record<string, string>;
220
+ /** A local stdio server: command + args. */
221
+ command?: string;
222
+ args?: string[];
223
+ env?: Record<string, string>;
224
+ }
225
+
226
+ export interface McpClient {
227
+ id: McpClientId;
228
+ label: string;
229
+ /** Where the config lives; project-scoped clients resolve against cwd. */
230
+ file: (cwd: string) => string;
231
+ /** Presence test: is this client on the machine (or in this project)? */
232
+ detect: (cwd: string) => boolean;
233
+ /** Merge an entry into the file's contents. */
234
+ write: (existing: string, name: string, entry: McpEntry) => string;
235
+ /** True when the client cannot speak the current MCP protocol; skipped by --all. */
236
+ incompatible?: string;
237
+ }
238
+
239
+ function readOr(file: string, fallback: string): string {
240
+ try {
241
+ return readFileSync(file, "utf8");
242
+ } catch {
243
+ return fallback;
244
+ }
245
+ }
246
+
247
+ function jsonMerge(existing: string, path: string[], name: string, value: unknown): string {
248
+ let doc: Record<string, unknown> = {};
249
+ try {
250
+ doc = JSON.parse(existing) as Record<string, unknown>;
251
+ } catch {
252
+ doc = {};
253
+ }
254
+ let cursor = doc;
255
+ for (const key of path) {
256
+ const next = cursor[key];
257
+ if (!next || typeof next !== "object" || Array.isArray(next)) cursor[key] = {};
258
+ cursor = cursor[key] as Record<string, unknown>;
259
+ }
260
+ cursor[name] = value;
261
+ return JSON.stringify(doc, null, 2) + "\n";
262
+ }
263
+
264
+ const httpEntry = (entry: McpEntry) => ({ type: "http", url: entry.url, ...(entry.headers ? { headers: entry.headers } : {}) });
265
+ const stdioEntry = (entry: McpEntry) => ({ command: entry.command, args: entry.args ?? [], ...(entry.env ? { env: entry.env } : {}) });
266
+ const standardEntry = (entry: McpEntry) => (entry.url ? httpEntry(entry) : stdioEntry(entry));
267
+
268
+ /** TOML for Codex's config.toml: append or replace the [mcp_servers.<name>] table. */
269
+ function tomlMerge(existing: string, name: string, entry: McpEntry): string {
270
+ const header = "[mcp_servers." + name + "]";
271
+ const lines: string[] = [header];
272
+ if (entry.url) {
273
+ lines.push("url = " + JSON.stringify(entry.url));
274
+ const auth = entry.headers?.Authorization ?? entry.headers?.authorization;
275
+ const envRef = auth ? /\$\{([A-Z0-9_]+)\}/.exec(auth)?.[1] : undefined;
276
+ if (envRef) lines.push("bearer_token_env_var = " + JSON.stringify(envRef));
277
+ } else {
278
+ lines.push("command = " + JSON.stringify(entry.command ?? "node"));
279
+ lines.push("args = " + JSON.stringify(entry.args ?? []));
280
+ }
281
+ const block = lines.join("\n") + "\n";
282
+ const pattern = new RegExp("\\[mcp_servers\\." + name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\][\\s\\S]*?(?=\\n\\[|$)");
283
+ if (pattern.test(existing)) return existing.replace(pattern, block.trimEnd() + "\n");
284
+ return (existing.trimEnd() + (existing.trim() ? "\n\n" : "")) + block;
285
+ }
286
+
287
+ const home = () => homedir();
288
+ const xdg = () => process.env.XDG_CONFIG_HOME ?? join(home(), ".config");
289
+
290
+ export const MCP_CLIENTS: McpClient[] = [
291
+ {
292
+ id: "claude-code",
293
+ label: "Claude Code",
294
+ file: (cwd) => join(cwd, ".mcp.json"),
295
+ detect: () => existsSync(join(home(), ".claude")) || existsSync(join(home(), ".claude.json")),
296
+ write: (existing, name, entry) => jsonMerge(existing, ["mcpServers"], name, standardEntry(entry)),
297
+ },
298
+ {
299
+ id: "codex",
300
+ label: "Codex CLI",
301
+ file: () => join(home(), ".codex", "config.toml"),
302
+ detect: () => existsSync(join(home(), ".codex")),
303
+ write: (existing, name, entry) => tomlMerge(existing, name, entry),
304
+ },
305
+ {
306
+ id: "vscode",
307
+ label: "VS Code",
308
+ file: (cwd) => join(cwd, ".vscode", "mcp.json"),
309
+ detect: (cwd) => existsSync(join(cwd, ".vscode")) || existsSync(join(home(), ".vscode")),
310
+ write: (existing, name, entry) => jsonMerge(existing, ["servers"], name, standardEntry(entry)),
311
+ },
312
+ {
313
+ id: "windsurf",
314
+ label: "Windsurf",
315
+ file: () => join(home(), ".codeium", "windsurf", "mcp_config.json"),
316
+ detect: () => existsSync(join(home(), ".codeium", "windsurf")),
317
+ write: (existing, name, entry) => jsonMerge(existing, ["mcpServers"], name, entry.url ? { serverUrl: entry.url, ...(entry.headers ? { headers: entry.headers } : {}) } : stdioEntry(entry)),
318
+ },
319
+ {
320
+ id: "gemini-cli",
321
+ label: "Gemini CLI",
322
+ file: () => join(home(), ".gemini", "settings.json"),
323
+ detect: () => existsSync(join(home(), ".gemini")),
324
+ write: (existing, name, entry) => jsonMerge(existing, ["mcpServers"], name, entry.url ? { httpUrl: entry.url, ...(entry.headers ? { headers: entry.headers } : {}) } : stdioEntry(entry)),
325
+ },
326
+ {
327
+ id: "opencode",
328
+ label: "OpenCode",
329
+ file: () => join(xdg(), "opencode", "opencode.json"),
330
+ detect: () => existsSync(join(xdg(), "opencode")),
331
+ write: (existing, name, entry) => jsonMerge(existing, ["mcp"], name, entry.url ? { type: "remote", url: entry.url, ...(entry.headers ? { headers: entry.headers } : {}) } : { type: "local", command: [entry.command ?? "node", ...(entry.args ?? [])] }),
332
+ },
333
+ {
334
+ id: "zed",
335
+ label: "Zed",
336
+ file: () => join(xdg(), "zed", "settings.json"),
337
+ detect: () => existsSync(join(xdg(), "zed")),
338
+ write: (existing, name, entry) => jsonMerge(existing, ["context_servers"], name, entry.url ? { source: "custom", url: entry.url, ...(entry.headers ? { headers: entry.headers } : {}) } : { source: "custom", command: entry.command ?? "node", args: entry.args ?? [] }),
339
+ },
340
+ {
341
+ id: "claude-desktop",
342
+ label: "Claude Desktop",
343
+ file: () => process.platform === "darwin"
344
+ ? join(home(), "Library", "Application Support", "Claude", "claude_desktop_config.json")
345
+ : process.platform === "win32"
346
+ ? join(process.env.APPDATA ?? join(home(), "AppData", "Roaming"), "Claude", "claude_desktop_config.json")
347
+ : join(xdg(), "Claude", "claude_desktop_config.json"),
348
+ detect: () => existsSync(dirname(MCP_CLIENTS.find((c) => c.id === "claude-desktop")!.file(""))),
349
+ write: (existing, name, entry) => jsonMerge(existing, ["mcpServers"], name, stdioEntry(entry)),
350
+ },
351
+ {
352
+ id: "cursor",
353
+ label: "Cursor",
354
+ file: (cwd) => join(cwd, ".cursor", "mcp.json"),
355
+ detect: () => existsSync(join(home(), ".cursor")),
356
+ write: (existing, name, entry) => jsonMerge(existing, ["mcpServers"], name, standardEntry(entry)),
357
+ incompatible: "Cursor does not yet speak MCP 2026-07-28, which is the only version this server serves; the entry is written but Cursor will not connect until it does.",
358
+ },
359
+ ];
360
+
361
+ export function findMcpClient(id: string): McpClient | undefined {
362
+ return MCP_CLIENTS.find((c) => c.id === id);
363
+ }
364
+
365
+ export interface McpWriteResult {
366
+ client: McpClientId;
367
+ file: string;
368
+ written: boolean;
369
+ note?: string;
370
+ }
371
+
372
+ /** Merge the entry into one client's config file. Never writes a literal secret: callers pass env references. */
373
+ export function writeMcpConfig(client: McpClient, cwd: string, name: string, entry: McpEntry): McpWriteResult {
374
+ const file = client.file(cwd);
375
+ if (!entry.url && client.id === "claude-desktop" && !entry.command) {
376
+ return { client: client.id, file, written: false, note: "Claude Desktop reads only stdio servers from its config file; add a remote server as a connector in the app." };
377
+ }
378
+ const existing = readOr(file, "");
379
+ const next = client.write(existing, name, entry);
380
+ mkdirSync(dirname(file), { recursive: true });
381
+ writeFileSync(file, next);
382
+ return { client: client.id, file, written: true, ...(client.incompatible ? { note: client.incompatible } : {}) };
383
+ }
384
+
385
+ /** Does a client's config already mention this server? For doctor. */
386
+ export function mcpConfigured(client: McpClient, cwd: string, name: string): boolean {
387
+ const text = readOr(client.file(cwd), "");
388
+ return text.includes('"' + name + '"') || text.includes("[mcp_servers." + name + "]");
389
+ }
390
+
391
+ // ---- AGENTS.md block ----------------------------------------------------------
392
+
393
+ /**
394
+ * Upsert a marked block into a repo's agent instructions. Idempotent: the
395
+ * block between the markers is replaced, everything else is untouched, and
396
+ * a missing file is created. Returns the file written and whether the
397
+ * block already existed.
398
+ */
399
+ export function upsertAgentBlock(file: string, marker: string, body: string): { file: string; updated: boolean } {
400
+ const start = "<!-- " + marker + " start -->";
401
+ const end = "<!-- " + marker + " end -->";
402
+ const block = start + "\n" + body.trim() + "\n" + end + "\n";
403
+ const existing = readOr(file, "");
404
+ const pattern = new RegExp(escapeRegExp(start) + "[\\s\\S]*?" + escapeRegExp(end) + "\\n?");
405
+ let next: string;
406
+ let updated = false;
407
+ if (pattern.test(existing)) {
408
+ next = existing.replace(pattern, block);
409
+ updated = true;
410
+ } else {
411
+ next = (existing.trimEnd() + (existing.trim() ? "\n\n" : "")) + block;
412
+ }
413
+ mkdirSync(dirname(resolve(file)), { recursive: true });
414
+ writeFileSync(file, next);
415
+ return { file, updated };
416
+ }
417
+
418
+ function escapeRegExp(text: string): string {
419
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
420
+ }
421
+
422
+ /** AGENTS.md by default; CLAUDE.md when only it exists, or when running under Claude Code and AGENTS.md is absent. */
423
+ export function agentInstructionsFile(cwd: string, harness: string | null): string {
424
+ const agents = join(cwd, "AGENTS.md");
425
+ const claude = join(cwd, "CLAUDE.md");
426
+ if (existsSync(agents)) return agents;
427
+ if (existsSync(claude)) return claude;
428
+ return harness === "claude-code" ? claude : agents;
429
+ }
430
+
431
+ // ---- command index + guide ------------------------------------------------
432
+
433
+ export interface CommandFlagSummary {
434
+ flag: string;
435
+ /** string | number | boolean | array | object | json | file */
436
+ type: string;
437
+ /** Element type of an array flag. */
438
+ items?: { type: string; enum?: string[] };
439
+ enum?: string[];
440
+ required: boolean;
441
+ description?: string;
442
+ }
443
+
444
+ export interface CommandSummary {
445
+ resource: string;
446
+ command: string;
447
+ method: string;
448
+ path: string;
449
+ summary?: string;
450
+ paginated: boolean;
451
+ destructive: boolean;
452
+ /** required | optional | none — what the spec's security says. */
453
+ auth?: "required" | "optional" | "none";
454
+ flags: CommandFlagSummary[];
455
+ }
456
+
457
+ export interface AgentContext {
458
+ bin: string;
459
+ pkg: string;
460
+ apiTitle: string;
461
+ version: string;
462
+ envPrefix: string;
463
+ authEnvVars: string[];
464
+ docsUrl: string | null;
465
+ /** The API's hosted MCP endpoint, when it has one. */
466
+ mcpUrl: string | null;
467
+ /** Skills repository (owner/name) an agent can install with npx skills add. */
468
+ skillsRepo: string | null;
469
+ hasMcp: boolean;
470
+ builtins: string[];
471
+ }
472
+
473
+ /** One line per command, pipe-delimited: the compact index that goes into AGENTS.md. */
474
+ /** string, number, usd|eur, string[], usd|eur[], object, json — the type as a reader expects it. */
475
+ export function flagTypeLabel(f: CommandFlagSummary): string {
476
+ const inline = (values: string[] | undefined) => values && values.join("|").length <= 24 ? values.join("|") : values ? "enum" : undefined;
477
+ if (f.type === "array") return (inline(f.items?.enum) ?? f.items?.type ?? "json") + "[]";
478
+ return inline(f.enum) ?? f.type;
479
+ }
480
+
481
+ export function compactIndex(commands: CommandSummary[]): string {
482
+ return commands
483
+ .map((c) => {
484
+ const required = c.flags.filter((f) => f.required).map((f) => "--" + f.flag + (f.type === "boolean" ? "" : " <" + flagTypeLabel(f) + ">"));
485
+ return c.resource + " " + c.command + " | " + c.method + " " + c.path + (required.length ? " | " + required.join(" ") : "") + (c.summary ? " | " + c.summary : "") + (c.auth === "none" ? " | no auth" : "");
486
+ })
487
+ .join("\n");
488
+ }
489
+
490
+ /** The AGENTS.md block body. */
491
+ export function agentBlock(ctx: AgentContext, commands: CommandSummary[]): string {
492
+ const auth = ctx.authEnvVars.length ? ctx.authEnvVars.join(", ") : "(none)";
493
+ return [
494
+ "## " + ctx.bin + " CLI (" + ctx.apiTitle + ")",
495
+ "",
496
+ "Generated by typeship. API commands write JSON on stdout; discovery commands take --json. Errors are JSON on stderr ({status, issues[{code,message}], next_steps}), exit 0/1/2. Non-interactive under an agent: no prompts, no browsers.",
497
+ "",
498
+ "- Auth: " + auth + " in the environment, or `" + ctx.bin + " login`. Never write a key into a file in this repo.",
499
+ "- Discover: `" + ctx.bin + " --help`, `" + ctx.bin + " <resource> <command> --help`, `" + ctx.bin + " help --json` (machine-readable), `" + ctx.bin + " agent-guide --format json`.",
500
+ "- Docs: " + (ctx.docsUrl ? "`" + ctx.bin + " docs search <term> --json`; " + ctx.docsUrl + "/llms.txt" : "`" + ctx.bin + " docs <resource> <command> --json`") + ".",
501
+ "- Lists: `--all` streams every page as NDJSON. Destructive commands need `--force`.",
502
+ ...(ctx.hasMcp ? ["- MCP: `" + ctx.bin + " mcp install --all` registers this API's MCP server with the agent clients on this machine" + (ctx.mcpUrl ? " (hosted: " + ctx.mcpUrl + ")" : "") + "."] : []),
503
+ "",
504
+ "Commands (resource command | METHOD path | required flags | summary):",
505
+ "",
506
+ "```",
507
+ compactIndex(commands),
508
+ "```",
509
+ ].join("\n");
510
+ }
511
+
512
+ /** What `agent-guide --format json` returns. */
513
+ export function agentGuide(ctx: AgentContext, commands: CommandSummary[]): Record<string, unknown> {
514
+ const first = commands.find((c) => c.method === "GET" && c.flags.every((f) => !f.required)) ?? commands[0];
515
+ return {
516
+ name: ctx.bin,
517
+ package: ctx.pkg,
518
+ api: ctx.apiTitle,
519
+ version: ctx.version,
520
+ generated_by: "typeship",
521
+ guide: agentBlock(ctx, commands),
522
+ first_command: first ? ctx.bin + " " + first.resource + " " + first.command : ctx.bin + " --help",
523
+ docs_index_url: ctx.docsUrl ? ctx.docsUrl.replace(/\/+$/, "") + "/llms.txt" : null,
524
+ docs_full_url: ctx.docsUrl ? ctx.docsUrl.replace(/\/+$/, "") + "/llms-full.txt" : null,
525
+ hosted_mcp_url: ctx.mcpUrl,
526
+ local_mcp: ctx.hasMcp ? ctx.bin + "-mcp (stdio) or '" + ctx.bin + " mcp install --all'" : null,
527
+ skills_install: ctx.skillsRepo ? "npx skills add " + ctx.skillsRepo : null,
528
+ auth_env_vars: ctx.authEnvVars,
529
+ conventions: {
530
+ output: "API commands return JSON on stdout; discovery commands take --json. --fields a,b.c keeps only those paths (per item for lists); --all streams NDJSON for paginated lists; SSE operations stream NDJSON events.",
531
+ errors: "JSON envelope on stderr: {status: 'error'|'action_required', issues: [{code, message}], docs_url?, next_steps: [], detail?}. Branch on issues[].code.",
532
+ exit_codes: { "0": "ok", "1": "the request or command failed", "2": "usage: wrong flags, missing arguments, or a confirmation was required" },
533
+ agent_mode: "--mode agent, " + ctx.envPrefix + "_MODE=agent, or no terminal on stdin and stdout: no prompts, no browsers, --yes implied for non-destructive stops.",
534
+ destructive: "Commands classified as destructive need --force (or --yes); without it they return CONFIRMATION_REQUIRED with the exact command to run.",
535
+ flags: "Positional path arguments first, then --flags. Array flags take a comma list, the flag repeated, or a JSON array; object flags take JSON. --data '<json>' merges under field flags (@<file> reads a file, - reads stdin).",
536
+ pagination: "Paginated commands return {items, hasMore, nextPage, nextCommand}: run nextCommand for the next page, or --all walks every page.",
537
+ },
538
+ builtins: ctx.builtins,
539
+ next_steps: [
540
+ ...(ctx.authEnvVars.length ? ["Set " + ctx.authEnvVars[0] + " in the environment or run '" + ctx.bin + " login'."] : []),
541
+ "Run '" + ctx.bin + " auth check'.",
542
+ "Run '" + ctx.bin + " help --json' for the command index, or read the AGENTS.md block '" + ctx.bin + " init' writes.",
543
+ ...(ctx.hasMcp ? ["Run '" + ctx.bin + " mcp install --all' if this session has an MCP-capable client."] : []),
544
+ ],
545
+ };
546
+ }
547
+
548
+ // ---- skills ---------------------------------------------------------------------
549
+
550
+ export interface SkillsInstallResult {
551
+ status: "installed" | "skipped" | "failed";
552
+ repo: string | null;
553
+ detail?: string;
554
+ }
555
+
556
+ /** Install a skills repository through the skills CLI (npx skills add). Best effort: init reports, never fails on it. */
557
+ export function installSkills(repo: string | null, options: { global?: boolean; agent?: string | null } = {}): SkillsInstallResult {
558
+ if (!repo) return { status: "skipped", repo: null, detail: "No skills repository is configured for this CLI." };
559
+ const args = ["-y", "skills", "add", repo, "-y", ...(options.global === false ? [] : ["-g"])];
560
+ if (options.agent) args.push("-a", options.agent);
561
+ // Windows resolves npx to npx.cmd, which only a shell can start.
562
+ const result = spawnSync("npx", args, { encoding: "utf8", timeout: 120_000, stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
563
+ if (result.error || result.status !== 0) {
564
+ return { status: "failed", repo, detail: (result.stderr || result.stdout || result.error?.message || "npx skills add failed").toString().trim().slice(-400) };
565
+ }
566
+ return { status: "installed", repo };
567
+ }
568
+
569
+ // ---- file bundles ------------------------------------------------------------------
570
+
571
+ /**
572
+ * A response that carries files: an array property whose items have string
573
+ * `path` and `content`. typeship's own generate call is one; any API that
574
+ * returns generated or exported files is another. `--out <dir>` writes them.
575
+ */
576
+ export function bundleProperty(outputSchema: Record<string, unknown> | undefined): string | null {
577
+ const props = outputSchema?.properties as Record<string, { type?: string; items?: { properties?: Record<string, { type?: string }>; required?: string[] } }> | undefined;
578
+ if (!props) return null;
579
+ for (const [name, schema] of Object.entries(props)) {
580
+ const item = schema.items;
581
+ if (schema.type === "array" && item?.properties?.path && item.properties.content) return name;
582
+ }
583
+ return null;
584
+ }
585
+
586
+ /** A non-page response whose result is a collection wrapped in one array
587
+ * property (`{data: [...]}` is the common shape). `--fields` applies to
588
+ * each item while preserving that envelope, just as it does for pages. */
589
+ export function collectionProperty(outputSchema: Record<string, unknown> | undefined): string | null {
590
+ const props = outputSchema?.properties as Record<string, { type?: string | string[] }> | undefined;
591
+ const names = props ? Object.keys(props) : [];
592
+ if (!props || names.length !== 1) return null;
593
+ const name = names[0]!;
594
+ const type = props[name]?.type;
595
+ return type === "array" || (Array.isArray(type) && type.includes("array")) ? name : null;
596
+ }
597
+
598
+ export function writeBundle(dir: string, files: { path: string; content: string }[]): { dir: string; written: number; paths: string[] } {
599
+ const paths: string[] = [];
600
+ for (const file of files) {
601
+ const rel = file.path.replace(/^\/+/, "");
602
+ if (rel.split(/[\\/]/).includes("..")) continue;
603
+ const full = join(dir, rel);
604
+ mkdirSync(dirname(full), { recursive: true });
605
+ writeFileSync(full, file.content);
606
+ paths.push(rel);
607
+ }
608
+ return { dir, written: paths.length, paths };
609
+ }
610
+
611
+ // ---- claims ----------------------------------------------------------------------------
612
+
613
+ /**
614
+ * A response that carries a claim: an object property `claim` with a string
615
+ * `url` (typeship's anonymous generate is one). The CLI tells the person
616
+ * where to claim and leaves a breadcrumb in the working directory so a
617
+ * later session (or `doctor`) can list what is still unclaimed.
618
+ */
619
+ export function claimProperty(outputSchema: Record<string, unknown> | undefined): boolean {
620
+ type Variant = { properties?: Record<string, unknown>; oneOf?: Variant[]; anyOf?: Variant[] };
621
+ const props = outputSchema?.properties as Record<string, Variant> | undefined;
622
+ const claim = props?.claim;
623
+ if (!claim) return false;
624
+ const hasUrl = (v: Variant): boolean => Boolean(v.properties && "url" in v.properties) || (v.oneOf ?? v.anyOf ?? []).some(hasUrl);
625
+ return hasUrl(claim);
626
+ }
627
+
628
+ export interface ClaimBreadcrumb {
629
+ url: string;
630
+ expires_at?: string;
631
+ command: string;
632
+ created_at: string;
633
+ }
634
+
635
+ export function breadcrumbFile(cwd: string, bin: string): string {
636
+ return join(cwd, "." + bin, "claims.json");
637
+ }
638
+
639
+ /** Append a claim to .<bin>/claims.json (git-ignored when a .gitignore exists). */
640
+ export function recordClaim(cwd: string, bin: string, crumb: ClaimBreadcrumb): string {
641
+ const file = breadcrumbFile(cwd, bin);
642
+ let list: ClaimBreadcrumb[] = [];
643
+ try {
644
+ list = JSON.parse(readFileSync(file, "utf8")) as ClaimBreadcrumb[];
645
+ } catch { /* none yet */ }
646
+ list.push(crumb);
647
+ mkdirSync(dirname(file), { recursive: true });
648
+ writeFileSync(file, JSON.stringify(list, null, 2) + "\n");
649
+ const gitignore = join(cwd, ".gitignore");
650
+ if (existsSync(gitignore)) {
651
+ const text = readFileSync(gitignore, "utf8");
652
+ const line = "." + bin + "/";
653
+ if (!text.split("\n").some((l) => l.trim() === line || l.trim() === "." + bin)) writeFileSync(gitignore, text.trimEnd() + "\n" + line + "\n");
654
+ }
655
+ return file;
656
+ }
657
+
658
+ /** Claims on file that have not expired. */
659
+ export function pendingClaims(cwd: string, bin: string): ClaimBreadcrumb[] {
660
+ try {
661
+ const list = JSON.parse(readFileSync(breadcrumbFile(cwd, bin), "utf8")) as ClaimBreadcrumb[];
662
+ const now = Date.now();
663
+ return list.filter((c) => !c.expires_at || new Date(c.expires_at).getTime() > now);
664
+ } catch {
665
+ return [];
666
+ }
667
+ }
668
+
669
+ // ---- doctor --------------------------------------------------------------------------
670
+
671
+ export interface DoctorCheck {
672
+ name: string;
673
+ ok: boolean;
674
+ detail?: string;
675
+ fix?: string;
676
+ }
677
+
678
+ export function summarizeDoctor(checks: DoctorCheck[]): { status: "ok" | "action_required"; checks: DoctorCheck[]; next_steps: string[] } {
679
+ const failing = checks.filter((c) => !c.ok);
680
+ return {
681
+ status: failing.length === 0 ? "ok" : "action_required",
682
+ checks,
683
+ next_steps: failing.map((c) => c.fix).filter((f): f is string => Boolean(f)),
684
+ };
685
+ }