@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.
- package/LICENSE +9 -0
- package/README.md +40 -0
- package/api.json +5163 -0
- package/api.md +512 -0
- package/dist/cli-agent.d.ts +204 -0
- package/dist/cli-agent.d.ts.map +1 -0
- package/dist/cli-agent.js +525 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +2811 -0
- package/dist/core/http.d.ts +303 -0
- package/dist/core/http.d.ts.map +1 -0
- package/dist/core/http.js +770 -0
- package/dist/core/pagination.d.ts +51 -0
- package/dist/core/pagination.d.ts.map +1 -0
- package/dist/core/pagination.js +154 -0
- package/dist/dates.d.ts +33 -0
- package/dist/dates.d.ts.map +1 -0
- package/dist/dates.js +136 -0
- package/dist/errors.d.ts +81 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +103 -0
- package/dist/index.d.ts +92 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +86 -0
- package/dist/ops.d.ts +115 -0
- package/dist/ops.d.ts.map +1 -0
- package/dist/ops.js +79 -0
- package/dist/resources/account.d.ts +18 -0
- package/dist/resources/account.d.ts.map +1 -0
- package/dist/resources/account.js +26 -0
- package/dist/resources/api-keys.d.ts +37 -0
- package/dist/resources/api-keys.d.ts.map +1 -0
- package/dist/resources/api-keys.js +67 -0
- package/dist/resources/generate.d.ts +25 -0
- package/dist/resources/generate.d.ts.map +1 -0
- package/dist/resources/generate.js +41 -0
- package/dist/resources/generations.d.ts +31 -0
- package/dist/resources/generations.d.ts.map +1 -0
- package/dist/resources/generations.js +56 -0
- package/dist/resources/projects.d.ts +110 -0
- package/dist/resources/projects.d.ts.map +1 -0
- package/dist/resources/projects.js +220 -0
- package/dist/resources/spec-revisions.d.ts +47 -0
- package/dist/resources/spec-revisions.d.ts.map +1 -0
- package/dist/resources/spec-revisions.js +90 -0
- package/dist/schemas.d.ts +6 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +88 -0
- package/dist/types.d.ts +759 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +37 -0
- package/package.json +43 -0
- package/src/cli-agent.ts +685 -0
- package/src/cli.ts +2695 -0
- package/src/core/http.ts +1008 -0
- package/src/core/pagination.ts +195 -0
- package/src/dates.ts +126 -0
- package/src/errors.ts +117 -0
- package/src/index.ts +153 -0
- package/src/ops.ts +174 -0
- package/src/resources/account.ts +43 -0
- package/src/resources/api-keys.ts +105 -0
- package/src/resources/generate.ts +69 -0
- package/src/resources/generations.ts +100 -0
- package/src/resources/projects.ts +391 -0
- package/src/resources/spec-revisions.ts +150 -0
- package/src/schemas.ts +90 -0
- package/src/types.ts +825 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,2695 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// typeship — command-line client. Generated by typeship — https://typeship.dev
|
|
3
|
+
// Flags-only for API commands (CI-safe); `login` is the one interactive
|
|
4
|
+
// exception (hidden prompt on a TTY, --with-token/--token for scripts).
|
|
5
|
+
// Prints raw JSON to stdout; errors as JSON on stderr.
|
|
6
|
+
// Exit codes: 0 success, 1 API/transport error, 2 usage error.
|
|
7
|
+
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { homedir, hostname } from "node:os";
|
|
12
|
+
import { basename, dirname, join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { TypeshipClient, formatDebugEvent, type DebugEvent } from "./index.js";
|
|
15
|
+
import { GLOBALS, OPS, buildArgs, findOp, missingRequired, type OpSpec, type ParamSpec } from "./ops.js";
|
|
16
|
+
import {
|
|
17
|
+
MCP_CLIENTS, agentGuide, agentBlock, agentInstructionsFile, agentMode, bundleProperty, claimProperty, classifyApiError, collectionProperty, detectHarness, envelope,
|
|
18
|
+
exitCodeFor, findMcpClient, installSkills, mcpConfigured, pendingClaims, recordClaim, summarizeDoctor, upsertAgentBlock, writeBundle, writeMcpConfig,
|
|
19
|
+
type AgentContext, type CommandSummary, type DoctorCheck, type EnvelopeInput, type IssueCode, type McpEntry, type McpWriteResult,
|
|
20
|
+
} from "./cli-agent.js";
|
|
21
|
+
import { relativeDate } from "./dates.js";
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
const BIN = "typeship";
|
|
25
|
+
const DEFAULT_BASE_URL = "https://typeship.dev/api/v1";
|
|
26
|
+
const AUTH_SCALARS: { option: string; flag: string; env: string }[] = [{"option":"bearerToken","flag":"token","env":"TYPESHIP_TOKEN"}];
|
|
27
|
+
const BASIC: { envUser: string; envPass: string } | null = null;
|
|
28
|
+
const EXCLUDED_OPS = 0;
|
|
29
|
+
const VERSION = "0.6.0";
|
|
30
|
+
const API_VERSION = "0.6.0";
|
|
31
|
+
const SPEC_FORMAT = "openapi";
|
|
32
|
+
const WHOAMI: { resource: string; method: string } | null = {"resource":"account","method":"retrieve"};
|
|
33
|
+
const ENVIRONMENTS: Record<string, string> = {};
|
|
34
|
+
const HAS_MCP = false;
|
|
35
|
+
const PKG_NAME = "@typeship-ax/cli";
|
|
36
|
+
const UPDATE_NOTICE = false;
|
|
37
|
+
const API_DESCRIPTION: string | null = "Generate production SDKs, CLIs, and MCP servers from an OpenAPI or\nGraphQL spec, and keep every selected output current.\n\nEvery operation but one requires an API key, created in the console and\nsent as `Authorization: Bearer ak_...`. A browser session is not a\ncredential for this API. The exception is POST /generate, which works\nanonymously with the free plan's limits.\n";
|
|
38
|
+
const DOCS_URL_DEFAULT: string | null = "https://typeship.dev";
|
|
39
|
+
const RELAY: { mintUrl: string; project: string } | null = null;
|
|
40
|
+
const SUPPORT_URL: string | null = null;
|
|
41
|
+
const OAUTH_TOKEN_URL: string | null = null;
|
|
42
|
+
const OAUTH_CLIENT_ID: string | null = null;
|
|
43
|
+
const OAUTH_SCOPES: string[] = [];
|
|
44
|
+
const OAUTH_TOKEN_PARAMS: Record<string, string> = {};
|
|
45
|
+
const MCP_URL: string | null = "https://typeship.dev/mcp";
|
|
46
|
+
const SKILLS_REPO: string | null = "typeship-ax/skills";
|
|
47
|
+
const CLI_AUTH_URL: string | null = "https://typeship.dev/api/auth/cli";
|
|
48
|
+
const ENV_PREFIX = "TYPESHIP";
|
|
49
|
+
const API_TITLE = "typeship";
|
|
50
|
+
|
|
51
|
+
interface Parsed {
|
|
52
|
+
positionals: string[];
|
|
53
|
+
flags: Map<string, string | boolean>;
|
|
54
|
+
/** Every value of a flag given more than once (`--ids a --ids b`);
|
|
55
|
+
* flags keeps the last one for scalar callers. */
|
|
56
|
+
repeated: Map<string, string[]>;
|
|
57
|
+
help: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Flags that never take a value, so they don't swallow the next positional
|
|
61
|
+
* (`--non-interactive accounts list`). Built-in commands' own switches
|
|
62
|
+
* (`mcp --cursor`, `upgrade --check`) count only under that command, so an
|
|
63
|
+
* API parameter with the same name (`accounts list --cursor <c>`) still
|
|
64
|
+
* takes its value. Boolean API parameters are recognized once the command
|
|
65
|
+
* is known. */
|
|
66
|
+
const CORE_BOOLEAN_FLAGS = new Set(["all", "version", "non-interactive", "debug", "validate", "yes", "force", "json"]);
|
|
67
|
+
const BUILTIN_BOOLEAN_FLAGS: Record<string, string[]> = {
|
|
68
|
+
login: ["with-token", "no-browser"],
|
|
69
|
+
upgrade: ["check"],
|
|
70
|
+
mcp: ["claude", "cursor", "claude-desktop", "codex", "vscode", "windsurf", "gemini", "opencode", "zed", "all", "read-only"],
|
|
71
|
+
docs: ["web", "schema"],
|
|
72
|
+
init: ["all", "yes", "no-skills", "no-mcp", "no-agents-md"],
|
|
73
|
+
auth: ["live"],
|
|
74
|
+
doctor: [],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function isBooleanFlag(name: string, positionals: string[]): boolean {
|
|
78
|
+
if (CORE_BOOLEAN_FLAGS.has(name)) return true;
|
|
79
|
+
const first = positionals[0];
|
|
80
|
+
if (first !== undefined && (BUILTIN_BOOLEAN_FLAGS[first] ?? []).includes(name)) return true;
|
|
81
|
+
if (positionals.length >= 2) {
|
|
82
|
+
const op = findOp(positionals[0]!, positionals[1]!);
|
|
83
|
+
if (op) return op.params.some((p) => p.flag === name && p.type === "boolean");
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseArgv(argv: string[]): Parsed {
|
|
89
|
+
const positionals: string[] = [];
|
|
90
|
+
const flags = new Map<string, string | boolean>();
|
|
91
|
+
const repeated = new Map<string, string[]>();
|
|
92
|
+
let help = false;
|
|
93
|
+
const setFlag = (name: string, value: string | boolean) => {
|
|
94
|
+
if (typeof value === "string" && flags.has(name)) {
|
|
95
|
+
const previous = flags.get(name);
|
|
96
|
+
const list = repeated.get(name) ?? (typeof previous === "string" ? [previous] : []);
|
|
97
|
+
list.push(value);
|
|
98
|
+
repeated.set(name, list);
|
|
99
|
+
}
|
|
100
|
+
flags.set(name, value);
|
|
101
|
+
};
|
|
102
|
+
for (let i = 0; i < argv.length; i++) {
|
|
103
|
+
const arg = argv[i]!;
|
|
104
|
+
if (arg === "--help" || arg === "-h") { help = true; continue; }
|
|
105
|
+
if (arg === "-v") { flags.set("version", true); continue; }
|
|
106
|
+
if (arg === "-y") { flags.set("yes", true); continue; }
|
|
107
|
+
if (arg === "-k" && argv[i + 1] !== undefined) { setFlag("k", argv[i + 1]!); i++; continue; }
|
|
108
|
+
if (arg.startsWith("--")) {
|
|
109
|
+
const eq = arg.indexOf("=");
|
|
110
|
+
if (eq !== -1) {
|
|
111
|
+
setFlag(arg.slice(2, eq), arg.slice(eq + 1));
|
|
112
|
+
} else if (isBooleanFlag(arg.slice(2), positionals)) {
|
|
113
|
+
const next = argv[i + 1];
|
|
114
|
+
if (next === "true" || next === "false") { setFlag(arg.slice(2), next); i++; }
|
|
115
|
+
else flags.set(arg.slice(2), true);
|
|
116
|
+
} else {
|
|
117
|
+
const next = argv[i + 1];
|
|
118
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
119
|
+
setFlag(arg.slice(2), next);
|
|
120
|
+
i++;
|
|
121
|
+
} else {
|
|
122
|
+
flags.set(arg.slice(2), true);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
positionals.push(arg);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { positionals, flags, repeated, help };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** --mode agent > TYPESHIP_MODE=agent > no terminal on stdin and stdout. In
|
|
133
|
+
* agent mode nothing prompts and no browser opens; every stop is a JSON
|
|
134
|
+
* envelope with next_steps. See cli-agent.ts. */
|
|
135
|
+
function isAgentMode(parsed: Parsed): boolean {
|
|
136
|
+
return agentMode({
|
|
137
|
+
flagMode: parsed.flags.get("mode"),
|
|
138
|
+
envMode: process.env["TYPESHIP_MODE"],
|
|
139
|
+
stdoutIsTTY: process.stdout.isTTY === true,
|
|
140
|
+
stdinIsTTY: process.stdin.isTTY === true,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The explicit switch only: --non-interactive or TYPESHIP_NON_INTERACTIVE=1. */
|
|
145
|
+
function explicitNonInteractive(parsed: Parsed): boolean {
|
|
146
|
+
return parsed.flags.get("non-interactive") === true || process.env["TYPESHIP_NON_INTERACTIVE"] === "1";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Explicit switch, or agent mode: nothing prompts, no browser opens. */
|
|
150
|
+
function nonInteractive(parsed: Parsed): boolean {
|
|
151
|
+
return explicitNonInteractive(parsed) || isAgentMode(parsed);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** --yes / --force (or TYPESHIP_YES=1): skip confirmations, including destructive ones. */
|
|
155
|
+
function assumeYes(parsed: Parsed): boolean {
|
|
156
|
+
return parsed.flags.get("yes") === true || parsed.flags.get("force") === true || process.env["TYPESHIP_YES"] === "1";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// color — human surfaces only (help, stderr hints); JSON is never colored
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
let COLOR_OUT = false;
|
|
164
|
+
let COLOR_ERR = false;
|
|
165
|
+
|
|
166
|
+
/** --color on|off|auto (Stripe-style; explicit beats NO_COLOR beats TTY). */
|
|
167
|
+
function colorEnabled(stream: { isTTY?: boolean }, parsed: Parsed): boolean {
|
|
168
|
+
const flag = parsed.flags.get("color");
|
|
169
|
+
if (flag === "off" || flag === "never") return false;
|
|
170
|
+
if (flag === "on" || flag === "always") return true;
|
|
171
|
+
if (flag !== undefined && flag !== true && flag !== "auto") {
|
|
172
|
+
fail(2, "--color expects on, off, or auto");
|
|
173
|
+
}
|
|
174
|
+
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "") return false;
|
|
175
|
+
return stream.isTTY === true && process.env.TERM !== "dumb";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const ANSI = { bold: "1", dim: "2", cyan: "36", yellow: "33", green: "32", red: "31" } as const;
|
|
179
|
+
|
|
180
|
+
function paintOut(code: keyof typeof ANSI, text: string): string {
|
|
181
|
+
return COLOR_OUT ? "\x1b[" + ANSI[code] + "m" + text + "\x1b[0m" : text;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function paintErr(code: keyof typeof ANSI, text: string): string {
|
|
185
|
+
return COLOR_ERR ? "\x1b[" + ANSI[code] + "m" + text + "\x1b[0m" : text;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function out(value: unknown): void {
|
|
189
|
+
process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** --fields a,b.c: the dotted paths to keep in API results (null = everything). Set in main(). */
|
|
193
|
+
let FIELDS: string[][] | null = null;
|
|
194
|
+
|
|
195
|
+
/** Keep only FIELDS of a result: arrays item by item, objects by dotted path; scalars untouched. */
|
|
196
|
+
function project(value: unknown): unknown {
|
|
197
|
+
if (FIELDS === null) return value;
|
|
198
|
+
if (Array.isArray(value)) return value.map(project);
|
|
199
|
+
if (value === null || typeof value !== "object") return value;
|
|
200
|
+
const out: Record<string, unknown> = {};
|
|
201
|
+
for (const path of FIELDS) {
|
|
202
|
+
let cursor: unknown = value;
|
|
203
|
+
for (const key of path) {
|
|
204
|
+
if (cursor === null || typeof cursor !== "object" || Array.isArray(cursor)) { cursor = undefined; break; }
|
|
205
|
+
cursor = (cursor as Record<string, unknown>)[key];
|
|
206
|
+
}
|
|
207
|
+
if (cursor === undefined) continue;
|
|
208
|
+
let target = out;
|
|
209
|
+
for (const key of path.slice(0, -1)) {
|
|
210
|
+
const next = target[key];
|
|
211
|
+
if (next === undefined || next === null || typeof next !== "object" || Array.isArray(next)) target[key] = {};
|
|
212
|
+
target = target[key] as Record<string, unknown>;
|
|
213
|
+
}
|
|
214
|
+
target[path[path.length - 1]!] = cursor;
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Thrown after scheduling exit so sync callers stop; main() swallows it. */
|
|
220
|
+
class ExitPending extends Error {}
|
|
221
|
+
|
|
222
|
+
/** Exit only after stdout has flushed — process.exit() mid-write truncates
|
|
223
|
+
* large payloads on pipes. */
|
|
224
|
+
function flushExit(code: number): Promise<never> {
|
|
225
|
+
return new Promise(() => {
|
|
226
|
+
process.stdout.write("", () => process.exit(code));
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Every failure is one JSON envelope on stderr:
|
|
232
|
+
* {status: "error"|"action_required", issues: [{code, message}], docs_url?, next_steps: [], detail?}
|
|
233
|
+
* Branch on issues[].code (stable), never on the message. Exit 1 when the
|
|
234
|
+
* request or command failed, 2 for usage (wrong flags, missing arguments,
|
|
235
|
+
* a confirmation the caller must give).
|
|
236
|
+
*/
|
|
237
|
+
function failWith(input: EnvelopeInput): never {
|
|
238
|
+
const body = envelope({ docsUrl: DOCS_URL_DEFAULT, ...input });
|
|
239
|
+
const code = exitCodeFor(input.code);
|
|
240
|
+
const payload = HUMAN_ERRORS ? humanError(body, code) : JSON.stringify(body, null, 2) + "\n";
|
|
241
|
+
process.stderr.write(payload, () => process.exit(code));
|
|
242
|
+
throw new ExitPending();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Set once argv is parsed: a person at a terminal reads prose; pipes, CI,
|
|
246
|
+
* and agents get the JSON envelope, byte for byte. */
|
|
247
|
+
let HUMAN_ERRORS = false;
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The same envelope as prose for a terminal:
|
|
251
|
+
* acme: No such account
|
|
252
|
+
* Check the resource id; list the resource first.
|
|
253
|
+
* NOT_FOUND · HTTP 404 · exit 1 · pipe stderr or --mode agent for JSON
|
|
254
|
+
*/
|
|
255
|
+
function humanError(body: ReturnType<typeof envelope>, code: number): string {
|
|
256
|
+
const lines: string[] = [];
|
|
257
|
+
const issue = body.issues[0];
|
|
258
|
+
lines.push(paintErr("red", BIN + ":") + " " + (issue?.message ?? "failed"));
|
|
259
|
+
for (const extra of body.issues.slice(1)) lines.push(" " + extra.message);
|
|
260
|
+
for (const step of body.next_steps ?? []) lines.push(" " + step);
|
|
261
|
+
const detail = body.detail as { status?: number; body?: unknown; violations?: unknown } | undefined;
|
|
262
|
+
if (detail?.violations !== undefined) {
|
|
263
|
+
for (const v of (detail.violations as { path?: string; message?: string }[]).slice(0, 8)) lines.push(" " + paintErr("dim", (v.path ? v.path + ": " : "") + (v.message ?? "")));
|
|
264
|
+
} else if (detail?.body !== undefined) {
|
|
265
|
+
// The API's own body, when the message above did not already come from
|
|
266
|
+
// it; the request id always, for support tickets.
|
|
267
|
+
const compact = typeof detail.body === "string" ? detail.body : JSON.stringify(detail.body);
|
|
268
|
+
const firstWords = (issue?.message ?? "").slice(0, 40);
|
|
269
|
+
if (compact && compact !== "{}" && !(firstWords && compact.includes(firstWords))) lines.push(" " + paintErr("dim", "API said: " + (compact.length > 300 ? compact.slice(0, 297) + "…" : compact)));
|
|
270
|
+
const requestId = (detail.body as { request_id?: unknown; requestId?: unknown } | null)?.request_id ?? (detail.body as { requestId?: unknown } | null)?.requestId;
|
|
271
|
+
if (typeof requestId === "string") lines.push(" " + paintErr("dim", "request id: " + requestId));
|
|
272
|
+
}
|
|
273
|
+
const tags = [issue?.code ?? "ERROR", ...(typeof detail?.status === "number" && detail.status > 0 ? ["HTTP " + detail.status] : []), "exit " + code, "pipe stderr or --mode agent for JSON"];
|
|
274
|
+
lines.push(" " + paintErr("dim", tags.join(" · ")));
|
|
275
|
+
return lines.join("\n") + "\n";
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The --help a usage error points at: the command's own once one is resolved. */
|
|
279
|
+
let USAGE_HINT = BIN + " --help";
|
|
280
|
+
|
|
281
|
+
/** Legacy shape: exit 2 is usage, everything else is a failed command. The
|
|
282
|
+
* message states the problem; what to do next goes in next_steps (the
|
|
283
|
+
* usage pointer by default, for exit 2). */
|
|
284
|
+
function fail(code: number, message: string, extra?: unknown, nextSteps?: string[]): never {
|
|
285
|
+
const usageCode: IssueCode = /^Unknown command/.test(message) ? "UNKNOWN_COMMAND"
|
|
286
|
+
: /^Unknown flag/.test(message) ? "UNKNOWN_FLAG"
|
|
287
|
+
: /^(Missing required|Expected \d+ argument)/.test(message) ? "MISSING_ARGUMENT"
|
|
288
|
+
: "INVALID_USAGE";
|
|
289
|
+
return failWith({
|
|
290
|
+
code: code === 2 ? usageCode : "COMMAND_FAILED",
|
|
291
|
+
message,
|
|
292
|
+
...(extra !== undefined ? { detail: extra } : {}),
|
|
293
|
+
nextSteps: nextSteps ?? (code === 2 ? ["Run '" + USAGE_HINT + "' for the flags this command takes."] : []),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** An SDK error result as an envelope: status-derived code, the API's body as detail, concrete next steps. */
|
|
298
|
+
function failApi(error: unknown, hadCredential: boolean): never {
|
|
299
|
+
return failWith(classifyApiError(error, { bin: BIN, hadCredential, docsUrl: DOCS_URL_DEFAULT }));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// credentials — written by `login`, cleared by `logout`
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
interface StoredCreds {
|
|
307
|
+
scalars?: Record<string, string>;
|
|
308
|
+
basic?: { username: string; password: string };
|
|
309
|
+
oauth?: { accessToken: string; refreshToken?: string; expiresAt?: number };
|
|
310
|
+
/** Set when this CLI minted the stored credential for itself (browser
|
|
311
|
+
* approval), so `logout` knows it may revoke it. A pasted key is not. */
|
|
312
|
+
minted?: { via: "browser"; key_name: string; org_id?: string };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function configDir(): string {
|
|
316
|
+
const base = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
|
|
317
|
+
return join(base, BIN);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function credsPath(): string {
|
|
321
|
+
return join(configDir(), "credentials.json");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function readCreds(): StoredCreds | null {
|
|
325
|
+
try {
|
|
326
|
+
return JSON.parse(readFileSync(credsPath(), "utf8")) as StoredCreds;
|
|
327
|
+
} catch {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function writeCreds(creds: StoredCreds): void {
|
|
333
|
+
mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
334
|
+
writeFileSync(credsPath(), JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function readStdin(): Promise<string> {
|
|
338
|
+
return new Promise((resolve) => {
|
|
339
|
+
let data = "";
|
|
340
|
+
process.stdin.setEncoding("utf8");
|
|
341
|
+
process.stdin.on("data", (chunk) => { data += chunk; });
|
|
342
|
+
process.stdin.on("end", () => resolve(data));
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** One visible line from a TTY, for yes/no confirmations. */
|
|
347
|
+
function readLine(): Promise<string> {
|
|
348
|
+
return new Promise((resolve) => {
|
|
349
|
+
const stdin = process.stdin;
|
|
350
|
+
stdin.resume();
|
|
351
|
+
stdin.setEncoding("utf8");
|
|
352
|
+
const onData = (chunk: string) => {
|
|
353
|
+
stdin.pause();
|
|
354
|
+
stdin.off("data", onData);
|
|
355
|
+
resolve(chunk);
|
|
356
|
+
};
|
|
357
|
+
stdin.on("data", onData);
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Hidden-input prompt. Login is the one interactive command; every API
|
|
362
|
+
* command stays flags-only. */
|
|
363
|
+
function promptHidden(promptText: string): Promise<string> {
|
|
364
|
+
return new Promise((resolve) => {
|
|
365
|
+
process.stderr.write(promptText);
|
|
366
|
+
const stdin = process.stdin;
|
|
367
|
+
if (stdin.isTTY) stdin.setRawMode(true);
|
|
368
|
+
stdin.resume();
|
|
369
|
+
stdin.setEncoding("utf8");
|
|
370
|
+
let value = "";
|
|
371
|
+
const onData = (chunk: string) => {
|
|
372
|
+
for (const ch of chunk) {
|
|
373
|
+
if (ch === "\n" || ch === "\r" || ch === "\u0004") {
|
|
374
|
+
if (stdin.isTTY) stdin.setRawMode(false);
|
|
375
|
+
stdin.pause();
|
|
376
|
+
stdin.off("data", onData);
|
|
377
|
+
process.stderr.write("\n");
|
|
378
|
+
resolve(value);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (ch === "\u0003") {
|
|
382
|
+
if (stdin.isTTY) stdin.setRawMode(false);
|
|
383
|
+
process.stderr.write("\n");
|
|
384
|
+
process.exit(2);
|
|
385
|
+
}
|
|
386
|
+
if (ch === "\u007f" || ch === "\b") { value = value.slice(0, -1); continue; }
|
|
387
|
+
value += ch;
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
stdin.on("data", onData);
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function oauthForm(url: string, params: Record<string, string>): Promise<{ status: number; body: Record<string, unknown> | null }> {
|
|
395
|
+
const response = await fetch(url, {
|
|
396
|
+
method: "POST",
|
|
397
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
398
|
+
body: new URLSearchParams(params).toString(),
|
|
399
|
+
});
|
|
400
|
+
let body: Record<string, unknown> | null = null;
|
|
401
|
+
try { body = await response.json() as Record<string, unknown>; } catch { /* non-JSON error body */ }
|
|
402
|
+
return { status: response.status, body };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** RFC 8628 device flow: discover the device endpoint from the token URL's
|
|
406
|
+
* well-known metadata, show the code, poll until authorized. */
|
|
407
|
+
async function deviceLogin(clientId: string, agent = false): Promise<void> {
|
|
408
|
+
if (!OAUTH_TOKEN_URL) fail(2, "This API declares no OAuth token URL.");
|
|
409
|
+
const origin = new URL(OAUTH_TOKEN_URL).origin;
|
|
410
|
+
let deviceEndpoint: string | undefined;
|
|
411
|
+
let tokenEndpoint = OAUTH_TOKEN_URL;
|
|
412
|
+
for (const wellKnown of ["/.well-known/oauth-authorization-server", "/.well-known/openid-configuration"]) {
|
|
413
|
+
try {
|
|
414
|
+
const response = await fetch(origin + wellKnown, { headers: { Accept: "application/json" } });
|
|
415
|
+
if (!response.ok) continue;
|
|
416
|
+
const meta = await response.json() as { device_authorization_endpoint?: string; token_endpoint?: string };
|
|
417
|
+
if (meta.device_authorization_endpoint) {
|
|
418
|
+
deviceEndpoint = meta.device_authorization_endpoint;
|
|
419
|
+
if (meta.token_endpoint) tokenEndpoint = meta.token_endpoint;
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
} catch { /* try the next well-known path */ }
|
|
423
|
+
}
|
|
424
|
+
if (!deviceEndpoint) {
|
|
425
|
+
fail(1, "The authorization server does not advertise a device flow. Run '" + BIN + " login' with a pasted credential instead.");
|
|
426
|
+
}
|
|
427
|
+
const start = await oauthForm(deviceEndpoint!, {
|
|
428
|
+
client_id: clientId,
|
|
429
|
+
...(OAUTH_SCOPES.length > 0 ? { scope: OAUTH_SCOPES.join(" ") } : {}),
|
|
430
|
+
...OAUTH_TOKEN_PARAMS,
|
|
431
|
+
});
|
|
432
|
+
const startBody = start.body as { device_code?: string; user_code?: string; verification_uri?: string; verification_uri_complete?: string; interval?: number; expires_in?: number } | null;
|
|
433
|
+
if (start.status !== 200 || !startBody?.device_code) {
|
|
434
|
+
fail(1, "Device authorization failed (HTTP " + start.status + ").", start.body);
|
|
435
|
+
}
|
|
436
|
+
const uri = startBody!.verification_uri_complete ?? startBody!.verification_uri;
|
|
437
|
+
process.stderr.write("Open " + paintErr("cyan", String(uri)) + " and enter code: " + paintErr("bold", String(startBody!.user_code)) + "\n");
|
|
438
|
+
// Under an agent the same facts also go out as one JSON line (stderr,
|
|
439
|
+
// so stdout stays the single result document), for the agent to hand
|
|
440
|
+
// the URL and code to the user while this polls.
|
|
441
|
+
if (agent) process.stderr.write(JSON.stringify({ event: "device_code", verification_uri: uri, user_code: startBody!.user_code, expires_in: startBody!.expires_in ?? 900 }) + "\n");
|
|
442
|
+
let intervalMs = (startBody!.interval ?? 5) * 1000;
|
|
443
|
+
const deadline = Date.now() + (startBody!.expires_in ?? 900) * 1000;
|
|
444
|
+
while (Date.now() < deadline) {
|
|
445
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
446
|
+
const poll = await oauthForm(tokenEndpoint, {
|
|
447
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
448
|
+
device_code: startBody!.device_code!,
|
|
449
|
+
client_id: clientId,
|
|
450
|
+
});
|
|
451
|
+
const tokenBody = poll.body as { access_token?: string; refresh_token?: string; expires_in?: number; error?: string } | null;
|
|
452
|
+
if (poll.status === 200 && tokenBody?.access_token) {
|
|
453
|
+
writeCreds({
|
|
454
|
+
...(readCreds() ?? {}),
|
|
455
|
+
oauth: {
|
|
456
|
+
accessToken: tokenBody.access_token,
|
|
457
|
+
refreshToken: tokenBody.refresh_token,
|
|
458
|
+
expiresAt: tokenBody.expires_in ? Date.now() + tokenBody.expires_in * 1000 : undefined,
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
process.stderr.write(paintErr("green", "Logged in.") + "\n");
|
|
462
|
+
out({ ok: true, method: "device", credentials: credsPath() });
|
|
463
|
+
await flushExit(0);
|
|
464
|
+
}
|
|
465
|
+
const errorCode = tokenBody?.error;
|
|
466
|
+
if (errorCode === "authorization_pending") continue;
|
|
467
|
+
if (errorCode === "slow_down") { intervalMs += 5000; continue; }
|
|
468
|
+
fail(1, "Device login failed: " + (errorCode ?? "HTTP " + poll.status), poll.body);
|
|
469
|
+
}
|
|
470
|
+
fail(1, "Device login timed out before the code was entered.");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Stored OAuth access token, refreshed through the token URL when expired.
|
|
474
|
+
* A failed refresh returns the stale token; the API's 401 explains better
|
|
475
|
+
* than a local guess. */
|
|
476
|
+
async function refreshedOauthToken(stored: StoredCreds): Promise<string | undefined> {
|
|
477
|
+
const oauth = stored.oauth;
|
|
478
|
+
if (!oauth) return undefined;
|
|
479
|
+
const expired = oauth.expiresAt !== undefined && Date.now() > oauth.expiresAt - 60_000;
|
|
480
|
+
if (!expired || !oauth.refreshToken || !OAUTH_TOKEN_URL) return oauth.accessToken;
|
|
481
|
+
const clientId = process.env["TYPESHIP_CLIENT_ID"] ?? OAUTH_CLIENT_ID;
|
|
482
|
+
const result = await oauthForm(OAUTH_TOKEN_URL, {
|
|
483
|
+
grant_type: "refresh_token",
|
|
484
|
+
refresh_token: oauth.refreshToken,
|
|
485
|
+
...(clientId ? { client_id: clientId } : {}),
|
|
486
|
+
});
|
|
487
|
+
const body = result.body as { access_token?: string; refresh_token?: string; expires_in?: number } | null;
|
|
488
|
+
if (result.status === 200 && body?.access_token) {
|
|
489
|
+
const next = {
|
|
490
|
+
accessToken: body.access_token,
|
|
491
|
+
refreshToken: body.refresh_token ?? oauth.refreshToken,
|
|
492
|
+
expiresAt: body.expires_in ? Date.now() + body.expires_in * 1000 : undefined,
|
|
493
|
+
};
|
|
494
|
+
writeCreds({ ...stored, oauth: next });
|
|
495
|
+
return next.accessToken;
|
|
496
|
+
}
|
|
497
|
+
return oauth.accessToken;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function storePastedToken(stored: StoredCreds, token: string): Promise<void> {
|
|
501
|
+
const first = AUTH_SCALARS[0];
|
|
502
|
+
if (!first) fail(2, "This API declares no credential the CLI can store. Use --username/--password if it uses basic auth.");
|
|
503
|
+
stored.scalars = { ...stored.scalars, [first!.option]: token };
|
|
504
|
+
writeCreds(stored);
|
|
505
|
+
out({ ok: true, method: "paste", stored_as: first!.flag, credentials: credsPath() });
|
|
506
|
+
await flushExit(0);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Browser approval (PKCE-shaped): POST <CLI_AUTH_URL>/start with an S256
|
|
511
|
+
* challenge, show the person the verification URL (open it unless we are
|
|
512
|
+
* headless or under an agent), poll <CLI_AUTH_URL>/status with the verifier
|
|
513
|
+
* until the API hands back a key minted for this CLI, store it. The key
|
|
514
|
+
* never crosses the chat: the agent relays a URL, the person clicks once.
|
|
515
|
+
*/
|
|
516
|
+
/** The approval endpoint follows the base URL: a preview or local deployment
|
|
517
|
+
* of the API approves its own logins. */
|
|
518
|
+
function cliAuthUrl(flags: Map<string, string | boolean>): string {
|
|
519
|
+
const base = resolveBaseUrl(flags);
|
|
520
|
+
try {
|
|
521
|
+
if (CLI_AUTH_URL && DEFAULT_BASE_URL && base) {
|
|
522
|
+
const defaultOrigin = new URL(DEFAULT_BASE_URL).origin;
|
|
523
|
+
const origin = new URL(base).origin;
|
|
524
|
+
if (origin !== defaultOrigin && CLI_AUTH_URL.startsWith(defaultOrigin)) return origin + CLI_AUTH_URL.slice(defaultOrigin.length);
|
|
525
|
+
}
|
|
526
|
+
} catch { /* fall through to the configured URL */ }
|
|
527
|
+
return CLI_AUTH_URL!;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Browser approval, start to key: opens (or prints) the approval URL and
|
|
531
|
+
* polls until a person decides. Returns the minted credential; every
|
|
532
|
+
* failure exits with the envelope. Shared by `login` and `init`. */
|
|
533
|
+
async function browserApprove(headless: boolean, flags: Map<string, string | boolean>): Promise<{ api_key: string; key_name: string; org_id?: string }> {
|
|
534
|
+
const first = AUTH_SCALARS[0]!;
|
|
535
|
+
const authUrl = cliAuthUrl(flags);
|
|
536
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
537
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
538
|
+
const name = BIN + " CLI on " + hostname();
|
|
539
|
+
const source = headless && !process.stdin.isTTY ? "agent" : "cli";
|
|
540
|
+
let start: { session?: string; verification_url?: string; expires_in?: number; interval?: number; error?: string };
|
|
541
|
+
try {
|
|
542
|
+
const response = await fetch(authUrl + "/start", {
|
|
543
|
+
method: "POST",
|
|
544
|
+
headers: { "Content-Type": "application/json" },
|
|
545
|
+
body: JSON.stringify({ code_challenge: challenge, name, source }),
|
|
546
|
+
signal: AbortSignal.timeout(15_000),
|
|
547
|
+
});
|
|
548
|
+
start = await response.json() as typeof start;
|
|
549
|
+
if (!response.ok || !start.session || !start.verification_url) {
|
|
550
|
+
failWith({ code: "COMMAND_FAILED", message: "Could not start the browser login: " + (start.error ?? "HTTP " + response.status), nextSteps: ["Pass the credential directly: '" + BIN + " login --" + first.flag + " <value>'."] });
|
|
551
|
+
}
|
|
552
|
+
} catch (e) {
|
|
553
|
+
failWith({ code: "NETWORK_ERROR", message: "Could not reach " + authUrl + "/start: " + (e as Error).message, nextSteps: ["Check the network, or pass the credential directly: '" + BIN + " login --" + first.flag + " <value>'."] });
|
|
554
|
+
}
|
|
555
|
+
const url = start!.verification_url!;
|
|
556
|
+
const expiresIn = start!.expires_in ?? 600;
|
|
557
|
+
const intervalMs = Math.max(1, start!.interval ?? 3) * 1000;
|
|
558
|
+
process.stderr.write("Approve this CLI in your browser: " + paintErr("cyan", url) + "\n");
|
|
559
|
+
if (headless) {
|
|
560
|
+
process.stderr.write(JSON.stringify({ event: "browser_approval", verification_url: url, expires_in: expiresIn, note: "Give this URL to the user; polling until they approve or it expires." }) + "\n");
|
|
561
|
+
} else {
|
|
562
|
+
openInBrowser(url);
|
|
563
|
+
}
|
|
564
|
+
const deadline = Date.now() + expiresIn * 1000;
|
|
565
|
+
while (Date.now() < deadline) {
|
|
566
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
567
|
+
let poll: { status?: string; api_key?: string; key_name?: string; org_id?: string };
|
|
568
|
+
try {
|
|
569
|
+
const response = await fetch(authUrl + "/status", {
|
|
570
|
+
method: "POST",
|
|
571
|
+
headers: { "Content-Type": "application/json" },
|
|
572
|
+
body: JSON.stringify({ session: start!.session, code_verifier: verifier }),
|
|
573
|
+
signal: AbortSignal.timeout(15_000),
|
|
574
|
+
});
|
|
575
|
+
poll = await response.json() as typeof poll;
|
|
576
|
+
} catch {
|
|
577
|
+
continue; // a blip; the next tick tries again
|
|
578
|
+
}
|
|
579
|
+
if (poll.status === "pending") continue;
|
|
580
|
+
if (poll.status === "complete" && poll.api_key) {
|
|
581
|
+
return { api_key: poll.api_key, key_name: poll.key_name ?? name, ...(poll.org_id ? { org_id: poll.org_id } : {}) };
|
|
582
|
+
}
|
|
583
|
+
if (poll.status === "denied") failWith({ status: "action_required", code: "AUTH_INVALID", message: "The request was denied in the browser.", nextSteps: ["Run '" + BIN + " login' again if that was a mistake, or pass a credential directly with --" + first.flag + "."] });
|
|
584
|
+
if (poll.status === "expired") break;
|
|
585
|
+
failWith({ code: "COMMAND_FAILED", message: "Browser login stopped: " + (poll.status ?? "unknown status"), nextSteps: ["Run '" + BIN + " login' again."] });
|
|
586
|
+
}
|
|
587
|
+
failWith({ status: "action_required", code: "TTY_REQUIRED", message: "The browser approval expired after " + expiresIn + "s without a decision.", nextSteps: ["Run '" + BIN + " login' again and approve the link within ten minutes.", "Or pass the credential directly: '" + BIN + " login --" + first.flag + " <value>'."] });
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Store what the browser approval minted, marked as this CLI's own. */
|
|
591
|
+
function storeMinted(stored: StoredCreds, minted: { api_key: string; key_name: string; org_id?: string }): void {
|
|
592
|
+
const first = AUTH_SCALARS[0]!;
|
|
593
|
+
stored.scalars = { ...stored.scalars, [first.option]: minted.api_key };
|
|
594
|
+
stored.minted = { via: "browser", key_name: minted.key_name, ...(minted.org_id ? { org_id: minted.org_id } : {}) };
|
|
595
|
+
writeCreds(stored);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async function browserLogin(stored: StoredCreds, headless: boolean, flags: Map<string, string | boolean>): Promise<void> {
|
|
599
|
+
const minted = await browserApprove(headless, flags);
|
|
600
|
+
storeMinted(stored, minted);
|
|
601
|
+
out({ ok: true, method: "browser", key_name: minted.key_name, ...(minted.org_id ? { org_id: minted.org_id } : {}), credentials: credsPath() });
|
|
602
|
+
await flushExit(0);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function cmdLogin(parsed: Parsed): Promise<void> {
|
|
606
|
+
if (parsed.help) {
|
|
607
|
+
const lines = [
|
|
608
|
+
BIN + " login — store credentials at " + credsPath(),
|
|
609
|
+
"",
|
|
610
|
+
...AUTH_SCALARS.map((a) => " " + BIN + " login --" + a.flag + " <value>"),
|
|
611
|
+
...(CLI_AUTH_URL ? [" " + BIN + " login approve in the browser: a key is minted for you (add --no-browser to print the link instead of opening it)"] : []),
|
|
612
|
+
" " + BIN + " login --with-token read the credential from stdin (CI)",
|
|
613
|
+
...(OAUTH_TOKEN_URL ? [" " + BIN + " login --client-id <id> OAuth device flow" + (OAUTH_CLIENT_ID ? " (a default id is built in)" : "")] : []),
|
|
614
|
+
...(BASIC ? [" " + BIN + " login --username <u> --password <p>"] : []),
|
|
615
|
+
...(CLI_AUTH_URL ? [] : [" " + BIN + " login interactive prompt (TTY only)"]),
|
|
616
|
+
"",
|
|
617
|
+
"Precedence at request time: flags > env vars > stored credentials.",
|
|
618
|
+
];
|
|
619
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
620
|
+
await flushExit(0);
|
|
621
|
+
}
|
|
622
|
+
const stored: StoredCreds = readCreds() ?? {};
|
|
623
|
+
|
|
624
|
+
const scalarValues: Record<string, string> = {};
|
|
625
|
+
for (const a of AUTH_SCALARS) {
|
|
626
|
+
const v = parsed.flags.get(a.flag);
|
|
627
|
+
if (typeof v === "string") scalarValues[a.option] = v;
|
|
628
|
+
}
|
|
629
|
+
const username = parsed.flags.get("username");
|
|
630
|
+
const password = parsed.flags.get("password");
|
|
631
|
+
const gotBasic = BASIC !== null && typeof username === "string" && typeof password === "string";
|
|
632
|
+
if (Object.keys(scalarValues).length > 0 || gotBasic) {
|
|
633
|
+
if (Object.keys(scalarValues).length > 0) stored.scalars = { ...stored.scalars, ...scalarValues };
|
|
634
|
+
if (gotBasic) stored.basic = { username: username as string, password: password as string };
|
|
635
|
+
writeCreds(stored);
|
|
636
|
+
out({ ok: true, method: "flags", credentials: credsPath() });
|
|
637
|
+
await flushExit(0);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (parsed.flags.get("with-token") === true) {
|
|
641
|
+
const token = (await readStdin()).trim();
|
|
642
|
+
if (!token) fail(2, "--with-token expects the credential on stdin.");
|
|
643
|
+
await storePastedToken(stored, token);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const clientId = (typeof parsed.flags.get("client-id") === "string" ? parsed.flags.get("client-id") as string : undefined)
|
|
647
|
+
?? process.env["TYPESHIP_CLIENT_ID"] ?? OAUTH_CLIENT_ID ?? undefined;
|
|
648
|
+
if (OAUTH_TOKEN_URL && clientId !== undefined && !explicitNonInteractive(parsed)) {
|
|
649
|
+
await deviceLogin(clientId, isAgentMode(parsed));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Browser approval: the API mints a key for this CLI once a person
|
|
653
|
+
// approves in the browser. Works under an agent too (it prints the URL and
|
|
654
|
+
// polls); only the explicit non-interactive switch turns it off.
|
|
655
|
+
if (CLI_AUTH_URL && AUTH_SCALARS[0] && !explicitNonInteractive(parsed)) {
|
|
656
|
+
await browserLogin(stored, isAgentMode(parsed) || parsed.flags.get("no-browser") === true, parsed.flags);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (nonInteractive(parsed) || !process.stdin.isTTY) {
|
|
660
|
+
failWith({
|
|
661
|
+
status: "action_required",
|
|
662
|
+
code: "TTY_REQUIRED",
|
|
663
|
+
message: "login needs a terminal to prompt, and there is none.",
|
|
664
|
+
nextSteps: [
|
|
665
|
+
...AUTH_SCALARS.map((a) => "Pass the credential: '" + BIN + " login --" + a.flag + " <value>', or set " + a.env + " in the environment."),
|
|
666
|
+
"Pipe it: echo \"$TOKEN\" | " + BIN + " login --with-token",
|
|
667
|
+
...(OAUTH_TOKEN_URL ? ["Device flow: '" + BIN + " login --client-id <id>' (prints a URL and code for the user)."] : []),
|
|
668
|
+
...(CLI_AUTH_URL ? ["Browser approval: '" + BIN + " login --no-browser' prints a link for the user to approve and waits."] : []),
|
|
669
|
+
],
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
const first = AUTH_SCALARS[0];
|
|
673
|
+
if (!first) fail(2, "This API declares no credential the CLI can prompt for. See '" + BIN + " login --help'.");
|
|
674
|
+
const token = (await promptHidden("Paste " + first.flag.replace(/-/g, " ") + " (input hidden): ")).trim();
|
|
675
|
+
if (!token) fail(2, "Nothing entered.");
|
|
676
|
+
await storePastedToken(stored, token);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function cmdLogout(): Promise<void> {
|
|
680
|
+
const stored = readCreds();
|
|
681
|
+
const existed = existsSync(credsPath());
|
|
682
|
+
// A key this CLI minted for itself (browser approval) is revoked on the
|
|
683
|
+
// way out, so logging out ends the credential and not just the file. A
|
|
684
|
+
// pasted or CI key is someone else's to revoke, and is left alone.
|
|
685
|
+
let revoked: boolean | null = null;
|
|
686
|
+
const first = AUTH_SCALARS[0];
|
|
687
|
+
const ownKey = first && stored?.minted?.via === "browser" ? stored.scalars?.[first.option] : undefined;
|
|
688
|
+
if (ownKey && CLI_AUTH_URL) {
|
|
689
|
+
try {
|
|
690
|
+
const response = await fetch(CLI_AUTH_URL + "/revoke", { method: "POST", headers: { Authorization: "Bearer " + ownKey }, signal: AbortSignal.timeout(15_000) });
|
|
691
|
+
revoked = response.ok;
|
|
692
|
+
} catch {
|
|
693
|
+
revoked = false;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
rmSync(credsPath(), { force: true });
|
|
697
|
+
out({ ok: true, removed: existed ? credsPath() : null, ...(revoked === null ? {} : { revoked, key_name: stored?.minted?.key_name }) });
|
|
698
|
+
await flushExit(0);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async function cmdWhoami(parsed: Parsed): Promise<void> {
|
|
702
|
+
const op = WHOAMI ? OPS.find((o) => o.resource === WHOAMI.resource && o.method === WHOAMI.method) : undefined;
|
|
703
|
+
if (op) {
|
|
704
|
+
const client = await makeClient(parsed.flags);
|
|
705
|
+
const target = (client as unknown as Record<string, Record<string, () => Promise<{ ok: boolean; data?: unknown; error?: unknown }>>>)[op.resource]!;
|
|
706
|
+
const result = await target[op.method]!();
|
|
707
|
+
if (result.ok) { out(result.data ?? { ok: true }); await flushExit(0); }
|
|
708
|
+
failApi(result.error, LAST_CLIENT_HAD_CREDENTIAL);
|
|
709
|
+
}
|
|
710
|
+
const stored = readCreds();
|
|
711
|
+
let source = "none";
|
|
712
|
+
if (AUTH_SCALARS.some((a) => typeof parsed.flags.get(a.flag) === "string")) {
|
|
713
|
+
source = "flags";
|
|
714
|
+
} else {
|
|
715
|
+
const envScalar = AUTH_SCALARS.find((a) => process.env[a.env] !== undefined);
|
|
716
|
+
if (envScalar) source = "env:" + envScalar.env;
|
|
717
|
+
else if (stored && (stored.scalars || stored.basic || stored.oauth)) source = "login";
|
|
718
|
+
}
|
|
719
|
+
out({ authenticated: source !== "none", source, credentials: existsSync(credsPath()) ? credsPath() : null });
|
|
720
|
+
await flushExit(source === "none" ? 1 : 0);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// ---------------------------------------------------------------------------
|
|
724
|
+
// config — stored defaults (base URL / named environment)
|
|
725
|
+
// ---------------------------------------------------------------------------
|
|
726
|
+
|
|
727
|
+
interface CliConfigFile {
|
|
728
|
+
baseUrl?: string;
|
|
729
|
+
environment?: string;
|
|
730
|
+
docsUrl?: string;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function configFilePath(): string {
|
|
734
|
+
return join(configDir(), "config.json");
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function readConfig(): CliConfigFile {
|
|
738
|
+
try {
|
|
739
|
+
return JSON.parse(readFileSync(configFilePath(), "utf8")) as CliConfigFile;
|
|
740
|
+
} catch {
|
|
741
|
+
return {};
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function writeConfig(config: CliConfigFile): void {
|
|
746
|
+
mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
747
|
+
writeFileSync(configFilePath(), JSON.stringify(config, null, 2) + "\n");
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const CONFIG_KEYS = ["base-url", "environment", "docs-url"];
|
|
751
|
+
|
|
752
|
+
function configGet(config: CliConfigFile, key: string): string | undefined {
|
|
753
|
+
return key === "base-url" ? config.baseUrl : key === "environment" ? config.environment : config.docsUrl;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function cmdConfig(parsed: Parsed): Promise<void> {
|
|
757
|
+
const sub = parsed.positionals[1];
|
|
758
|
+
const key = parsed.positionals[2];
|
|
759
|
+
const value = parsed.positionals[3];
|
|
760
|
+
if (parsed.help) {
|
|
761
|
+
const lines = [
|
|
762
|
+
BIN + " config — stored defaults at " + configFilePath(),
|
|
763
|
+
"",
|
|
764
|
+
" " + BIN + " config list",
|
|
765
|
+
" " + BIN + " config get <" + CONFIG_KEYS.join("|") + ">",
|
|
766
|
+
" " + BIN + " config set base-url <url>",
|
|
767
|
+
...(Object.keys(ENVIRONMENTS).length > 0
|
|
768
|
+
? [" " + BIN + " config set environment <" + Object.keys(ENVIRONMENTS).join("|") + ">"]
|
|
769
|
+
: []),
|
|
770
|
+
" " + BIN + " config set docs-url <url> docs site for '" + BIN + " docs'",
|
|
771
|
+
" " + BIN + " config unset <" + CONFIG_KEYS.join("|") + ">",
|
|
772
|
+
" " + BIN + " config path",
|
|
773
|
+
"",
|
|
774
|
+
"Base URL resolution: --base-url > env var > config base-url > config environment > spec default.",
|
|
775
|
+
];
|
|
776
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
777
|
+
await flushExit(0);
|
|
778
|
+
}
|
|
779
|
+
if (sub === undefined || sub === "list") {
|
|
780
|
+
const config = readConfig();
|
|
781
|
+
out({
|
|
782
|
+
base_url: config.baseUrl ?? null,
|
|
783
|
+
environment: config.environment ?? null,
|
|
784
|
+
docs_url: config.docsUrl ?? DOCS_URL_DEFAULT,
|
|
785
|
+
environments: Object.keys(ENVIRONMENTS),
|
|
786
|
+
file: existsSync(configFilePath()) ? configFilePath() : null,
|
|
787
|
+
});
|
|
788
|
+
await flushExit(0);
|
|
789
|
+
}
|
|
790
|
+
if (sub === "path") {
|
|
791
|
+
process.stdout.write(configFilePath() + "\n");
|
|
792
|
+
await flushExit(0);
|
|
793
|
+
}
|
|
794
|
+
if (sub === "get" || sub === "set" || sub === "unset") {
|
|
795
|
+
if (key === undefined || !CONFIG_KEYS.includes(key)) {
|
|
796
|
+
fail(2, "config " + sub + " expects one of: " + CONFIG_KEYS.join(", "));
|
|
797
|
+
}
|
|
798
|
+
const config = readConfig();
|
|
799
|
+
if (sub === "get") {
|
|
800
|
+
out({ [key.replace(/-/g, "_")]: configGet(config, key) ?? null });
|
|
801
|
+
await flushExit(0);
|
|
802
|
+
}
|
|
803
|
+
if (sub === "unset") {
|
|
804
|
+
if (key === "base-url") delete config.baseUrl;
|
|
805
|
+
else if (key === "environment") delete config.environment;
|
|
806
|
+
else delete config.docsUrl;
|
|
807
|
+
writeConfig(config);
|
|
808
|
+
out({ ok: true });
|
|
809
|
+
await flushExit(0);
|
|
810
|
+
}
|
|
811
|
+
if (value === undefined) fail(2, "config set " + key + " expects a value");
|
|
812
|
+
if (key === "base-url" || key === "docs-url") {
|
|
813
|
+
try {
|
|
814
|
+
new URL(value!);
|
|
815
|
+
} catch {
|
|
816
|
+
fail(2, key + " must be a valid URL");
|
|
817
|
+
}
|
|
818
|
+
if (key === "base-url") config.baseUrl = value;
|
|
819
|
+
else config.docsUrl = value;
|
|
820
|
+
} else {
|
|
821
|
+
if (!(value! in ENVIRONMENTS)) {
|
|
822
|
+
fail(2, Object.keys(ENVIRONMENTS).length > 0
|
|
823
|
+
? "environment must be one of: " + Object.keys(ENVIRONMENTS).join(", ")
|
|
824
|
+
: "The spec declares no named environments; use 'config set base-url' instead.");
|
|
825
|
+
}
|
|
826
|
+
config.environment = value;
|
|
827
|
+
}
|
|
828
|
+
writeConfig(config);
|
|
829
|
+
out({ ok: true, [key.replace(/-/g, "_")]: value });
|
|
830
|
+
await flushExit(0);
|
|
831
|
+
}
|
|
832
|
+
fail(2, "Unknown config command: " + String(sub) + ". Use list, get, set, unset, or path.");
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ---------------------------------------------------------------------------
|
|
836
|
+
// mcp — wire the sibling MCP server into agent clients
|
|
837
|
+
// ---------------------------------------------------------------------------
|
|
838
|
+
|
|
839
|
+
function mcpServerPath(): { path: string; warning: string | null } {
|
|
840
|
+
const self = fileURLToPath(import.meta.url);
|
|
841
|
+
let path = self.replace(/cli\.(js|mjs|cjs)$/, "mcp.$1");
|
|
842
|
+
let warning: string | null = null;
|
|
843
|
+
if (self.endsWith(".ts")) {
|
|
844
|
+
path = self.replace(/([\\/])src([\\/])cli\.ts$/, "$1dist$2mcp.js");
|
|
845
|
+
if (!existsSync(path)) {
|
|
846
|
+
warning = "Build the package first (npm install && npm run build) so " + path + " exists.";
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return { path, warning };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function claudeDesktopConfigPath(): string {
|
|
853
|
+
if (process.platform === "darwin") {
|
|
854
|
+
return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
855
|
+
}
|
|
856
|
+
if (process.platform === "win32") {
|
|
857
|
+
return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
858
|
+
}
|
|
859
|
+
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "Claude", "claude_desktop_config.json");
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** The mcp entry for a client: the hosted endpoint when the API has one
|
|
863
|
+
* (auth env var as a reference, never a literal), else the local stdio
|
|
864
|
+
* server. --url overrides. */
|
|
865
|
+
function mcpEntryFor(url: string | undefined, readOnly = false): { entry: McpEntry; warnings: string[] } {
|
|
866
|
+
const warnings: string[] = [];
|
|
867
|
+
const hosted = url ?? MCP_URL ?? undefined;
|
|
868
|
+
if (hosted) {
|
|
869
|
+
const envVar = AUTH_SCALARS[0]?.env;
|
|
870
|
+
// The hosted endpoint serves its read-only twin at <url>/readonly; a
|
|
871
|
+
// remote server of someone else's may not, so say so.
|
|
872
|
+
const target = readOnly ? hosted.replace(/\/+$/, "") + "/readonly" : hosted;
|
|
873
|
+
if (readOnly && url !== undefined && !MCP_URL) warnings.push("--read-only appended /readonly to the URL, which typeship-hosted endpoints serve; check that this server does too.");
|
|
874
|
+
return { entry: { url: target, ...(envVar ? { headers: { Authorization: "Bearer ${" + envVar + "}" } } : {}) }, warnings };
|
|
875
|
+
}
|
|
876
|
+
if (!HAS_MCP) {
|
|
877
|
+
fail(2, "This package was generated without the MCP server target. Regenerate with it, or pass --url for a remote endpoint.");
|
|
878
|
+
}
|
|
879
|
+
const server = mcpServerPath();
|
|
880
|
+
if (server.warning) warnings.push(server.warning);
|
|
881
|
+
return { entry: { command: "node", args: [server.path, ...(readOnly ? ["--read-only"] : [])] }, warnings };
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const MCP_CLIENT_FLAGS: Record<string, string> = {
|
|
885
|
+
claude: "claude-code", cursor: "cursor", "claude-desktop": "claude-desktop", codex: "codex", vscode: "vscode",
|
|
886
|
+
windsurf: "windsurf", gemini: "gemini-cli", opencode: "opencode", zed: "zed",
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
async function cmdMcp(parsed: Parsed): Promise<void> {
|
|
890
|
+
if (parsed.help) {
|
|
891
|
+
const lines = [
|
|
892
|
+
BIN + " mcp — connect this API's MCP server to agent clients",
|
|
893
|
+
"",
|
|
894
|
+
" " + BIN + " mcp print the server entry JSON",
|
|
895
|
+
" " + BIN + " mcp install --all write it into every agent client found on this machine",
|
|
896
|
+
" " + BIN + " mcp install --claude Claude Code (./.mcp.json)",
|
|
897
|
+
" " + BIN + " mcp install --codex Codex CLI (~/.codex/config.toml)",
|
|
898
|
+
" " + BIN + " mcp install --vscode VS Code (./.vscode/mcp.json)",
|
|
899
|
+
" " + BIN + " mcp install --windsurf | --gemini | --opencode | --zed | --claude-desktop",
|
|
900
|
+
" " + BIN + " mcp install --cursor Cursor (./.cursor/mcp.json; see note below)",
|
|
901
|
+
" " + BIN + " mcp --url <https://...> use a remote MCP endpoint instead of the local server",
|
|
902
|
+
" " + BIN + " mcp install --claude --read-only register a read-only server (writes are not callable)",
|
|
903
|
+
"",
|
|
904
|
+
(MCP_URL ? "Default entry: the hosted endpoint " + MCP_URL + " with the auth env var as a reference (never a literal key)." : "Default entry: this package's local stdio server, which reads credentials saved by '" + BIN + " login' or the CLI's auth env vars."),
|
|
905
|
+
"The old spelling '" + BIN + " mcp --claude' still works. --all skips Cursor until it speaks MCP 2026-07-28.",
|
|
906
|
+
];
|
|
907
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
908
|
+
await flushExit(0);
|
|
909
|
+
}
|
|
910
|
+
const url = typeof parsed.flags.get("url") === "string" ? parsed.flags.get("url") as string : undefined;
|
|
911
|
+
if (url) {
|
|
912
|
+
try {
|
|
913
|
+
new URL(url);
|
|
914
|
+
} catch {
|
|
915
|
+
fail(2, "--url must be a valid URL");
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
const { entry, warnings } = mcpEntryFor(url, parsed.flags.get("read-only") === true);
|
|
919
|
+
const cwd = process.cwd();
|
|
920
|
+
const wanted = new Set<string>();
|
|
921
|
+
for (const [flag, id] of Object.entries(MCP_CLIENT_FLAGS)) if (parsed.flags.get(flag) === true) wanted.add(id);
|
|
922
|
+
const all = parsed.flags.get("all") === true;
|
|
923
|
+
if (all) {
|
|
924
|
+
for (const client of MCP_CLIENTS) if (!client.incompatible && client.detect(cwd)) wanted.add(client.id);
|
|
925
|
+
}
|
|
926
|
+
if (wanted.has("claude-desktop") && entry.url) {
|
|
927
|
+
if (all) wanted.delete("claude-desktop");
|
|
928
|
+
else fail(2, "Claude Desktop reads only stdio servers from its config file; add a remote server as a connector in Claude Desktop (Settings > Connectors > Add custom connector) with " + entry.url + ". Or drop --url to register this package's local server.");
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
if (wanted.size === 0) {
|
|
932
|
+
out({
|
|
933
|
+
server: BIN,
|
|
934
|
+
entry,
|
|
935
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
936
|
+
detected: MCP_CLIENTS.filter((c) => c.detect(cwd)).map((c) => c.id),
|
|
937
|
+
note: "Pass --all to write this entry into every detected client, one of --claude/--codex/--vscode/--windsurf/--gemini/--opencode/--zed/--claude-desktop/--cursor for a specific one, or merge it under mcpServers yourself." + (entry.url ? "" : " The server reads credentials saved by '" + BIN + " login'."),
|
|
938
|
+
});
|
|
939
|
+
await flushExit(0);
|
|
940
|
+
}
|
|
941
|
+
const results: McpWriteResult[] = [];
|
|
942
|
+
for (const id of wanted) {
|
|
943
|
+
const client = findMcpClient(id)!;
|
|
944
|
+
results.push(writeMcpConfig(client, cwd, BIN, entry));
|
|
945
|
+
}
|
|
946
|
+
out({
|
|
947
|
+
ok: true,
|
|
948
|
+
server: BIN,
|
|
949
|
+
entry,
|
|
950
|
+
written: results.filter((r) => r.written).map((r) => r.file),
|
|
951
|
+
clients: results,
|
|
952
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
953
|
+
...(all && MCP_CLIENTS.some((c) => c.incompatible && c.detect(cwd)) ? { skipped: MCP_CLIENTS.filter((c) => c.incompatible && c.detect(cwd)).map((c) => ({ client: c.id, reason: c.incompatible })) } : {}),
|
|
954
|
+
});
|
|
955
|
+
await flushExit(0);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// ---------------------------------------------------------------------------
|
|
959
|
+
// agent contract — init, agent-guide, auth check, doctor, help --json
|
|
960
|
+
// ---------------------------------------------------------------------------
|
|
961
|
+
|
|
962
|
+
function agentContext(): AgentContext {
|
|
963
|
+
return {
|
|
964
|
+
bin: BIN,
|
|
965
|
+
pkg: PKG_NAME,
|
|
966
|
+
apiTitle: API_TITLE,
|
|
967
|
+
version: VERSION,
|
|
968
|
+
envPrefix: ENV_PREFIX,
|
|
969
|
+
authEnvVars: [...AUTH_SCALARS.map((a) => a.env), ...(BASIC ? [BASIC.envUser, BASIC.envPass] : [])],
|
|
970
|
+
docsUrl: docsSiteUrl(),
|
|
971
|
+
mcpUrl: MCP_URL,
|
|
972
|
+
skillsRepo: SKILLS_REPO,
|
|
973
|
+
hasMcp: HAS_MCP,
|
|
974
|
+
builtins: BUILTIN_COMMANDS,
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function commandSummaries(): CommandSummary[] {
|
|
979
|
+
return OPS.map((op) => ({
|
|
980
|
+
resource: op.command[0],
|
|
981
|
+
command: op.command[1],
|
|
982
|
+
method: op.httpMethod,
|
|
983
|
+
path: op.path,
|
|
984
|
+
...(op.summary ? { summary: op.summary } : {}),
|
|
985
|
+
paginated: op.paginated,
|
|
986
|
+
destructive: op.safety === "destructive",
|
|
987
|
+
auth: op.auth,
|
|
988
|
+
flags: op.params.filter((p) => p.kind !== "path").map((p) => ({ flag: p.flag, type: p.type, ...(p.items ? { items: p.items } : {}), ...(p.enum ? { enum: p.enum } : {}), required: p.required, ...(p.description ? { description: p.description.split("\n")[0] } : {}) })),
|
|
989
|
+
}));
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* help --json: a compact command index. Full schemas live behind the
|
|
994
|
+
* per-operation docs command so discovery does not spend an agent's context
|
|
995
|
+
* window on every response shape before it has chosen an operation.
|
|
996
|
+
*/
|
|
997
|
+
function helpJson(): Record<string, unknown> {
|
|
998
|
+
const byResource = new Map<string, CommandSummary[]>();
|
|
999
|
+
for (const c of commandSummaries()) {
|
|
1000
|
+
const list = byResource.get(c.resource) ?? [];
|
|
1001
|
+
list.push(c);
|
|
1002
|
+
byResource.set(c.resource, list);
|
|
1003
|
+
}
|
|
1004
|
+
return {
|
|
1005
|
+
schema_version: "2",
|
|
1006
|
+
name: BIN,
|
|
1007
|
+
version: VERSION,
|
|
1008
|
+
api: API_TITLE,
|
|
1009
|
+
api_version: API_VERSION,
|
|
1010
|
+
spec_format: SPEC_FORMAT,
|
|
1011
|
+
usage: BIN + " <resource> <command> [args] [--flags]",
|
|
1012
|
+
resources: [...byResource.entries()].map(([resource, commands]) => ({
|
|
1013
|
+
resource,
|
|
1014
|
+
commands: commands.map((c) => {
|
|
1015
|
+
const op = OPS.find((o) => o.command[0] === resource && o.command[1] === c.command)!;
|
|
1016
|
+
return {
|
|
1017
|
+
command: c.command,
|
|
1018
|
+
method: c.method,
|
|
1019
|
+
path: c.path,
|
|
1020
|
+
...(c.summary ? { summary: c.summary } : {}),
|
|
1021
|
+
paginated: c.paginated,
|
|
1022
|
+
safety: op.safety,
|
|
1023
|
+
destructive: c.destructive,
|
|
1024
|
+
auth: c.auth,
|
|
1025
|
+
positional: op.params.filter((p) => p.kind === "path").map((p) => p.name),
|
|
1026
|
+
flags: c.flags,
|
|
1027
|
+
details_command: BIN + " docs " + resource + " " + c.command + " --json",
|
|
1028
|
+
};
|
|
1029
|
+
}),
|
|
1030
|
+
})),
|
|
1031
|
+
discovery: {
|
|
1032
|
+
search: BIN + " docs search <term> --json",
|
|
1033
|
+
operation: BIN + " docs <resource> <command> --json",
|
|
1034
|
+
note: "Choose an operation from this index, then read only that operation's complete schemas and example arguments.",
|
|
1035
|
+
},
|
|
1036
|
+
builtins: BUILTIN_COMMANDS,
|
|
1037
|
+
global_flags: ["--help", "--version", "--debug", "--non-interactive", "--mode agent|human", "--yes", "--force", "--color on|off|auto", "--base-url <url>", "--data '<json>' | @<file> | -", "--fields <a,b.c>", "--all", "--validate", "--out <dir>", ...AUTH_SCALARS.map((a) => "--" + a.flag + " <value>")],
|
|
1038
|
+
auth_env_vars: agentContext().authEnvVars,
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
async function cmdAgentGuide(parsed: Parsed): Promise<void> {
|
|
1043
|
+
if (parsed.help) {
|
|
1044
|
+
process.stdout.write(BIN + " agent-guide [--format json] — how an agent should drive this CLI: conventions, first command, docs, MCP, skills, next steps. JSON.\n");
|
|
1045
|
+
await flushExit(0);
|
|
1046
|
+
}
|
|
1047
|
+
out(agentGuide(agentContext(), commandSummaries()));
|
|
1048
|
+
await flushExit(0);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** Which credential the CLI would send, without sending it. --live calls the identity endpoint too. */
|
|
1052
|
+
async function cmdAuth(parsed: Parsed): Promise<void> {
|
|
1053
|
+
const sub = parsed.positionals[1];
|
|
1054
|
+
if (parsed.help || sub !== "check") {
|
|
1055
|
+
process.stdout.write([
|
|
1056
|
+
BIN + " auth check [--live] — report the credential the CLI would use, as JSON: {status: ok|action_required, authenticated, source, ...}",
|
|
1057
|
+
" --live also call the API's identity endpoint" + (WHOAMI ? "" : " (none in this API; --live is a no-op)"),
|
|
1058
|
+
"",
|
|
1059
|
+
"Precedence: flags > env vars > stored credentials (" + credsPath() + ").",
|
|
1060
|
+
].join("\n") + "\n");
|
|
1061
|
+
await flushExit(parsed.help ? 0 : 2);
|
|
1062
|
+
}
|
|
1063
|
+
const stored = readCreds();
|
|
1064
|
+
let source = "none";
|
|
1065
|
+
if (AUTH_SCALARS.some((a) => typeof parsed.flags.get(a.flag) === "string")) source = "flags";
|
|
1066
|
+
else {
|
|
1067
|
+
const envScalar = AUTH_SCALARS.find((a) => process.env[a.env] !== undefined);
|
|
1068
|
+
if (envScalar) source = "env:" + envScalar.env;
|
|
1069
|
+
else if (stored && (stored.scalars || stored.basic || stored.oauth)) source = "login";
|
|
1070
|
+
}
|
|
1071
|
+
const authenticated = source !== "none";
|
|
1072
|
+
const report: Record<string, unknown> = {
|
|
1073
|
+
status: authenticated ? "ok" : "action_required",
|
|
1074
|
+
authenticated,
|
|
1075
|
+
source,
|
|
1076
|
+
credentials_path: existsSync(credsPath()) ? credsPath() : null,
|
|
1077
|
+
auth_env_vars: agentContext().authEnvVars,
|
|
1078
|
+
base_url: resolveBaseUrl(parsed.flags) ?? null,
|
|
1079
|
+
next_steps: authenticated ? [] : [
|
|
1080
|
+
...AUTH_SCALARS.map((a) => "Set " + a.env + " in the environment, or run '" + BIN + " login --" + a.flag + " <value>'."),
|
|
1081
|
+
"Then run '" + BIN + " auth check --live'.",
|
|
1082
|
+
],
|
|
1083
|
+
};
|
|
1084
|
+
if (authenticated && parsed.flags.get("live") === true && WHOAMI) {
|
|
1085
|
+
const op = OPS.find((o) => o.resource === WHOAMI.resource && o.method === WHOAMI.method);
|
|
1086
|
+
if (op) {
|
|
1087
|
+
const client = await makeClient(parsed.flags);
|
|
1088
|
+
const target = (client as unknown as Record<string, Record<string, () => Promise<{ ok: boolean; data?: unknown; error?: unknown }>>>)[op.resource]!;
|
|
1089
|
+
const result = await target[op.method]!();
|
|
1090
|
+
if (result.ok) report.identity = result.data;
|
|
1091
|
+
else {
|
|
1092
|
+
const why = classifyApiError(result.error, { bin: BIN, hadCredential: true, docsUrl: DOCS_URL_DEFAULT });
|
|
1093
|
+
report.status = "action_required";
|
|
1094
|
+
report.live = { ok: false, code: why.code, message: why.message };
|
|
1095
|
+
report.next_steps = why.nextSteps ?? [];
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
out(report);
|
|
1100
|
+
await flushExit(report.status === "ok" ? 0 : 1);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
async function cmdDoctor(parsed: Parsed): Promise<void> {
|
|
1104
|
+
if (parsed.help) {
|
|
1105
|
+
process.stdout.write(BIN + " doctor — check the install: binary, node, credentials, base URL, docs, MCP client config. JSON with next_steps.\n");
|
|
1106
|
+
await flushExit(0);
|
|
1107
|
+
}
|
|
1108
|
+
const checks: DoctorCheck[] = [];
|
|
1109
|
+
const nodeMajor = Number(process.versions.node.split(".")[0]);
|
|
1110
|
+
checks.push({ name: "node", ok: nodeMajor >= 18, detail: process.version, ...(nodeMajor >= 18 ? {} : { fix: "Install Node 18 or newer." }) });
|
|
1111
|
+
checks.push({ name: "cli", ok: true, detail: BIN + " " + VERSION + " (" + PKG_NAME + ")" });
|
|
1112
|
+
const stored = readCreds();
|
|
1113
|
+
const envScalar = AUTH_SCALARS.find((a) => process.env[a.env] !== undefined);
|
|
1114
|
+
const hasCred = Boolean(envScalar) || Boolean(stored && (stored.scalars || stored.basic || stored.oauth));
|
|
1115
|
+
checks.push({ name: "credentials", ok: hasCred, detail: envScalar ? "env:" + envScalar.env : hasCred ? credsPath() : "none", ...(hasCred ? {} : { fix: AUTH_SCALARS[0] ? "Set " + AUTH_SCALARS[0].env + " or run '" + BIN + " login'." : "Run '" + BIN + " login'." }) });
|
|
1116
|
+
const baseUrl = resolveBaseUrl(parsed.flags);
|
|
1117
|
+
if (baseUrl) {
|
|
1118
|
+
try {
|
|
1119
|
+
const response = await fetch(baseUrl, { method: "GET", signal: AbortSignal.timeout(8_000) });
|
|
1120
|
+
checks.push({ name: "base_url", ok: true, detail: baseUrl + " → HTTP " + response.status });
|
|
1121
|
+
} catch (e) {
|
|
1122
|
+
checks.push({ name: "base_url", ok: false, detail: baseUrl + ": " + (e as Error).message, fix: "Check the network, or set --base-url / " + ENV_PREFIX + "_BASE_URL." });
|
|
1123
|
+
}
|
|
1124
|
+
} else {
|
|
1125
|
+
checks.push({ name: "base_url", ok: false, detail: "none", fix: "Pass --base-url or run '" + BIN + " config set base-url <url>'." });
|
|
1126
|
+
}
|
|
1127
|
+
if (hasCred && WHOAMI) {
|
|
1128
|
+
const op = OPS.find((o) => o.resource === WHOAMI.resource && o.method === WHOAMI.method);
|
|
1129
|
+
if (op) {
|
|
1130
|
+
try {
|
|
1131
|
+
const client = await makeClient(parsed.flags);
|
|
1132
|
+
const target = (client as unknown as Record<string, Record<string, () => Promise<{ ok: boolean; error?: unknown }>>>)[op.resource]!;
|
|
1133
|
+
const result = await target[op.method]!();
|
|
1134
|
+
checks.push(result.ok ? { name: "identity", ok: true, detail: op.command.join(" ") + " ok" } : { name: "identity", ok: false, detail: classifyApiError(result.error, { bin: BIN, hadCredential: true, docsUrl: DOCS_URL_DEFAULT }).message, fix: "The credential was rejected; run '" + BIN + " login' with a current one." });
|
|
1135
|
+
} catch (e) {
|
|
1136
|
+
checks.push({ name: "identity", ok: false, detail: (e as Error).message });
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
const docs = docsSiteUrl();
|
|
1141
|
+
if (docs) {
|
|
1142
|
+
const index = await fetchDocs("llms.txt");
|
|
1143
|
+
checks.push({ name: "docs", ok: index !== null, detail: docs + "/llms.txt" + (index !== null ? " reachable" : " unreachable"), ...(index !== null ? {} : { fix: "The docs site is not answering; '" + BIN + " docs' falls back to the built-in reference." }) });
|
|
1144
|
+
}
|
|
1145
|
+
if (HAS_MCP || MCP_URL) {
|
|
1146
|
+
const cwd = process.cwd();
|
|
1147
|
+
const configured = MCP_CLIENTS.filter((c) => c.detect(cwd) && mcpConfigured(c, cwd, BIN)).map((c) => c.id);
|
|
1148
|
+
const detected = MCP_CLIENTS.filter((c) => c.detect(cwd) && !c.incompatible).map((c) => c.id);
|
|
1149
|
+
checks.push({ name: "mcp_clients", ok: detected.length === 0 || configured.length > 0, detail: "detected: " + (detected.join(", ") || "none") + "; configured: " + (configured.join(", ") || "none"), ...(detected.length > 0 && configured.length === 0 ? { fix: "Run '" + BIN + " mcp install --all'." } : {}) });
|
|
1150
|
+
}
|
|
1151
|
+
const claims = pendingClaims(process.cwd(), BIN);
|
|
1152
|
+
if (claims.length > 0) checks.push({ name: "unclaimed", ok: false, detail: claims.length + " unclaimed generation(s) in ." + BIN + "/claims.json", fix: "Claim them: " + claims.map((c) => c.url).join(", ") });
|
|
1153
|
+
const summary = summarizeDoctor(checks);
|
|
1154
|
+
out({ ...summary, harness: detectHarness(), agent_mode: isAgentMode(parsed) });
|
|
1155
|
+
await flushExit(summary.status === "ok" ? 0 : 1);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* init: the one command that connects a machine (or a repo) to this API.
|
|
1160
|
+
* Stores a credential when given one, installs the skills repository, writes
|
|
1161
|
+
* MCP config for every agent client found, and upserts a marked block into
|
|
1162
|
+
* AGENTS.md (CLAUDE.md under Claude Code). Idempotent; --all takes the
|
|
1163
|
+
* defaults without asking; every part reports and none of them fails init.
|
|
1164
|
+
*/
|
|
1165
|
+
async function cmdInit(parsed: Parsed): Promise<void> {
|
|
1166
|
+
if (parsed.help) {
|
|
1167
|
+
process.stdout.write([
|
|
1168
|
+
BIN + " init [--all] [-k <credential>] [--yes] [--no-skills] [--no-mcp] [--no-agents-md]",
|
|
1169
|
+
"",
|
|
1170
|
+
" -k, --" + (AUTH_SCALARS[0]?.flag ?? "token") + " <value> store the credential (or set " + (AUTH_SCALARS[0]?.env ?? ENV_PREFIX + "_TOKEN") + " in the environment)" + (CLI_AUTH_URL ? "; with neither, approve one in the browser" : ""),
|
|
1171
|
+
" --all do everything without prompting (implied under an agent)",
|
|
1172
|
+
" --no-skills | --no-mcp | --no-agents-md skip a part",
|
|
1173
|
+
"",
|
|
1174
|
+
"Writes: " + credsPath() + " (credential), the skills directory of each agent on this machine" + (SKILLS_REPO ? " (npx skills add " + SKILLS_REPO + ")" : " (no skills repository configured)") + ", MCP config for detected clients, and a '" + BIN + "' block in ./AGENTS.md.",
|
|
1175
|
+
].join("\n") + "\n");
|
|
1176
|
+
await flushExit(0);
|
|
1177
|
+
}
|
|
1178
|
+
const report: Record<string, unknown> = { ok: true, bin: BIN };
|
|
1179
|
+
const nextSteps: string[] = [];
|
|
1180
|
+
const cwd = process.cwd();
|
|
1181
|
+
const harness = detectHarness();
|
|
1182
|
+
|
|
1183
|
+
// 1. credential
|
|
1184
|
+
const stored: StoredCreds = readCreds() ?? {};
|
|
1185
|
+
const first = AUTH_SCALARS[0];
|
|
1186
|
+
const given = (typeof parsed.flags.get("k") === "string" ? parsed.flags.get("k") as string : undefined)
|
|
1187
|
+
?? (first && typeof parsed.flags.get(first.flag) === "string" ? parsed.flags.get(first.flag) as string : undefined);
|
|
1188
|
+
if (given && first) {
|
|
1189
|
+
stored.scalars = { ...stored.scalars, [first.option]: given };
|
|
1190
|
+
writeCreds(stored);
|
|
1191
|
+
report.credential = { status: "stored", path: credsPath() };
|
|
1192
|
+
} else if (first && process.env[first.env]) {
|
|
1193
|
+
report.credential = { status: "env", variable: first.env };
|
|
1194
|
+
} else if (stored.scalars || stored.basic || stored.oauth) {
|
|
1195
|
+
report.credential = { status: "stored", path: credsPath() };
|
|
1196
|
+
} else if (first && CLI_AUTH_URL && !explicitNonInteractive(parsed)) {
|
|
1197
|
+
// Nothing anywhere: approve a credential in the browser, as `login`
|
|
1198
|
+
// would, then carry on. Under an agent the URL is printed for the person
|
|
1199
|
+
// and polled; only the explicit non-interactive switch skips this.
|
|
1200
|
+
const minted = await browserApprove(isAgentMode(parsed) || parsed.flags.get("no-browser") === true, parsed.flags);
|
|
1201
|
+
storeMinted(stored, minted);
|
|
1202
|
+
report.credential = { status: "minted", method: "browser", key_name: minted.key_name, ...(minted.org_id ? { org_id: minted.org_id } : {}), path: credsPath() };
|
|
1203
|
+
} else {
|
|
1204
|
+
report.credential = { status: "none" };
|
|
1205
|
+
if (first) nextSteps.push("Set " + first.env + " in the environment, or run '" + BIN + " init -k <credential>' or '" + BIN + " login'.");
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// 2. skills
|
|
1209
|
+
if (parsed.flags.get("no-skills") === true) report.skills = { status: "skipped" };
|
|
1210
|
+
else if (SKILLS_REPO) report.skills = installSkills(SKILLS_REPO, { agent: harness === "claude-code" ? "claude-code" : null });
|
|
1211
|
+
else report.skills = { status: "skipped", repo: null, detail: "No skills repository is configured for this CLI." };
|
|
1212
|
+
|
|
1213
|
+
// 3. MCP config
|
|
1214
|
+
if (parsed.flags.get("no-mcp") === true || !(HAS_MCP || MCP_URL)) {
|
|
1215
|
+
report.mcp = { status: HAS_MCP || MCP_URL ? "skipped" : "unavailable" };
|
|
1216
|
+
} else {
|
|
1217
|
+
const { entry, warnings } = mcpEntryFor(undefined);
|
|
1218
|
+
const clients = MCP_CLIENTS.filter((c) => !c.incompatible && c.detect(cwd) && !(c.id === "claude-desktop" && entry.url));
|
|
1219
|
+
const results = clients.map((c) => writeMcpConfig(c, cwd, BIN, entry));
|
|
1220
|
+
report.mcp = { status: results.length > 0 ? "written" : "no-clients", entry, clients: results, ...(warnings.length > 0 ? { warnings } : {}) };
|
|
1221
|
+
if (results.length === 0) nextSteps.push("No MCP client was found on this machine; run '" + BIN + " mcp' to print the entry.");
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// 4. AGENTS.md
|
|
1225
|
+
if (parsed.flags.get("no-agents-md") === true) {
|
|
1226
|
+
report.agents_md = { status: "skipped" };
|
|
1227
|
+
} else {
|
|
1228
|
+
const file = agentInstructionsFile(cwd, harness);
|
|
1229
|
+
const result = upsertAgentBlock(file, BIN + " agent-contract", agentBlock(agentContext(), commandSummaries()));
|
|
1230
|
+
report.agents_md = { status: result.updated ? "updated" : "written", file: result.file };
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
nextSteps.push("Run '" + BIN + " auth check --live'" + (WHOAMI ? "" : " (or any read command)") + " to confirm the connection.");
|
|
1234
|
+
nextSteps.push("Run '" + BIN + " agent-guide' for the conventions, or '" + BIN + " --help' for commands.");
|
|
1235
|
+
out({ ...report, next_steps: nextSteps });
|
|
1236
|
+
await flushExit(0);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// ---------------------------------------------------------------------------
|
|
1240
|
+
// upgrade — explicit self-update via the npm registry
|
|
1241
|
+
// ---------------------------------------------------------------------------
|
|
1242
|
+
|
|
1243
|
+
function semverLess(a: string, b: string): boolean {
|
|
1244
|
+
const pa = a.split(/[.+-]/).map(Number);
|
|
1245
|
+
const pb = b.split(/[.+-]/).map(Number);
|
|
1246
|
+
for (let i = 0; i < 3; i++) {
|
|
1247
|
+
const x = pa[i] ?? 0;
|
|
1248
|
+
const y = pb[i] ?? 0;
|
|
1249
|
+
if (x !== y) return x < y;
|
|
1250
|
+
}
|
|
1251
|
+
return false;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function registryBase(): string {
|
|
1255
|
+
return (process.env.npm_config_registry ?? "https://registry.npmjs.org").replace(/\/+$/, "");
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
async function latestVersion(timeoutMs: number): Promise<string | null> {
|
|
1259
|
+
try {
|
|
1260
|
+
const response = await fetch(registryBase() + "/" + PKG_NAME, {
|
|
1261
|
+
headers: { Accept: "application/vnd.npm.install-v1+json" },
|
|
1262
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
1263
|
+
});
|
|
1264
|
+
if (!response.ok) return null;
|
|
1265
|
+
const doc = await response.json() as { "dist-tags"?: { latest?: string } };
|
|
1266
|
+
return doc["dist-tags"]?.latest ?? null;
|
|
1267
|
+
} catch {
|
|
1268
|
+
return null;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
async function cmdUpgrade(parsed: Parsed): Promise<void> {
|
|
1273
|
+
if (parsed.help) {
|
|
1274
|
+
const lines = [
|
|
1275
|
+
BIN + " upgrade — update this CLI from the npm registry",
|
|
1276
|
+
"",
|
|
1277
|
+
" " + BIN + " upgrade install the latest published version (npm install -g)",
|
|
1278
|
+
" " + BIN + " upgrade --check report current vs latest without installing",
|
|
1279
|
+
"",
|
|
1280
|
+
"Registry: " + registryBase() + " (honors npm_config_registry).",
|
|
1281
|
+
];
|
|
1282
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
1283
|
+
await flushExit(0);
|
|
1284
|
+
}
|
|
1285
|
+
const latest = await latestVersion(5000);
|
|
1286
|
+
if (latest === null) {
|
|
1287
|
+
fail(1, PKG_NAME + " is not on the registry (" + registryBase() + "). This package updates by regeneration from its API spec; get the latest from the API provider.");
|
|
1288
|
+
}
|
|
1289
|
+
const outdated = semverLess(VERSION, latest!);
|
|
1290
|
+
if (parsed.flags.get("check") === true) {
|
|
1291
|
+
out({ current: VERSION, latest, outdated, registry: registryBase() });
|
|
1292
|
+
await flushExit(0);
|
|
1293
|
+
}
|
|
1294
|
+
if (!outdated) {
|
|
1295
|
+
out({ ok: true, current: VERSION, latest, message: "Already up to date." });
|
|
1296
|
+
await flushExit(0);
|
|
1297
|
+
}
|
|
1298
|
+
// How this binary was installed decides how it upgrades; "npm install -g"
|
|
1299
|
+
// over a pnpm, bun, or npx install would leave two copies or none.
|
|
1300
|
+
let self = process.argv[1] ?? "";
|
|
1301
|
+
try { self = realpathSync(self); } catch { /* keep the literal path */ }
|
|
1302
|
+
const manager = /[\\/]_npx[\\/]/.test(self) ? "npx" : /[\\/]pnpm[\\/]/.test(self) ? "pnpm" : /[\\/]\.bun[\\/]/.test(self) ? "bun" : /[\\/]yarn[\\/]/.test(self) ? "yarn" : "npm";
|
|
1303
|
+
if (manager === "npx") fail(1, "This run came through npx, which fetches the latest version each time; there is nothing to upgrade in place.");
|
|
1304
|
+
if (manager !== "npm") {
|
|
1305
|
+
const command = manager === "pnpm" ? "pnpm add -g " + PKG_NAME + "@" + latest : manager === "bun" ? "bun add -g " + PKG_NAME + "@" + latest : "yarn global add " + PKG_NAME + "@" + latest;
|
|
1306
|
+
failWith({ status: "action_required", code: "COMMAND_FAILED", message: PKG_NAME + " was installed with " + manager + ", so npm can't upgrade it in place.", nextSteps: ["Run: " + command] });
|
|
1307
|
+
}
|
|
1308
|
+
process.stderr.write("Upgrading " + PKG_NAME + " " + VERSION + " -> " + latest + "\n");
|
|
1309
|
+
// Windows resolves npm to npm.cmd, which only a shell can start.
|
|
1310
|
+
const install = spawnSync("npm", ["install", "-g", PKG_NAME + "@" + latest], { stdio: ["ignore", "inherit", "inherit"], shell: process.platform === "win32" });
|
|
1311
|
+
if (install.status !== 0) {
|
|
1312
|
+
fail(1, "npm install failed (exit " + String(install.status) + "). Try: npm install -g " + PKG_NAME + "@latest");
|
|
1313
|
+
}
|
|
1314
|
+
out({ ok: true, upgraded: { from: VERSION, to: latest } });
|
|
1315
|
+
await flushExit(0);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// ---------------------------------------------------------------------------
|
|
1319
|
+
// completion — shell completion scripts built from the op table
|
|
1320
|
+
// ---------------------------------------------------------------------------
|
|
1321
|
+
|
|
1322
|
+
const TOP_WORDS = ["login", "logout", "whoami", "config", "mcp", "upgrade", "docs", "webhooks", "feedback", "completion", "help", "version", "init", "agent-guide", "auth", "doctor"];
|
|
1323
|
+
|
|
1324
|
+
/** Every flag an API command accepts, with the values a flag completes to (enums, on|off|auto, agent|human). */
|
|
1325
|
+
function completionFlagsFor(op: OpSpec): { flags: string[]; values: Record<string, string[]> } {
|
|
1326
|
+
const flags = [...op.params.filter((p) => p.kind !== "path").map((p) => "--" + p.flag), ...COMPLETION_GLOBAL_FLAGS];
|
|
1327
|
+
const values: Record<string, string[]> = { "--color": ["on", "off", "auto"], "--mode": ["agent", "human"] };
|
|
1328
|
+
for (const p of op.params) {
|
|
1329
|
+
const enumValues = p.type === "array" ? p.items?.enum : p.enum;
|
|
1330
|
+
if (enumValues && enumValues.length > 0) values["--" + p.flag] = enumValues;
|
|
1331
|
+
if (p.type === "boolean") values["--" + p.flag] = ["true", "false"];
|
|
1332
|
+
}
|
|
1333
|
+
return { flags, values };
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
const COMPLETION_GLOBAL_FLAGS = ["--help", "--version", "--non-interactive", "--color", "--base-url", "--data", "--fields", "--all", "--validate", "--debug", "--mode", "--yes", "--force", "--out", ...AUTH_SCALARS.map((a) => "--" + a.flag)];
|
|
1337
|
+
const BUILTIN_WORDS: Record<string, string[]> = {
|
|
1338
|
+
config: ["list", "get", "set", "unset", "path"],
|
|
1339
|
+
completion: ["bash", "zsh", "fish"],
|
|
1340
|
+
auth: ["check"],
|
|
1341
|
+
mcp: ["install"],
|
|
1342
|
+
webhooks: ["listen", "fake"],
|
|
1343
|
+
};
|
|
1344
|
+
|
|
1345
|
+
/** bash (and zsh via bashcompinit): resources, commands, flags, and the values a flag takes. */
|
|
1346
|
+
function completionScript(): string {
|
|
1347
|
+
const fn = "_" + BIN.replace(/-/g, "_") + "_complete";
|
|
1348
|
+
const resources = [...new Set(OPS.map((o) => o.command[0]))];
|
|
1349
|
+
const lines: string[] = [];
|
|
1350
|
+
lines.push(fn + "() {");
|
|
1351
|
+
lines.push(' local cur="${COMP_WORDS[COMP_CWORD]}"');
|
|
1352
|
+
lines.push(' local prev="${COMP_WORDS[COMP_CWORD-1]}"');
|
|
1353
|
+
lines.push(' local first="${COMP_WORDS[1]}"');
|
|
1354
|
+
lines.push(' local second="${COMP_WORDS[2]}"');
|
|
1355
|
+
lines.push(' if [ "$COMP_CWORD" -eq 1 ]; then');
|
|
1356
|
+
lines.push(' COMPREPLY=($(compgen -W "' + [...TOP_WORDS, ...resources].join(" ") + '" -- "$cur")); return');
|
|
1357
|
+
lines.push(" fi");
|
|
1358
|
+
lines.push(' if [ "$COMP_CWORD" -eq 2 ]; then');
|
|
1359
|
+
lines.push(' case "$first" in');
|
|
1360
|
+
for (const resource of resources) {
|
|
1361
|
+
const cmds = OPS.filter((o) => o.command[0] === resource).map((o) => o.command[1]).join(" ");
|
|
1362
|
+
lines.push(" " + resource + ') COMPREPLY=($(compgen -W "' + cmds + '" -- "$cur")); return ;;');
|
|
1363
|
+
}
|
|
1364
|
+
for (const [word, subs] of Object.entries(BUILTIN_WORDS)) lines.push(" " + word + ') COMPREPLY=($(compgen -W "' + subs.join(" ") + '" -- "$cur")); return ;;');
|
|
1365
|
+
lines.push(' docs) COMPREPLY=($(compgen -W "search read ' + resources.join(" ") + '" -- "$cur")); return ;;');
|
|
1366
|
+
lines.push(" esac");
|
|
1367
|
+
lines.push(" fi");
|
|
1368
|
+
lines.push(' if [ "$first" = "config" ] && [ "$COMP_CWORD" -eq 3 ]; then COMPREPLY=($(compgen -W "' + CONFIG_KEYS.join(" ") + '" -- "$cur")); return; fi');
|
|
1369
|
+
lines.push(' if [ "$first" = "mcp" ] && [ "$second" = "install" ]; then COMPREPLY=($(compgen -W "' + Object.keys(MCP_CLIENT_FLAGS).map((f) => "--" + f).join(" ") + ' --all --url" -- "$cur")); return; fi');
|
|
1370
|
+
lines.push(' case "$first $second" in');
|
|
1371
|
+
for (const op of OPS) {
|
|
1372
|
+
const { flags, values } = completionFlagsFor(op);
|
|
1373
|
+
lines.push(' "' + op.command[0] + " " + op.command[1] + '")');
|
|
1374
|
+
lines.push(' case "$prev" in');
|
|
1375
|
+
for (const [flag, options] of Object.entries(values)) lines.push(" " + flag + ') COMPREPLY=($(compgen -W "' + options.join(" ") + '" -- "$cur")); return ;;');
|
|
1376
|
+
lines.push(" esac");
|
|
1377
|
+
lines.push(' COMPREPLY=($(compgen -W "' + flags.join(" ") + '" -- "$cur")); return ;;');
|
|
1378
|
+
}
|
|
1379
|
+
lines.push(" esac");
|
|
1380
|
+
lines.push(' COMPREPLY=($(compgen -W "' + COMPLETION_GLOBAL_FLAGS.join(" ") + '" -- "$cur"))');
|
|
1381
|
+
lines.push("}");
|
|
1382
|
+
lines.push("complete -F " + fn + " " + BIN);
|
|
1383
|
+
return lines.join("\n");
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/** fish: the same table as "complete" rules. */
|
|
1387
|
+
function fishCompletionScript(): string {
|
|
1388
|
+
const resources = [...new Set(OPS.map((o) => o.command[0]))];
|
|
1389
|
+
const q = (text: string) => "'" + text.replace(/'/g, "\\'") + "'";
|
|
1390
|
+
const lines: string[] = ["complete -c " + BIN + " -f"];
|
|
1391
|
+
const top = [...TOP_WORDS, ...resources];
|
|
1392
|
+
lines.push("complete -c " + BIN + " -n '__fish_use_subcommand' -a " + q(top.join(" ")));
|
|
1393
|
+
for (const resource of resources) {
|
|
1394
|
+
const cmds = OPS.filter((o) => o.command[0] === resource);
|
|
1395
|
+
lines.push("complete -c " + BIN + " -n '__fish_seen_subcommand_from " + resource + "; and not __fish_seen_subcommand_from " + cmds.map((o) => o.command[1]).join(" ") + "' -a " + q(cmds.map((o) => o.command[1]).join(" ")));
|
|
1396
|
+
for (const op of cmds) {
|
|
1397
|
+
const { flags, values } = completionFlagsFor(op);
|
|
1398
|
+
const seen = "__fish_seen_subcommand_from " + resource + "; and __fish_seen_subcommand_from " + op.command[1];
|
|
1399
|
+
for (const flag of flags) {
|
|
1400
|
+
const options = values[flag];
|
|
1401
|
+
lines.push("complete -c " + BIN + " -n " + q(seen) + " -l " + flag.slice(2) + (options ? " -x -a " + q(options.join(" ")) : " -r"));
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
for (const [word, subs] of Object.entries(BUILTIN_WORDS)) {
|
|
1406
|
+
lines.push("complete -c " + BIN + " -n '__fish_seen_subcommand_from " + word + "; and not __fish_seen_subcommand_from " + subs.join(" ") + "' -a " + q(subs.join(" ")));
|
|
1407
|
+
}
|
|
1408
|
+
lines.push("complete -c " + BIN + " -n '__fish_seen_subcommand_from config; and __fish_seen_subcommand_from get set unset' -a " + q(CONFIG_KEYS.join(" ")));
|
|
1409
|
+
lines.push("complete -c " + BIN + " -n '__fish_seen_subcommand_from docs' -a " + q(["search", "read", ...resources].join(" ")));
|
|
1410
|
+
return lines.join("\n");
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
async function cmdCompletion(parsed: Parsed): Promise<void> {
|
|
1414
|
+
const shell = parsed.positionals[1];
|
|
1415
|
+
if (parsed.help || shell === undefined) {
|
|
1416
|
+
const lines = [
|
|
1417
|
+
BIN + " completion <bash|zsh|fish> — print a shell completion script: resources, commands, flags, and enum values",
|
|
1418
|
+
"",
|
|
1419
|
+
' bash: add to ~/.bashrc: eval "$(' + BIN + ' completion bash)"',
|
|
1420
|
+
' zsh: add to ~/.zshrc: eval "$(' + BIN + ' completion zsh)"',
|
|
1421
|
+
" fish: " + BIN + " completion fish > ~/.config/fish/completions/" + BIN + ".fish",
|
|
1422
|
+
];
|
|
1423
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
1424
|
+
await flushExit(parsed.help ? 0 : 2);
|
|
1425
|
+
}
|
|
1426
|
+
if (shell !== "bash" && shell !== "zsh" && shell !== "fish") fail(2, "completion expects bash, zsh, or fish");
|
|
1427
|
+
const script = shell === "fish" ? fishCompletionScript()
|
|
1428
|
+
: shell === "zsh" ? "autoload -U +X bashcompinit && bashcompinit\n" + completionScript()
|
|
1429
|
+
: completionScript();
|
|
1430
|
+
process.stdout.write(script + "\n");
|
|
1431
|
+
await flushExit(0);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// ---------------------------------------------------------------------------
|
|
1435
|
+
// docs — spec reference + the docs site's prose (llms.txt convention)
|
|
1436
|
+
// ---------------------------------------------------------------------------
|
|
1437
|
+
|
|
1438
|
+
function docsSiteUrl(): string | null {
|
|
1439
|
+
return readConfig().docsUrl ?? DOCS_URL_DEFAULT;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/** Fetch llms.txt / llms-full.txt / a prose page from the docs site,
|
|
1443
|
+
* with a 1h cache in the config dir. Explicit command = explicit fetch;
|
|
1444
|
+
* nothing here runs unless the user asked for docs. */
|
|
1445
|
+
async function fetchDocs(pathOrFile: string): Promise<string | null> {
|
|
1446
|
+
const base = docsSiteUrl();
|
|
1447
|
+
const url = /^https?:\/\//.test(pathOrFile)
|
|
1448
|
+
? pathOrFile
|
|
1449
|
+
: base === null
|
|
1450
|
+
? null
|
|
1451
|
+
: base.replace(/\/+$/, "") + "/" + pathOrFile.replace(/^\/+/, "");
|
|
1452
|
+
if (url === null) return null;
|
|
1453
|
+
const cacheFile = join(configDir(), "docs-cache", url.replace(/[^a-zA-Z0-9.]+/g, "_").slice(-120));
|
|
1454
|
+
try {
|
|
1455
|
+
const stat = statSync(cacheFile);
|
|
1456
|
+
if (Date.now() - stat.mtimeMs < 60 * 60 * 1000) return readFileSync(cacheFile, "utf8");
|
|
1457
|
+
} catch { /* not cached */ }
|
|
1458
|
+
try {
|
|
1459
|
+
const response = await fetch(url, {
|
|
1460
|
+
headers: { Accept: "text/markdown, text/plain, */*" },
|
|
1461
|
+
signal: AbortSignal.timeout(10_000),
|
|
1462
|
+
});
|
|
1463
|
+
if (!response.ok) return null;
|
|
1464
|
+
const text = await response.text();
|
|
1465
|
+
mkdirSync(join(configDir(), "docs-cache"), { recursive: true, mode: 0o700 });
|
|
1466
|
+
writeFileSync(cacheFile, text);
|
|
1467
|
+
return text;
|
|
1468
|
+
} catch {
|
|
1469
|
+
try {
|
|
1470
|
+
return readFileSync(cacheFile, "utf8");
|
|
1471
|
+
} catch {
|
|
1472
|
+
return null;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function searchTerms(query: string): string[] {
|
|
1478
|
+
return [...new Set(query.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter((term) => term.length >= 2))];
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/** Token search across names, prose, paths, and arguments. A phrase such as
|
|
1482
|
+
* "create project" should find the projects create command, even though that exact
|
|
1483
|
+
* substring never occurs in the generated command. */
|
|
1484
|
+
function referenceSearchScore(op: OpSpec, query: string): number {
|
|
1485
|
+
const terms = searchTerms(query);
|
|
1486
|
+
if (terms.length === 0) return 0;
|
|
1487
|
+
const names = [...op.command, op.tool].join("_").toLowerCase().split(/[^a-z0-9]+/);
|
|
1488
|
+
const summary = (op.summary ?? "").toLowerCase();
|
|
1489
|
+
const description = (op.description ?? "").toLowerCase();
|
|
1490
|
+
const path = op.path.toLowerCase();
|
|
1491
|
+
const params = op.params.flatMap((p) => [p.name.toLowerCase(), p.flag.toLowerCase()]);
|
|
1492
|
+
let score = 0;
|
|
1493
|
+
for (const term of terms) {
|
|
1494
|
+
if (names.includes(term)) score += 10;
|
|
1495
|
+
if (summary.split(/[^a-z0-9]+/).includes(term)) score += 5;
|
|
1496
|
+
else if (summary.includes(term)) score += 3;
|
|
1497
|
+
if (path.includes(term)) score += 3;
|
|
1498
|
+
if (params.includes(term)) score += 3;
|
|
1499
|
+
else if (params.some((param) => param.includes(term))) score += 1;
|
|
1500
|
+
if (description.includes(term)) score += 1;
|
|
1501
|
+
}
|
|
1502
|
+
return score;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function referenceFor(op: OpSpec, includeSchemas = false): string {
|
|
1506
|
+
const lines: string[] = [];
|
|
1507
|
+
lines.push(paintOut("bold", usageLine(op)));
|
|
1508
|
+
if (op.summary) lines.push(op.summary);
|
|
1509
|
+
lines.push(wireOf(op) + (op.paginated ? " (paginated: --all streams every item)" : ""));
|
|
1510
|
+
if (op.description) lines.push("", op.description.trim());
|
|
1511
|
+
lines.push("", paintOut("bold", "Contract:"));
|
|
1512
|
+
lines.push(" Safety: " + op.safety + (op.safety === "destructive" ? " (requires --force)" : ""));
|
|
1513
|
+
lines.push(" Auth: " + op.auth);
|
|
1514
|
+
const groups: [string, ParamSpec[]][] = [
|
|
1515
|
+
["Path arguments", op.params.filter((p) => p.kind === "path")],
|
|
1516
|
+
["Flags", op.params.filter((p) => p.kind !== "path")],
|
|
1517
|
+
];
|
|
1518
|
+
for (const [label, params] of groups) {
|
|
1519
|
+
if (params.length === 0) continue;
|
|
1520
|
+
lines.push("", paintOut("bold", label + ":"));
|
|
1521
|
+
for (const p of params) {
|
|
1522
|
+
const name = p.kind === "path" ? "<" + p.name + ">" : "--" + p.flag;
|
|
1523
|
+
lines.push(" " + padPaint("cyan", name, 30) + typeLabel(p) + (p.required ? " " + paintOut("yellow", "(required)") : ""));
|
|
1524
|
+
const values = p.type === "array" ? p.items?.enum : p.enum;
|
|
1525
|
+
if (values && values.join("|").length > 24) lines.push(" one of: " + values.join(", "));
|
|
1526
|
+
if (p.description) {
|
|
1527
|
+
for (const descLine of p.description.trim().split("\n")) lines.push(" " + descLine);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
const extras = commandExtras(op);
|
|
1532
|
+
if (extras.length > 0) {
|
|
1533
|
+
lines.push("", paintOut("bold", "CLI flags:"));
|
|
1534
|
+
for (const [name, text] of extras) lines.push(" " + padPaint("cyan", name, 30) + text);
|
|
1535
|
+
}
|
|
1536
|
+
lines.push("", paintOut("bold", "Example:"), " " + exampleLine(op));
|
|
1537
|
+
if (includeSchemas) {
|
|
1538
|
+
lines.push("", paintOut("bold", "Input schema:"), JSON.stringify(op.inputSchema, null, 2));
|
|
1539
|
+
lines.push("", paintOut("bold", "Wire arguments:"), JSON.stringify(op.exampleArguments, null, 2));
|
|
1540
|
+
if (op.outputSchema) lines.push("", paintOut("bold", "Output schema:"), JSON.stringify(op.outputSchema, null, 2));
|
|
1541
|
+
} else {
|
|
1542
|
+
lines.push("", "Add --schema for the complete input/output schemas, or --json for the machine contract.");
|
|
1543
|
+
}
|
|
1544
|
+
return lines.join("\n");
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
/** Opens a browser for a person; under an agent it prints the URL instead of opening anything. */
|
|
1548
|
+
function openInBrowser(url: string, parsed?: Parsed): { opened: boolean; url: string } {
|
|
1549
|
+
if (parsed && nonInteractive(parsed)) {
|
|
1550
|
+
out({ opened: false, url, note: "Agent mode: nothing was opened. Give this URL to the user." });
|
|
1551
|
+
return { opened: false, url };
|
|
1552
|
+
}
|
|
1553
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
1554
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
1555
|
+
spawnSync(opener, args, { stdio: "ignore" });
|
|
1556
|
+
return { opened: true, url };
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
async function cmdDocs(parsed: Parsed): Promise<void> {
|
|
1560
|
+
if (parsed.help) {
|
|
1561
|
+
const lines = [
|
|
1562
|
+
BIN + " docs — API reference from the spec, plus the docs site's guides",
|
|
1563
|
+
"",
|
|
1564
|
+
" " + BIN + " docs overview",
|
|
1565
|
+
" " + BIN + " docs <resource> <command> operation contract and example",
|
|
1566
|
+
" --schema include input/output JSON Schema",
|
|
1567
|
+
" --json print the machine contract as JSON",
|
|
1568
|
+
" " + BIN + " docs search <term> search reference and guides",
|
|
1569
|
+
" --json / --format json print structured matches and availability",
|
|
1570
|
+
" " + BIN + " docs read <page> print a docs-site page in the terminal",
|
|
1571
|
+
" " + BIN + " docs --web open the docs site in a browser",
|
|
1572
|
+
"",
|
|
1573
|
+
"Guides come from the docs site's llms.txt (set with '" + BIN + " config set docs-url <url>').",
|
|
1574
|
+
];
|
|
1575
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
1576
|
+
await flushExit(0);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
if (parsed.flags.get("web") === true) {
|
|
1580
|
+
const base = docsSiteUrl();
|
|
1581
|
+
if (base === null) fail(2, "No docs site configured. Run '" + BIN + " config set docs-url <url>'.");
|
|
1582
|
+
const opened = openInBrowser(base!, parsed);
|
|
1583
|
+
if (opened.opened) out({ ok: true, opened: base });
|
|
1584
|
+
await flushExit(0);
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
const sub = parsed.positionals[1];
|
|
1588
|
+
|
|
1589
|
+
if (sub === "search") {
|
|
1590
|
+
const term = parsed.positionals.slice(2).join(" ");
|
|
1591
|
+
if (!term) fail(2, "docs search expects a term");
|
|
1592
|
+
const jsonOutput = parsed.flags.get("json") === true || parsed.flags.get("format") === "json";
|
|
1593
|
+
const refMatches = OPS.map((op) => ({ op, score: referenceSearchScore(op, term) }))
|
|
1594
|
+
.filter((match) => match.score > 0)
|
|
1595
|
+
.sort((a, b) => b.score - a.score || a.op.command.join(" ").localeCompare(b.op.command.join(" ")))
|
|
1596
|
+
.map((match) => match.op);
|
|
1597
|
+
const prose = await fetchDocs("llms-full.txt");
|
|
1598
|
+
const proseMatches: { heading: string; excerpt: string; score: number }[] = [];
|
|
1599
|
+
if (prose !== null) {
|
|
1600
|
+
let heading = "";
|
|
1601
|
+
const terms = searchTerms(term);
|
|
1602
|
+
for (const line of prose.split("\n")) {
|
|
1603
|
+
if (/^#{1,3} /.test(line)) heading = line.replace(/^#+ /, "").trim();
|
|
1604
|
+
else {
|
|
1605
|
+
const lowerHeading = heading.toLowerCase();
|
|
1606
|
+
const lowerLine = line.toLowerCase();
|
|
1607
|
+
const matched = terms.filter((word) => lowerHeading.includes(word) || lowerLine.includes(word));
|
|
1608
|
+
if (matched.length > 0) {
|
|
1609
|
+
const allTerms = matched.length === terms.length;
|
|
1610
|
+
proseMatches.push({
|
|
1611
|
+
heading,
|
|
1612
|
+
excerpt: line.trim().slice(0, 160),
|
|
1613
|
+
score: matched.length * 10 + (allTerms ? 50 : 0) + (lowerHeading.includes(term.toLowerCase()) || lowerLine.includes(term.toLowerCase()) ? 25 : 0),
|
|
1614
|
+
});
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
proseMatches.sort((a, b) => b.score - a.score || a.heading.localeCompare(b.heading) || a.excerpt.localeCompare(b.excerpt));
|
|
1619
|
+
}
|
|
1620
|
+
const docsStatus = docsSiteUrl() === null ? "not_configured" : prose === null ? "unavailable" : "ok";
|
|
1621
|
+
if (jsonOutput) {
|
|
1622
|
+
out({
|
|
1623
|
+
schema_version: "1",
|
|
1624
|
+
query: term,
|
|
1625
|
+
reference: refMatches.slice(0, 15).map((op) => ({
|
|
1626
|
+
command: op.command.join(" "),
|
|
1627
|
+
method: op.httpMethod,
|
|
1628
|
+
path: op.path,
|
|
1629
|
+
...(op.summary ? { summary: op.summary } : {}),
|
|
1630
|
+
details_command: BIN + " docs " + op.command.join(" ") + " --json",
|
|
1631
|
+
})),
|
|
1632
|
+
guides: proseMatches.slice(0, 15).map(({ heading, excerpt }) => ({ heading, excerpt })),
|
|
1633
|
+
totals: { reference: refMatches.length, guides: proseMatches.length },
|
|
1634
|
+
guides_status: docsStatus,
|
|
1635
|
+
...(docsStatus === "not_configured" ? { next_steps: ["Run '" + BIN + " config set docs-url <url>' to add guide search; the API reference was still searched."] } : {}),
|
|
1636
|
+
...(docsStatus === "unavailable" ? { next_steps: ["The docs site could not be reached; cached guide content was unavailable. The API reference was still searched."] } : {}),
|
|
1637
|
+
});
|
|
1638
|
+
await flushExit(0);
|
|
1639
|
+
}
|
|
1640
|
+
const lines: string[] = [];
|
|
1641
|
+
if (refMatches.length > 0) {
|
|
1642
|
+
lines.push(paintOut("bold", "Reference:"));
|
|
1643
|
+
for (const op of refMatches.slice(0, 15)) lines.push(" " + padPaint("cyan", op.command.join(" "), 34) + (op.summary ?? wireOf(op)));
|
|
1644
|
+
}
|
|
1645
|
+
if (proseMatches.length > 0) {
|
|
1646
|
+
lines.push(...(lines.length > 0 ? [""] : []), paintOut("bold", "Guides:"));
|
|
1647
|
+
for (const match of proseMatches.slice(0, 15)) lines.push(" " + padPaint("cyan", match.heading.slice(0, 32), 34) + match.excerpt.slice(0, 100));
|
|
1648
|
+
} else if (docsStatus === "not_configured") {
|
|
1649
|
+
lines.push(...(lines.length > 0 ? [""] : []), "(no docs site configured for guide search: '" + BIN + " config set docs-url <url>')");
|
|
1650
|
+
} else if (docsStatus === "unavailable") {
|
|
1651
|
+
lines.push(...(lines.length > 0 ? [""] : []), "(docs site unavailable; the API reference was still searched)");
|
|
1652
|
+
}
|
|
1653
|
+
if (lines.length === 0) lines.push("No matches for: " + term);
|
|
1654
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
1655
|
+
await flushExit(0);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
if (sub === "read") {
|
|
1659
|
+
const page = parsed.positionals[2];
|
|
1660
|
+
if (page === undefined) fail(2, "docs read expects a page path or URL");
|
|
1661
|
+
let target = page!;
|
|
1662
|
+
if (!/^https?:\/\//.test(target)) {
|
|
1663
|
+
const index = await fetchDocs("llms.txt");
|
|
1664
|
+
const linked = index?.match(/\((https?:[^)]+)\)/g)?.map((m) => m.slice(1, -1)) ?? [];
|
|
1665
|
+
const hit = linked.find((u) => u.toLowerCase().includes(target.toLowerCase()));
|
|
1666
|
+
if (hit !== undefined) target = hit;
|
|
1667
|
+
}
|
|
1668
|
+
const text = await fetchDocs(target);
|
|
1669
|
+
if (text === null) {
|
|
1670
|
+
fail(1, docsSiteUrl() === null
|
|
1671
|
+
? "No docs site configured. Run '" + BIN + " config set docs-url <url>'."
|
|
1672
|
+
: "Couldn't fetch that page. Run '" + BIN + " docs' to see the index.");
|
|
1673
|
+
}
|
|
1674
|
+
process.stdout.write(text! + (text!.endsWith("\n") ? "" : "\n"));
|
|
1675
|
+
await flushExit(0);
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
if (sub !== undefined) {
|
|
1679
|
+
const op = findOp(sub!, parsed.positionals[2] ?? "");
|
|
1680
|
+
if (!op) {
|
|
1681
|
+
const list = OPS.filter((o) => o.command[0] === sub);
|
|
1682
|
+
if (list.length === 0) fail(2, "Unknown docs topic: " + sub + ". Run '" + BIN + " docs' for the overview.");
|
|
1683
|
+
const lines = [paintOut("bold", "Commands for " + sub + ":"), ""];
|
|
1684
|
+
for (const resourceOp of list) {
|
|
1685
|
+
lines.push(" " + padPaint("cyan", resourceOp.command.join(" "), 34) + (resourceOp.summary ?? ""));
|
|
1686
|
+
}
|
|
1687
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
1688
|
+
await flushExit(0);
|
|
1689
|
+
}
|
|
1690
|
+
if (parsed.flags.get("json") === true) {
|
|
1691
|
+
process.stdout.write(JSON.stringify({
|
|
1692
|
+
schema_version: "1",
|
|
1693
|
+
spec_format: SPEC_FORMAT,
|
|
1694
|
+
api_version: API_VERSION,
|
|
1695
|
+
operation: op,
|
|
1696
|
+
}, null, 2) + "\n");
|
|
1697
|
+
await flushExit(0);
|
|
1698
|
+
}
|
|
1699
|
+
process.stdout.write(referenceFor(op!, parsed.flags.get("schema") === true) + "\n");
|
|
1700
|
+
await flushExit(0);
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
const lines: string[] = [];
|
|
1704
|
+
lines.push(paintOut("bold", "typeship") + " (v" + API_VERSION + ")");
|
|
1705
|
+
if (API_DESCRIPTION) lines.push("", API_DESCRIPTION.trim());
|
|
1706
|
+
lines.push("", paintOut("bold", "Reference:") + " " + BIN + " docs <resource> <command>");
|
|
1707
|
+
const byResource = new Map<string, number>();
|
|
1708
|
+
for (const op of OPS) byResource.set(op.command[0], (byResource.get(op.command[0]) ?? 0) + 1);
|
|
1709
|
+
for (const [resource, count] of byResource) {
|
|
1710
|
+
lines.push(" " + padPaint("cyan", resource, 24) + count + " command" + (count === 1 ? "" : "s"));
|
|
1711
|
+
}
|
|
1712
|
+
const base = docsSiteUrl();
|
|
1713
|
+
lines.push("", paintOut("bold", "Guides:") + " " + (base !== null
|
|
1714
|
+
? base + " (" + BIN + " docs search <term>, " + BIN + " docs read <page>, " + BIN + " docs --web)"
|
|
1715
|
+
: "no docs site configured — '" + BIN + " config set docs-url <url>'"));
|
|
1716
|
+
if (base !== null) {
|
|
1717
|
+
const index = await fetchDocs("llms.txt");
|
|
1718
|
+
if (index === null) {
|
|
1719
|
+
lines.push(" (the docs site doesn't publish llms.txt; only --web is available)");
|
|
1720
|
+
} else {
|
|
1721
|
+
const titles = [...index.matchAll(/^- \[([^\]]+)\]/gm)].map((m) => m[1]).slice(0, 12);
|
|
1722
|
+
for (const title of titles) lines.push(" " + title);
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
1726
|
+
await flushExit(0);
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
// ---------------------------------------------------------------------------
|
|
1730
|
+
// webhooks listen — forward relayed events to a local handler
|
|
1731
|
+
// ---------------------------------------------------------------------------
|
|
1732
|
+
|
|
1733
|
+
interface RelayedEvent {
|
|
1734
|
+
seq: number;
|
|
1735
|
+
method: string;
|
|
1736
|
+
headers: Record<string, string>;
|
|
1737
|
+
content_type: string | null;
|
|
1738
|
+
body: string;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
function eventTypeOf(body: string): string | null {
|
|
1742
|
+
try {
|
|
1743
|
+
const parsed = JSON.parse(body) as Record<string, unknown>;
|
|
1744
|
+
for (const key of ["type", "event", "event_type", "eventType", "name"]) {
|
|
1745
|
+
if (typeof parsed[key] === "string") return parsed[key] as string;
|
|
1746
|
+
}
|
|
1747
|
+
} catch { /* not JSON */ }
|
|
1748
|
+
return null;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
async function cmdWebhooksFake(parsed: Parsed): Promise<void> {
|
|
1752
|
+
void parsed;
|
|
1753
|
+
fail(2, "This API's spec declares no webhooks, so there is nothing to fake.");
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
async function cmdWebhooks(parsed: Parsed): Promise<void> {
|
|
1757
|
+
const sub = parsed.positionals[1];
|
|
1758
|
+
if (parsed.help || sub === undefined) {
|
|
1759
|
+
const lines = [
|
|
1760
|
+
BIN + " webhooks — work with this API's webhook events",
|
|
1761
|
+
"",
|
|
1762
|
+
" " + BIN + " webhooks listen --forward-to localhost:3000/webhooks",
|
|
1763
|
+
" " + BIN + " webhooks listen --forward-to localhost:3000/hooks --events account.created,account.updated",
|
|
1764
|
+
" " + BIN + " webhooks fake [<event>] [--key whsec_...] [--forward-to <url>]",
|
|
1765
|
+
"",
|
|
1766
|
+
"listen mints a private relay URL to point the API's webhook settings",
|
|
1767
|
+
"at; events replay locally with their original headers, so signature",
|
|
1768
|
+
"verification keeps working. No tunnels, no exposed ports.",
|
|
1769
|
+
"fake sends (or prints) a signed sample event for local handler testing.",
|
|
1770
|
+
];
|
|
1771
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
1772
|
+
await flushExit(parsed.help ? 0 : 2);
|
|
1773
|
+
}
|
|
1774
|
+
if (sub === "fake") { await cmdWebhooksFake(parsed); }
|
|
1775
|
+
if (sub !== "listen") fail(2, "Unknown webhooks command: " + String(sub) + ". Try '" + BIN + " webhooks listen' or '" + BIN + " webhooks fake'.");
|
|
1776
|
+
if (!RELAY) {
|
|
1777
|
+
fail(2, "The webhook relay isn't enabled for this package. The API provider can enable it in their typeship console; the next regeneration bakes it in.");
|
|
1778
|
+
}
|
|
1779
|
+
const forwardRaw = parsed.flags.get("forward-to");
|
|
1780
|
+
if (typeof forwardRaw !== "string") {
|
|
1781
|
+
fail(2, "webhooks listen requires --forward-to <url>, e.g. --forward-to localhost:3000/webhooks");
|
|
1782
|
+
}
|
|
1783
|
+
const forwardTo = /^https?:\/\//.test(forwardRaw as string) ? forwardRaw as string : "http://" + (forwardRaw as string);
|
|
1784
|
+
const eventsRaw = parsed.flags.get("events");
|
|
1785
|
+
const eventsFilter = typeof eventsRaw === "string"
|
|
1786
|
+
? eventsRaw.split(",").map((s) => s.trim()).filter(Boolean)
|
|
1787
|
+
: null;
|
|
1788
|
+
|
|
1789
|
+
interface MintedSession {
|
|
1790
|
+
session_id?: string;
|
|
1791
|
+
ingest_url?: string;
|
|
1792
|
+
poll_url?: string;
|
|
1793
|
+
errors?: { message?: string }[];
|
|
1794
|
+
}
|
|
1795
|
+
let minted: MintedSession | null = null;
|
|
1796
|
+
try {
|
|
1797
|
+
const response = await fetch(RELAY!.mintUrl, {
|
|
1798
|
+
method: "POST",
|
|
1799
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
1800
|
+
body: JSON.stringify({ project: RELAY!.project }),
|
|
1801
|
+
signal: AbortSignal.timeout(15_000),
|
|
1802
|
+
});
|
|
1803
|
+
minted = await response.json().catch(() => null) as MintedSession | null;
|
|
1804
|
+
if (!response.ok || typeof minted?.ingest_url !== "string" || typeof minted.poll_url !== "string") {
|
|
1805
|
+
const detail = minted?.errors?.[0]?.message ?? "HTTP " + response.status;
|
|
1806
|
+
fail(1, "Couldn't start a relay session: " + detail);
|
|
1807
|
+
}
|
|
1808
|
+
} catch (e) {
|
|
1809
|
+
if (e instanceof ExitPending) throw e;
|
|
1810
|
+
fail(1, "Couldn't reach the relay: " + (e as Error).message);
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
process.stderr.write(paintErr("green", "Ready!") + " Forwarding relayed events to " + forwardTo + "\n");
|
|
1814
|
+
process.stderr.write("Point this API's webhook endpoint at: " + paintErr("cyan", minted!.ingest_url!) + " (^C to quit)\n");
|
|
1815
|
+
|
|
1816
|
+
let cursor = 0;
|
|
1817
|
+
let pollFailures = 0;
|
|
1818
|
+
for (;;) {
|
|
1819
|
+
let payload: { events: RelayedEvent[]; next: number };
|
|
1820
|
+
try {
|
|
1821
|
+
const response = await fetch(minted!.poll_url! + "?after=" + cursor + "&wait=1", {
|
|
1822
|
+
headers: { Accept: "application/json" },
|
|
1823
|
+
signal: AbortSignal.timeout(30_000),
|
|
1824
|
+
});
|
|
1825
|
+
if (response.status === 404) fail(1, "The relay session expired. Run '" + BIN + " webhooks listen' again.");
|
|
1826
|
+
if (!response.ok) throw new Error("HTTP " + response.status);
|
|
1827
|
+
payload = await response.json() as typeof payload;
|
|
1828
|
+
pollFailures = 0;
|
|
1829
|
+
} catch (e) {
|
|
1830
|
+
if (e instanceof ExitPending) throw e;
|
|
1831
|
+
pollFailures++;
|
|
1832
|
+
if (pollFailures > 5) fail(1, "Lost the relay: " + (e as Error).message);
|
|
1833
|
+
await new Promise((resolve) => setTimeout(resolve, 2000 * pollFailures));
|
|
1834
|
+
continue;
|
|
1835
|
+
}
|
|
1836
|
+
for (const event of payload.events) {
|
|
1837
|
+
cursor = event.seq;
|
|
1838
|
+
const type = eventTypeOf(event.body);
|
|
1839
|
+
if (eventsFilter !== null && (type === null || !eventsFilter.includes(type))) continue;
|
|
1840
|
+
const started = Date.now();
|
|
1841
|
+
try {
|
|
1842
|
+
const forwarded = await fetch(forwardTo, {
|
|
1843
|
+
method: event.method,
|
|
1844
|
+
headers: event.headers,
|
|
1845
|
+
body: event.method === "GET" ? undefined : event.body,
|
|
1846
|
+
signal: AbortSignal.timeout(30_000),
|
|
1847
|
+
});
|
|
1848
|
+
process.stderr.write(
|
|
1849
|
+
(forwarded.ok ? paintErr("green", String(forwarded.status)) : paintErr("yellow", String(forwarded.status))) +
|
|
1850
|
+
" " + (type ?? event.method) + " [" + event.seq + "] (" + (Date.now() - started) + "ms)\n",
|
|
1851
|
+
);
|
|
1852
|
+
} catch (e) {
|
|
1853
|
+
process.stderr.write(paintErr("yellow", "unreachable") + " " + (type ?? event.method) + " [" + event.seq + "]: " + (e as Error).message + "\n");
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
if (payload.next > cursor) cursor = payload.next;
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
async function cmdFeedback(parsed: Parsed): Promise<void> {
|
|
1861
|
+
if (parsed.help) {
|
|
1862
|
+
process.stdout.write(BIN + " feedback — open the API provider's issue tracker with environment details prefilled\n");
|
|
1863
|
+
await flushExit(0);
|
|
1864
|
+
}
|
|
1865
|
+
if (!SUPPORT_URL) {
|
|
1866
|
+
fail(2, "No support URL is configured for this CLI. The API provider can set one in their typeship console.");
|
|
1867
|
+
}
|
|
1868
|
+
let target = SUPPORT_URL!;
|
|
1869
|
+
if (/github\.com\/[^/]+\/[^/]+\/issues\/new/.test(target)) {
|
|
1870
|
+
const bodyLines = [
|
|
1871
|
+
"<!-- describe the problem or request above the line -->",
|
|
1872
|
+
"",
|
|
1873
|
+
"---",
|
|
1874
|
+
"- cli: " + BIN + " " + VERSION + " (api " + API_VERSION + ")",
|
|
1875
|
+
"- node: " + process.version,
|
|
1876
|
+
"- platform: " + process.platform + "/" + process.arch,
|
|
1877
|
+
];
|
|
1878
|
+
const sep = target.includes("?") ? "&" : "?";
|
|
1879
|
+
target += sep + "title=" + encodeURIComponent("[" + BIN + "] ") + "&body=" + encodeURIComponent(bodyLines.join("\n"));
|
|
1880
|
+
}
|
|
1881
|
+
if (!nonInteractive(parsed)) process.stderr.write("Opening " + paintErr("cyan", SUPPORT_URL!) + "\n");
|
|
1882
|
+
const opened = openInBrowser(target, parsed);
|
|
1883
|
+
if (opened.opened) out({ ok: true, opened: SUPPORT_URL });
|
|
1884
|
+
await flushExit(0);
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
/** Opt-in (console setting) once-a-day upgrade hint. Off by default:
|
|
1888
|
+
* generated code phones nobody unless the project owner chose this. The
|
|
1889
|
+
* notice prints the previous run's cached answer; at most once a day it
|
|
1890
|
+
* refreshes the cache first, with a 1.5s cap so a slow registry can't
|
|
1891
|
+
* hold a command hostage. */
|
|
1892
|
+
async function maybeUpdateNotice(commandWord: string | undefined, quiet: boolean): Promise<void> {
|
|
1893
|
+
if (!UPDATE_NOTICE || quiet) return;
|
|
1894
|
+
if (commandWord === undefined || ["upgrade", "version", "help", "login", "logout"].includes(commandWord)) return;
|
|
1895
|
+
const cacheFile = join(configDir(), "update-check.json");
|
|
1896
|
+
let cache: { checkedAt?: number; latest?: string } = {};
|
|
1897
|
+
try {
|
|
1898
|
+
cache = JSON.parse(readFileSync(cacheFile, "utf8")) as typeof cache;
|
|
1899
|
+
} catch { /* no cache yet */ }
|
|
1900
|
+
if ((cache.checkedAt ?? 0) < Date.now() - 24 * 60 * 60 * 1000) {
|
|
1901
|
+
const latest = await latestVersion(1500);
|
|
1902
|
+
if (latest !== null) {
|
|
1903
|
+
cache = { checkedAt: Date.now(), latest };
|
|
1904
|
+
mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
1905
|
+
writeFileSync(cacheFile, JSON.stringify(cache) + "\n");
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
if (cache.latest !== undefined && semverLess(VERSION, cache.latest)) {
|
|
1909
|
+
process.stderr.write(paintErr("yellow", "A newer " + BIN + " is available: " + VERSION + " -> " + cache.latest + ". Run '" + BIN + " upgrade'.") + "\n");
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
/** The command that fetches the next page: same positionals, the next page's query flags. */
|
|
1914
|
+
function nextCommandFor(op: OpSpec, pathValues: string[], next: Record<string, unknown>): string {
|
|
1915
|
+
const parts = [BIN, op.command[0], op.command[1], ...pathValues.map(shellQuote)];
|
|
1916
|
+
for (const [wire, value] of Object.entries(next)) {
|
|
1917
|
+
if (value === undefined || value === null) continue;
|
|
1918
|
+
const spec = op.params.find((p) => (p.kind === "query" || p.kind === "body") && p.name === wire);
|
|
1919
|
+
const flag = spec ? spec.flag : wire;
|
|
1920
|
+
const text = Array.isArray(value) ? value.map(String).join(",") : typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
1921
|
+
parts.push("--" + flag, shellQuote(text));
|
|
1922
|
+
}
|
|
1923
|
+
return parts.join(" ");
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
/** Quote a value for a copy-pasteable shell line (POSIX single quotes). */
|
|
1927
|
+
function shellQuote(value: string): string {
|
|
1928
|
+
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : "'" + value.replace(/'/g, "'\\''") + "'";
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
function usageLine(op: OpSpec): string {
|
|
1932
|
+
const paths = op.params.filter((p) => p.kind === "path").map((p) => "<" + p.name + ">").join(" ");
|
|
1933
|
+
return BIN + " " + op.command[0] + " " + op.command[1] + (paths ? " " + paths : "");
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
/** How the command reaches the wire: "GET /users/{id}", or for GraphQL the
|
|
1937
|
+
* root field, since every operation is one POST to the endpoint. */
|
|
1938
|
+
function wireOf(op: OpSpec): string {
|
|
1939
|
+
return op.graphql ? "GraphQL " + op.graphql.kind + " " + op.graphql.field : op.httpMethod + " " + op.path;
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
/** Pad to a column, then paint — escape codes must not count toward width. */
|
|
1943
|
+
function padPaint(code: keyof typeof ANSI, text: string, width: number): string {
|
|
1944
|
+
return paintOut(code, text) + " ".repeat(Math.max(1, width - text.length));
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/** Terminal width for wrapping help: the real column count, else 100, never under 60. */
|
|
1948
|
+
function termWidth(): number {
|
|
1949
|
+
const cols = process.stdout.columns;
|
|
1950
|
+
return Math.max(60, Math.min(cols && cols > 0 ? cols : 100, 160));
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
/** Word-wrap text to a width. */
|
|
1954
|
+
function wrapText(text: string, width: number): string[] {
|
|
1955
|
+
const words = text.split(/\s+/).filter((w) => w !== "");
|
|
1956
|
+
const lines: string[] = [];
|
|
1957
|
+
let line = "";
|
|
1958
|
+
const max = Math.max(20, width);
|
|
1959
|
+
for (const word of words) {
|
|
1960
|
+
if (line === "") { line = word; continue; }
|
|
1961
|
+
if ((line + " " + word).length > max) { lines.push(line); line = word; }
|
|
1962
|
+
else line += " " + word;
|
|
1963
|
+
}
|
|
1964
|
+
if (line !== "") lines.push(line);
|
|
1965
|
+
return lines;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
/** The first sentence of a description for --help: Markdown links flattened, one line, bounded. */
|
|
1969
|
+
function helpSentence(description: string | undefined): string {
|
|
1970
|
+
if (!description) return "";
|
|
1971
|
+
const flat = description.trim().replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/\s+/g, " ");
|
|
1972
|
+
const m = flat.match(/^(.{1,200}?[.!?])(\s|$)/);
|
|
1973
|
+
const first = m ? m[1]! : flat;
|
|
1974
|
+
return first.length > 220 ? first.slice(0, 217).replace(/\s+\S*$/, "") + "…" : first;
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
/** Type column text for a param: string, number, string[], a|b|c, enum, object, json, path. */
|
|
1978
|
+
function typeLabel(p: ParamSpec): string {
|
|
1979
|
+
if (p.type === "file") return "path (uploaded)";
|
|
1980
|
+
if (p.format && p.type === "string") return p.format;
|
|
1981
|
+
const inlineEnum = (values: string[] | undefined) => values && values.join("|").length <= 24 ? values.join("|") : undefined;
|
|
1982
|
+
if (p.type === "array") {
|
|
1983
|
+
const items = p.items ?? { type: "json" as const };
|
|
1984
|
+
const inner = inlineEnum(items.enum) ?? (items.enum ? "enum" : items.type);
|
|
1985
|
+
return inner + "[]";
|
|
1986
|
+
}
|
|
1987
|
+
if (p.enum) return inlineEnum(p.enum) ?? "enum";
|
|
1988
|
+
return p.type;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
/** The enum values that did not fit in the type column, for a continuation line. */
|
|
1992
|
+
function enumNote(p: ParamSpec): string | null {
|
|
1993
|
+
const values = p.type === "array" ? p.items?.enum : p.enum;
|
|
1994
|
+
if (!values || values.join("|").length <= 24) return null;
|
|
1995
|
+
return "one of: " + values.join(", ");
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
/** A "--flag type description" table with columns sized to the content and descriptions wrapped. */
|
|
1999
|
+
function flagTable(params: ParamSpec[], extras: [string, string][]): string[] {
|
|
2000
|
+
const width = termWidth();
|
|
2001
|
+
const rows: { name: string; type: string; required: boolean; text: string; note: string | null }[] = params.map((p) => ({
|
|
2002
|
+
name: p.kind === "path" ? "<" + p.name + ">" : "--" + p.flag,
|
|
2003
|
+
type: typeLabel(p),
|
|
2004
|
+
required: p.required,
|
|
2005
|
+
text: helpSentence(p.description),
|
|
2006
|
+
note: enumNote(p),
|
|
2007
|
+
}));
|
|
2008
|
+
for (const [name, text] of extras) rows.push({ name, type: "", required: false, text, note: null });
|
|
2009
|
+
const nameCol = Math.min(34, Math.max(12, ...rows.map((r) => r.name.length + 2)));
|
|
2010
|
+
const typeCol = Math.min(28, Math.max(8, ...rows.map((r) => r.type.length + 2)));
|
|
2011
|
+
const indent = 2 + nameCol + typeCol;
|
|
2012
|
+
const lines: string[] = [];
|
|
2013
|
+
for (const r of rows) {
|
|
2014
|
+
// A name wider than its column gets its own line; the type and text follow beneath, aligned.
|
|
2015
|
+
const head = r.name.length + 2 > nameCol
|
|
2016
|
+
? " " + paintOut("cyan", r.name) + "\n" + " ".repeat(2 + nameCol) + r.type.padEnd(typeCol)
|
|
2017
|
+
: " " + padPaint("cyan", r.name, nameCol) + r.type.padEnd(typeCol);
|
|
2018
|
+
const plain = (r.required ? "(required)" + (r.text ? " " : "") : "") + r.text;
|
|
2019
|
+
if (plain === "") { lines.push(head.trimEnd()); }
|
|
2020
|
+
else {
|
|
2021
|
+
const wrapped = wrapText(plain, width - indent);
|
|
2022
|
+
const first = r.required ? paintOut("yellow", "(required)") + wrapped[0]!.slice("(required)".length) : wrapped[0]!;
|
|
2023
|
+
lines.push((head + first).trimEnd());
|
|
2024
|
+
for (const more of wrapped.slice(1)) lines.push(" ".repeat(indent) + more);
|
|
2025
|
+
}
|
|
2026
|
+
if (r.note) for (const more of wrapText(r.note, width - indent)) lines.push(" ".repeat(indent) + paintOut("dim", more));
|
|
2027
|
+
}
|
|
2028
|
+
return lines;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
/** Display order for a resource's commands: the CRUD verbs first, then the rest in spec order. */
|
|
2032
|
+
const VERB_ORDER = ["list", "get", "create", "update", "delete"];
|
|
2033
|
+
function displayOrder(list: OpSpec[]): OpSpec[] {
|
|
2034
|
+
const rank = (o: OpSpec) => { const i = VERB_ORDER.indexOf(o.command[1]); return i === -1 ? VERB_ORDER.length : i; };
|
|
2035
|
+
return [...list].map((o, i) => ({ o, i })).sort((a, b) => rank(a.o) - rank(b.o) || a.i - b.i).map((x) => x.o);
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
/** "Label: text" wrapped to the terminal with the continuation indented under the text. */
|
|
2039
|
+
function labeled(label: string, text: string, width: number, indent: number): string[] {
|
|
2040
|
+
const first = label.padEnd(indent);
|
|
2041
|
+
const wrapped = wrapText(text, width - indent);
|
|
2042
|
+
return wrapped.map((line, i) => (i === 0 ? first + line : " ".repeat(indent) + line));
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
function printRoot(stream: NodeJS.WriteStream = process.stdout): void {
|
|
2046
|
+
const byResource = new Map<string, OpSpec[]>();
|
|
2047
|
+
for (const op of OPS) {
|
|
2048
|
+
const list = byResource.get(op.command[0]) ?? [];
|
|
2049
|
+
list.push(op);
|
|
2050
|
+
byResource.set(op.command[0], list);
|
|
2051
|
+
}
|
|
2052
|
+
const width = termWidth();
|
|
2053
|
+
const lines: string[] = [];
|
|
2054
|
+
lines.push(paintOut("bold", BIN) + ": " + "typeship" + " (v" + "0.6.0" + ")");
|
|
2055
|
+
lines.push("");
|
|
2056
|
+
lines.push(paintOut("bold", "Usage:") + " " + BIN + " <resource> <command> [args] [--flags]");
|
|
2057
|
+
lines.push("");
|
|
2058
|
+
lines.push(paintOut("bold", "Resources:"));
|
|
2059
|
+
// Big APIs get a digest per resource; the resource's own help has the full list.
|
|
2060
|
+
const digest = OPS.length > 120;
|
|
2061
|
+
const col = Math.min(30, Math.max(...[...byResource.keys()].map((r) => r.length)) + 4);
|
|
2062
|
+
for (const [resource, list] of byResource) {
|
|
2063
|
+
const names = displayOrder(list).map((o) => o.command[1]);
|
|
2064
|
+
const text = digest && names.length > 6
|
|
2065
|
+
? names.slice(0, 6).join(", ") + ", … " + (names.length - 6) + " more (" + BIN + " " + resource + ")"
|
|
2066
|
+
: names.join(", ");
|
|
2067
|
+
const wrapped = wrapText(text, width - 2 - col);
|
|
2068
|
+
lines.push(" " + padPaint("cyan", resource, col) + wrapped[0]);
|
|
2069
|
+
for (const more of wrapped.slice(1)) lines.push(" ".repeat(2 + col) + more);
|
|
2070
|
+
}
|
|
2071
|
+
lines.push("");
|
|
2072
|
+
const flagsText = "-v/--version, -h/--help, --debug, --non-interactive, --color on|off|auto, --base-url <url>, --data '<json>', --fields <a,b.c>, --all (paginated lists), --validate (schema-check bodies)" +
|
|
2073
|
+
(AUTH_SCALARS.length > 0 ? ", " + AUTH_SCALARS.map((a) => "--" + a.flag + " <value>").join(", ") : "");
|
|
2074
|
+
lines.push(...labeled(paintOut("bold", "Global flags:") + " ", flagsText, width, 14).map((l, i) => (i === 0 ? l : l)));
|
|
2075
|
+
lines.push(...labeled("Credential env vars: ", [
|
|
2076
|
+
...AUTH_SCALARS.map((a) => a.env),
|
|
2077
|
+
...(BASIC ? [BASIC.envUser, BASIC.envPass] : []),
|
|
2078
|
+
].join(", ") || "none", width, 21));
|
|
2079
|
+
lines.push(...labeled("Endpoint env var: ", "TYPESHIP_BASE_URL", width, 18));
|
|
2080
|
+
lines.push(...labeled("Account: ", BIN + " login | logout | whoami | auth check (stored at " + credsPath() + ")", width, 9));
|
|
2081
|
+
lines.push(...labeled("Setup: ", BIN + " init (connect this machine)" + " | " + BIN + " config (defaults)" + (HAS_MCP || MCP_URL ? " | " + BIN + " mcp install --all (agent clients)" : "") + " | " + BIN + " doctor | " + BIN + " upgrade | " + BIN + " completion <shell>", width, 7));
|
|
2082
|
+
lines.push(...labeled("Agents: ", BIN + " agent-guide | " + BIN + " help --json | --mode agent | -y/--yes/--force | --out <dir> (JSON errors: {status, issues[{code}], next_steps})", width, 8));
|
|
2083
|
+
lines.push(...labeled("Docs: ", BIN + " docs [<resource> <command> | search <term> | read <page> | --web]", width, 6));
|
|
2084
|
+
if (RELAY) lines.push("Webhooks: " + BIN + " webhooks listen --forward-to <url> (local event forwarding)");
|
|
2085
|
+
if (SUPPORT_URL) lines.push("Feedback: " + BIN + " feedback (opens the provider's issue tracker)");
|
|
2086
|
+
if (EXCLUDED_OPS > 0 && HAS_MCP) {
|
|
2087
|
+
lines.push("");
|
|
2088
|
+
lines.push("Note: " + EXCLUDED_OPS + " operation(s) with uploads or event streams are CLI/SDK-only, not MCP tools.");
|
|
2089
|
+
}
|
|
2090
|
+
lines.push("");
|
|
2091
|
+
lines.push("Run '" + BIN + " <resource>' for a resource's commands, '" + BIN + " <resource> <command> --help' for flags.");
|
|
2092
|
+
stream.write(lines.join("\n") + "\n");
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
function printResource(resource: string, stream: NodeJS.WriteStream = process.stdout): void {
|
|
2096
|
+
const list = displayOrder(OPS.filter((o) => o.command[0] === resource));
|
|
2097
|
+
const lines: string[] = ["Commands for " + resource + ":", ""];
|
|
2098
|
+
const width = termWidth();
|
|
2099
|
+
const col = Math.min(64, Math.max(...list.map((o) => usageLine(o).length)) + 2);
|
|
2100
|
+
for (const op of list) {
|
|
2101
|
+
const usage = usageLine(op);
|
|
2102
|
+
const wrapped = wrapText((helpSentence(op.summary) || wireOf(op)) + authNote(op), width - 2 - col);
|
|
2103
|
+
if (usage.length + 2 > col) {
|
|
2104
|
+
lines.push(" " + usage);
|
|
2105
|
+
for (const line of wrapped) lines.push(" ".repeat(2 + col) + line);
|
|
2106
|
+
} else {
|
|
2107
|
+
lines.push(" " + usage.padEnd(col) + wrapped[0]);
|
|
2108
|
+
for (const line of wrapped.slice(1)) lines.push(" ".repeat(2 + col) + line);
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
lines.push("", "Run '" + BIN + " " + resource + " <command> --help' for flags.");
|
|
2112
|
+
stream.write(lines.join("\n") + "\n");
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
/** Flags the CLI itself adds to an API command, as [flag, description] rows. */
|
|
2116
|
+
function commandExtras(op: OpSpec): [string, string][] {
|
|
2117
|
+
const extras: [string, string][] = [];
|
|
2118
|
+
if (op.hasBody && op.bodyKind === "binary") extras.push(["--file <path>", "raw request body, uploaded as-is (- reads stdin)"]);
|
|
2119
|
+
else if (op.hasBody) extras.push(["--data '<json>'", "raw JSON body" + (op.bodyStyle === "fields" ? " (merged under field flags)" : "") + "; @<file> reads a file, - reads stdin"]);
|
|
2120
|
+
if (op.select) extras.push(["--select '<selection>'", "GraphQL selection set replacing the default, e.g. '{ id name }'"]);
|
|
2121
|
+
if (op.paginated) extras.push(["--all", "stream every item from every page (NDJSON)"]);
|
|
2122
|
+
const collectionField = collectionProperty(op.outputSchema);
|
|
2123
|
+
extras.push(["--fields <a,b.c>", "keep only these fields of the result" + (op.paginated ? " (per item)" : collectionField ? " (per item in " + collectionField + ")" : "")]);
|
|
2124
|
+
if (bundleProperty(op.outputSchema) !== null) extras.push(["--out <dir>", "write the response's files ({path, content}) into a directory"]);
|
|
2125
|
+
if (op.safety === "destructive") extras.push(["--force, -y", "destructive: required without a terminal, skips the prompt with one"]);
|
|
2126
|
+
return extras;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
/** One runnable example built from the required inputs. */
|
|
2130
|
+
function exampleLine(op: OpSpec): string {
|
|
2131
|
+
const parts = [BIN, op.command[0], op.command[1]];
|
|
2132
|
+
for (const p of op.params) {
|
|
2133
|
+
if (!p.required) continue;
|
|
2134
|
+
const generated = p.type === "file" ? undefined : op.exampleArguments[p.name];
|
|
2135
|
+
const values = p.type === "array" ? p.items?.enum : p.enum;
|
|
2136
|
+
const fallback: unknown = p.type === "file" ? "./file"
|
|
2137
|
+
: values ? values[0]!
|
|
2138
|
+
: p.type === "number" ? 1
|
|
2139
|
+
: p.type === "boolean" ? true
|
|
2140
|
+
: p.type === "object" ? {}
|
|
2141
|
+
: p.type === "array" ? [p.items?.type === "number" ? 1 : "value"]
|
|
2142
|
+
: "value";
|
|
2143
|
+
const value = generated ?? fallback;
|
|
2144
|
+
const sample = typeof value === "string" ? shellQuote(value)
|
|
2145
|
+
: typeof value === "object" ? shellQuote(JSON.stringify(value))
|
|
2146
|
+
: String(value);
|
|
2147
|
+
if (p.kind === "path") parts.push(sample);
|
|
2148
|
+
else parts.push("--" + p.flag, sample);
|
|
2149
|
+
}
|
|
2150
|
+
const required = op.bodyStyle === "data" && ((op.inputSchema.required as string[] | undefined) ?? []).includes("body");
|
|
2151
|
+
if (required) {
|
|
2152
|
+
const body = op.exampleArguments.body;
|
|
2153
|
+
parts.push(op.bodyKind === "binary" ? "--file ./file" : "--data " + shellQuote(JSON.stringify(body ?? {})));
|
|
2154
|
+
}
|
|
2155
|
+
if (op.safety === "destructive") parts.push("--force");
|
|
2156
|
+
return parts.join(" ");
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
/** " (no auth needed)" for an anonymous operation in an API that otherwise authenticates. */
|
|
2160
|
+
function authNote(op: OpSpec): string {
|
|
2161
|
+
const apiHasAuth = AUTH_SCALARS.length > 0 || BASIC !== null || OAUTH_TOKEN_URL !== null;
|
|
2162
|
+
return apiHasAuth && op.auth === "none" ? " (no auth needed)" : apiHasAuth && op.auth === "optional" ? " (auth optional)" : "";
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
function printOp(op: OpSpec): void {
|
|
2166
|
+
const lines: string[] = [];
|
|
2167
|
+
lines.push(paintOut("bold", usageLine(op)));
|
|
2168
|
+
if (op.summary) lines.push(helpSentence(op.summary));
|
|
2169
|
+
lines.push(wireOf(op) + authNote(op));
|
|
2170
|
+
lines.push("");
|
|
2171
|
+
const rows = op.params.filter((p) => p.kind !== "path");
|
|
2172
|
+
const extras = commandExtras(op);
|
|
2173
|
+
if (rows.length > 0 || extras.length > 0) {
|
|
2174
|
+
lines.push(paintOut("bold", "Flags:"));
|
|
2175
|
+
lines.push(...flagTable(rows, extras));
|
|
2176
|
+
}
|
|
2177
|
+
if (op.sse) lines.push(" (streams: one JSON line per server-sent event until the stream ends)");
|
|
2178
|
+
lines.push("", paintOut("bold", "Example:"));
|
|
2179
|
+
lines.push(" " + exampleLine(op));
|
|
2180
|
+
if (op.paginated) lines.push(" " + usageLine(op) + " --all | jq -r '.id'");
|
|
2181
|
+
const arrayFlag = rows.find((p) => p.type === "array");
|
|
2182
|
+
if (arrayFlag) {
|
|
2183
|
+
const sample = arrayFlag.items?.enum ? arrayFlag.items.enum.slice(0, 2).join(",") : arrayFlag.items?.type === "number" ? "1,2" : "a,b";
|
|
2184
|
+
lines.push("", "Array flags take a comma list (--" + arrayFlag.flag + " " + sample + "), the flag repeated, or a JSON array.");
|
|
2185
|
+
}
|
|
2186
|
+
const dateFlag = rows.find((p) => p.format && p.type === "string");
|
|
2187
|
+
if (dateFlag) {
|
|
2188
|
+
lines.push("", "Date flags take ISO 8601 or a relative form (--" + dateFlag.flag + " -7d, -P7D, \"7 days ago\", today, now); relative values resolve against the clock.");
|
|
2189
|
+
}
|
|
2190
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
/** A text file named by a flag value (--data @body.json). */
|
|
2194
|
+
function readTextFile(flag: string, path: string): string {
|
|
2195
|
+
try {
|
|
2196
|
+
return readFileSync(path, "utf8");
|
|
2197
|
+
} catch (e) {
|
|
2198
|
+
return fail(2, "--" + flag + ": cannot read " + path + " (" + (e as Error).message + ")");
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
/** Everything on stdin as bytes, for --file -. */
|
|
2203
|
+
function readStdinBytes(): Promise<Uint8Array<ArrayBuffer>> {
|
|
2204
|
+
return new Promise((resolve) => {
|
|
2205
|
+
const chunks: Buffer[] = [];
|
|
2206
|
+
process.stdin.on("data", (chunk: Buffer) => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); });
|
|
2207
|
+
process.stdin.on("end", () => {
|
|
2208
|
+
const all = Buffer.concat(chunks);
|
|
2209
|
+
const bytes = new Uint8Array(all.byteLength);
|
|
2210
|
+
bytes.set(all);
|
|
2211
|
+
resolve(bytes);
|
|
2212
|
+
});
|
|
2213
|
+
});
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
/** A local file as an upload part; the SDK's multipart encoder takes Blobs. */
|
|
2217
|
+
function fileFromPath(flag: string, path: string): File {
|
|
2218
|
+
try {
|
|
2219
|
+
return new File([readFileSync(path)], basename(path));
|
|
2220
|
+
} catch (e) {
|
|
2221
|
+
return fail(2, "--" + flag + ": cannot read " + path + " (" + (e as Error).message + ")");
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
/** One scalar value of a flag (or of an array flag's element). */
|
|
2226
|
+
function coerceScalar(flag: string, type: "string" | "number" | "boolean" | "object" | "json", enumValues: string[] | undefined, text: string): unknown {
|
|
2227
|
+
if (type === "object") {
|
|
2228
|
+
let parsed: unknown;
|
|
2229
|
+
try { parsed = JSON.parse(text); } catch (e) { fail(2, "--" + flag + " expects JSON objects, got: " + text + " (" + (e as Error).message + ")"); }
|
|
2230
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) fail(2, "--" + flag + " expects JSON objects, got: " + text);
|
|
2231
|
+
return parsed;
|
|
2232
|
+
}
|
|
2233
|
+
if (type === "boolean") {
|
|
2234
|
+
if (text === "true") return true;
|
|
2235
|
+
if (text === "false") return false;
|
|
2236
|
+
fail(2, "--" + flag + " expects true or false, got: " + text);
|
|
2237
|
+
}
|
|
2238
|
+
if (type === "number") {
|
|
2239
|
+
const n = Number(text);
|
|
2240
|
+
if (!Number.isFinite(n) || text.trim() === "") fail(2, "--" + flag + " expects a number, got: " + text);
|
|
2241
|
+
return n;
|
|
2242
|
+
}
|
|
2243
|
+
if (type === "json") {
|
|
2244
|
+
try {
|
|
2245
|
+
return JSON.parse(text);
|
|
2246
|
+
} catch {
|
|
2247
|
+
return text; // let plain strings through for loosely-typed fields
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
if (enumValues && !enumValues.includes(text)) {
|
|
2251
|
+
fail(2, "--" + flag + " must be one of: " + enumValues.join(", ") + " (got: " + text + ")");
|
|
2252
|
+
}
|
|
2253
|
+
return text;
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
/**
|
|
2257
|
+
* A flag's raw value(s) as the typed value the SDK expects.
|
|
2258
|
+
* Arrays take the flag repeated (--tag a --tag b), a comma list (--tag a,b),
|
|
2259
|
+
* or a JSON array; a lone scalar is a one-item array, never a bare string.
|
|
2260
|
+
* Objects must be JSON. Enum values are checked locally.
|
|
2261
|
+
*/
|
|
2262
|
+
function coerce(spec: ParamSpec, raw: string | boolean, repeated?: string[]): unknown {
|
|
2263
|
+
if (spec.type === "file") {
|
|
2264
|
+
if (raw === true) fail(2, "--" + spec.flag + " expects a file path");
|
|
2265
|
+
return fileFromPath(spec.flag, String(raw));
|
|
2266
|
+
}
|
|
2267
|
+
if (spec.type === "boolean") {
|
|
2268
|
+
if (raw === true) return true;
|
|
2269
|
+
return coerceScalar(spec.flag, "boolean", undefined, String(raw));
|
|
2270
|
+
}
|
|
2271
|
+
if (raw === true) fail(2, "--" + spec.flag + " needs a value");
|
|
2272
|
+
const text = String(raw);
|
|
2273
|
+
if (spec.type === "array") {
|
|
2274
|
+
const items = spec.items ?? { type: "json" as const };
|
|
2275
|
+
const values = repeated !== undefined ? repeated : [text];
|
|
2276
|
+
const out: unknown[] = [];
|
|
2277
|
+
for (const value of values) {
|
|
2278
|
+
const trimmed = value.trim();
|
|
2279
|
+
if (trimmed.startsWith("[")) {
|
|
2280
|
+
let parsed: unknown;
|
|
2281
|
+
try { parsed = JSON.parse(trimmed); } catch (e) { fail(2, "--" + spec.flag + " looks like a JSON array but does not parse: " + (e as Error).message); }
|
|
2282
|
+
if (!Array.isArray(parsed)) fail(2, "--" + spec.flag + " expects an array");
|
|
2283
|
+
for (const element of parsed as unknown[]) {
|
|
2284
|
+
out.push(typeof element === "string" && items.type !== "json" && items.type !== "object" ? coerceScalar(spec.flag, items.type, items.enum, element) : element);
|
|
2285
|
+
}
|
|
2286
|
+
continue;
|
|
2287
|
+
}
|
|
2288
|
+
// A comma list splits for scalar elements; JSON elements (objects) keep commas.
|
|
2289
|
+
const parts = items.type === "json" || items.type === "object" || repeated !== undefined ? [value] : value.split(",").map((v) => v.trim()).filter((v) => v !== "");
|
|
2290
|
+
for (const part of parts) out.push(coerceScalar(spec.flag, items.type, items.enum, part));
|
|
2291
|
+
}
|
|
2292
|
+
return out;
|
|
2293
|
+
}
|
|
2294
|
+
if (spec.type === "object") {
|
|
2295
|
+
let parsed: unknown;
|
|
2296
|
+
try { parsed = JSON.parse(text); } catch (e) { fail(2, "--" + spec.flag + " expects a JSON object, e.g. --" + spec.flag + " '{\"key\": \"value\"}' (" + (e as Error).message + ")"); }
|
|
2297
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) fail(2, "--" + spec.flag + " expects a JSON object, got: " + text);
|
|
2298
|
+
return parsed;
|
|
2299
|
+
}
|
|
2300
|
+
if (spec.format && spec.type === "string") {
|
|
2301
|
+
// Relative dates (-7d, 7 days ago, today) resolve against the clock;
|
|
2302
|
+
// absolute values pass through for the API to judge.
|
|
2303
|
+
const resolved = relativeDate(text, spec.format);
|
|
2304
|
+
if (resolved && "error" in resolved) fail(2, "--" + spec.flag + ": " + resolved.error);
|
|
2305
|
+
if (resolved) return resolved.value;
|
|
2306
|
+
}
|
|
2307
|
+
return coerceScalar(spec.flag, spec.type === "json" ? "json" : spec.type === "number" ? "number" : "string", spec.enum, text);
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
/** Whether the last client built carried any credential; failApi tells NO_AUTH from AUTH_INVALID with it. */
|
|
2311
|
+
let LAST_CLIENT_HAD_CREDENTIAL = false;
|
|
2312
|
+
|
|
2313
|
+
/** Where a credential would come from, without sending it: "flags", "env:<VAR>", "login", or null. */
|
|
2314
|
+
function credentialSource(flags: Map<string, string | boolean>): string | null {
|
|
2315
|
+
if (AUTH_SCALARS.some((a) => typeof flags.get(a.flag) === "string")) return "flags";
|
|
2316
|
+
if (BASIC && typeof flags.get("username") === "string" && typeof flags.get("password") === "string") return "flags";
|
|
2317
|
+
const envScalar = AUTH_SCALARS.find((a) => process.env[a.env] !== undefined);
|
|
2318
|
+
if (envScalar) return "env:" + envScalar.env;
|
|
2319
|
+
if (BASIC && process.env[BASIC.envUser] !== undefined && process.env[BASIC.envPass] !== undefined) return "env:" + BASIC.envUser;
|
|
2320
|
+
const stored = readCreds();
|
|
2321
|
+
if (stored && (stored.scalars || stored.basic || stored.oauth)) return "login";
|
|
2322
|
+
return null;
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
/** Base URL resolution: --base-url > env > config base-url > config environment > spec default. */
|
|
2326
|
+
function resolveBaseUrl(flags: Map<string, string | boolean>): string | undefined {
|
|
2327
|
+
const config = readConfig();
|
|
2328
|
+
return (typeof flags.get("base-url") === "string" ? flags.get("base-url") as string : undefined)
|
|
2329
|
+
?? process.env["TYPESHIP_BASE_URL"]
|
|
2330
|
+
?? config.baseUrl
|
|
2331
|
+
?? (config.environment !== undefined ? ENVIRONMENTS[config.environment] : undefined)
|
|
2332
|
+
?? DEFAULT_BASE_URL ?? undefined;
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
async function makeClient(flags: Map<string, string | boolean>): Promise<TypeshipClient> {
|
|
2336
|
+
const stored = readCreds();
|
|
2337
|
+
const options: Record<string, unknown> = {};
|
|
2338
|
+
const baseUrl = resolveBaseUrl(flags);
|
|
2339
|
+
if (baseUrl === undefined) fail(2, "No base URL. Pass --base-url, set TYPESHIP_BASE_URL, or run '" + BIN + " config set base-url <url>'.");
|
|
2340
|
+
options.baseUrl = baseUrl;
|
|
2341
|
+
for (const a of AUTH_SCALARS) {
|
|
2342
|
+
const v = (typeof flags.get(a.flag) === "string" ? flags.get(a.flag) as string : undefined)
|
|
2343
|
+
?? process.env[a.env] ?? stored?.scalars?.[a.option];
|
|
2344
|
+
if (v !== undefined) options[a.option] = v;
|
|
2345
|
+
}
|
|
2346
|
+
if (BASIC) {
|
|
2347
|
+
const username = (typeof flags.get("username") === "string" ? flags.get("username") as string : undefined)
|
|
2348
|
+
?? process.env[BASIC.envUser] ?? stored?.basic?.username;
|
|
2349
|
+
const password = (typeof flags.get("password") === "string" ? flags.get("password") as string : undefined)
|
|
2350
|
+
?? process.env[BASIC.envPass] ?? stored?.basic?.password;
|
|
2351
|
+
if (username !== undefined && password !== undefined) options.basicAuth = { username, password };
|
|
2352
|
+
}
|
|
2353
|
+
if (options.bearerToken === undefined && stored?.oauth) {
|
|
2354
|
+
const token = await refreshedOauthToken(stored);
|
|
2355
|
+
if (token !== undefined) options.bearerToken = token;
|
|
2356
|
+
}
|
|
2357
|
+
if (flags.get("debug") === true || process.env["TYPESHIP_DEBUG"] === "1") {
|
|
2358
|
+
options.debug = (event: DebugEvent) => process.stderr.write(paintErr("dim", formatDebugEvent(BIN, event)) + "\n");
|
|
2359
|
+
}
|
|
2360
|
+
if (flags.get("validate") === true) options.validate = true;
|
|
2361
|
+
for (const g of GLOBALS) {
|
|
2362
|
+
const flagValue = flags.get(g.flag);
|
|
2363
|
+
const value = typeof flagValue === "string" ? flagValue : process.env["TYPESHIP_" + g.envSuffix];
|
|
2364
|
+
if (value !== undefined) options[g.option] = value;
|
|
2365
|
+
}
|
|
2366
|
+
LAST_CLIENT_HAD_CREDENTIAL = AUTH_SCALARS.some((a) => options[a.option] !== undefined) || options.basicAuth !== undefined || options.bearerToken !== undefined;
|
|
2367
|
+
// Who is calling: the CLI, under which agent harness, and whether an
|
|
2368
|
+
// agent is driving. "agent" means a harness was detected or the caller
|
|
2369
|
+
// said so (--mode agent / env); a bare non-TTY run (CI, a pipeline) is
|
|
2370
|
+
// "non-interactive", so usage by surface does not count CI as agents.
|
|
2371
|
+
// The API can read it back; it carries no secrets.
|
|
2372
|
+
const harness = detectHarness();
|
|
2373
|
+
const explicitAgent = flags.get("mode") === "agent" || process.env["TYPESHIP_MODE"] === "agent";
|
|
2374
|
+
const behaving = agentMode({ flagMode: flags.get("mode"), envMode: process.env["TYPESHIP_MODE"], stdoutIsTTY: process.stdout.isTTY === true, stdinIsTTY: process.stdin.isTTY === true });
|
|
2375
|
+
const caller = harness || explicitAgent ? "; agent" : behaving ? "; non-interactive" : "";
|
|
2376
|
+
options.defaultHeaders = {
|
|
2377
|
+
...(options.defaultHeaders as Record<string, string> | undefined),
|
|
2378
|
+
"User-Agent": PKG_NAME + "-cli/" + VERSION + " (typeship" + (harness ? "; harness=" + harness : "") + caller + ")",
|
|
2379
|
+
};
|
|
2380
|
+
return new TypeshipClient(options as never);
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
function editDistance(a: string, b: string): number {
|
|
2384
|
+
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
2385
|
+
for (let i = 1; i <= a.length; i++) {
|
|
2386
|
+
let diag = prev[0]!;
|
|
2387
|
+
prev[0] = i;
|
|
2388
|
+
for (let j = 1; j <= b.length; j++) {
|
|
2389
|
+
const next = Math.min(prev[j]! + 1, prev[j - 1]! + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
2390
|
+
diag = prev[j]!;
|
|
2391
|
+
prev[j] = next;
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
return prev[b.length]!;
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
/** Closest candidate within edit distance 2, for did-you-mean hints. */
|
|
2398
|
+
function didYouMean(input: string, candidates: Iterable<string>): string | undefined {
|
|
2399
|
+
let best: string | undefined;
|
|
2400
|
+
let bestDistance = 3;
|
|
2401
|
+
for (const candidate of candidates) {
|
|
2402
|
+
const d = editDistance(input.toLowerCase(), candidate.toLowerCase());
|
|
2403
|
+
if (d < bestDistance) { bestDistance = d; best = candidate; }
|
|
2404
|
+
}
|
|
2405
|
+
return best;
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
const BUILTIN_COMMANDS = ["login", "logout", "whoami", "config", "mcp", "docs", "upgrade", "feedback", "completion", "webhooks", "help", "version", "init", "agent-guide", "auth", "doctor"];
|
|
2409
|
+
|
|
2410
|
+
async function main(): Promise<void> {
|
|
2411
|
+
const argv = process.argv.slice(2);
|
|
2412
|
+
const parsed = parseArgv(argv);
|
|
2413
|
+
if (parsed.positionals[0] === "help") {
|
|
2414
|
+
// help --json: the command surface as data (agents read this once).
|
|
2415
|
+
if (parsed.flags.get("json") === true || parsed.flags.get("format") === "json") { out(helpJson()); await flushExit(0); }
|
|
2416
|
+
parsed.positionals.shift(); parsed.help = true;
|
|
2417
|
+
}
|
|
2418
|
+
if (parsed.flags.get("format") !== undefined && parsed.flags.get("format") !== "json") {
|
|
2419
|
+
fail(2, "--format json is the only format; output is always JSON.");
|
|
2420
|
+
}
|
|
2421
|
+
if (parsed.flags.has("version") || parsed.positionals[0] === "version") {
|
|
2422
|
+
// "typeship 1.0.0 (typeship 1.0.0, ...)" said the name three times; when
|
|
2423
|
+
// the API's title is the bin, name the API version as such.
|
|
2424
|
+
const apiLabel = API_TITLE.toLowerCase().replace(/[^a-z0-9]/g, "") === BIN.toLowerCase().replace(/[^a-z0-9]/g, "") ? "API " + API_VERSION : API_TITLE + " " + API_VERSION;
|
|
2425
|
+
process.stdout.write(BIN + " " + VERSION + " (" + apiLabel + ", generated by typeship)\n");
|
|
2426
|
+
await flushExit(0);
|
|
2427
|
+
}
|
|
2428
|
+
const [resourceCmd, methodCmd] = parsed.positionals;
|
|
2429
|
+
COLOR_OUT = colorEnabled(process.stdout, parsed);
|
|
2430
|
+
COLOR_ERR = colorEnabled(process.stderr, parsed);
|
|
2431
|
+
// Prose errors for a person: stderr is a terminal (or --mode human says
|
|
2432
|
+
// to behave as if), and nothing asked for JSON.
|
|
2433
|
+
const forcedHuman = parsed.flags.get("mode") === "human" || parsed.flags.get("mode") === "interactive" || process.env["TYPESHIP_MODE"] === "human";
|
|
2434
|
+
HUMAN_ERRORS = (process.stderr.isTTY === true || forcedHuman) && !isAgentMode(parsed) && parsed.flags.get("format") !== "json" && parsed.flags.get("json") !== true;
|
|
2435
|
+
await maybeUpdateNotice(resourceCmd, explicitNonInteractive(parsed));
|
|
2436
|
+
|
|
2437
|
+
if (resourceCmd === "init") { await cmdInit(parsed); }
|
|
2438
|
+
if (resourceCmd === "agent-guide") { await cmdAgentGuide(parsed); }
|
|
2439
|
+
if (resourceCmd === "auth") { await cmdAuth(parsed); }
|
|
2440
|
+
if (resourceCmd === "doctor") { await cmdDoctor(parsed); }
|
|
2441
|
+
if (resourceCmd === "upgrade") { await cmdUpgrade(parsed); }
|
|
2442
|
+
if (resourceCmd === "webhooks") { await cmdWebhooks(parsed); }
|
|
2443
|
+
if (resourceCmd === "feedback") { await cmdFeedback(parsed); }
|
|
2444
|
+
if (resourceCmd === "docs") { await cmdDocs(parsed); }
|
|
2445
|
+
if (resourceCmd === "completion") { await cmdCompletion(parsed); }
|
|
2446
|
+
if (resourceCmd === "config") { await cmdConfig(parsed); }
|
|
2447
|
+
if (resourceCmd === "mcp") { await cmdMcp(parsed); }
|
|
2448
|
+
if (resourceCmd === "login") { await cmdLogin(parsed); }
|
|
2449
|
+
if (resourceCmd === "logout") {
|
|
2450
|
+
if (parsed.help) { process.stdout.write(BIN + " logout — remove " + credsPath() + "\n"); await flushExit(0); }
|
|
2451
|
+
await cmdLogout();
|
|
2452
|
+
}
|
|
2453
|
+
if (resourceCmd === "whoami") {
|
|
2454
|
+
if (parsed.help) {
|
|
2455
|
+
process.stdout.write(BIN + " whoami — " + (WHOAMI
|
|
2456
|
+
? "call the API's identity endpoint with the resolved credentials"
|
|
2457
|
+
: "report which credentials the CLI would use (no identity endpoint in this API)") + "\n");
|
|
2458
|
+
await flushExit(0);
|
|
2459
|
+
}
|
|
2460
|
+
await cmdWhoami(parsed);
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
// Help on request goes to stdout and exits 0; help because the command
|
|
2464
|
+
// was incomplete is a usage error: stderr, exit 2, like every other one.
|
|
2465
|
+
if (!resourceCmd) { printRoot(parsed.help ? process.stdout : process.stderr); await flushExit(parsed.help ? 0 : 2); }
|
|
2466
|
+
const resourceExists = OPS.some((o) => o.command[0] === resourceCmd);
|
|
2467
|
+
if (!resourceExists) {
|
|
2468
|
+
const suggestion = didYouMean(resourceCmd, [...new Set(OPS.map((o) => o.command[0])), ...BUILTIN_COMMANDS]);
|
|
2469
|
+
fail(2, "Unknown command: " + resourceCmd + "." + (suggestion ? " Did you mean '" + BIN + " " + suggestion + "'?" : ""),
|
|
2470
|
+
undefined, ["Run '" + BIN + " --help' for the commands."]);
|
|
2471
|
+
}
|
|
2472
|
+
if (!methodCmd) { printResource(resourceCmd, parsed.help ? process.stdout : process.stderr); await flushExit(parsed.help ? 0 : 2); }
|
|
2473
|
+
|
|
2474
|
+
const op = findOp(resourceCmd, methodCmd);
|
|
2475
|
+
if (!op) {
|
|
2476
|
+
const suggestion = didYouMean(methodCmd, OPS.filter((o) => o.command[0] === resourceCmd).map((o) => o.command[1]));
|
|
2477
|
+
fail(2, "Unknown command: " + resourceCmd + " " + methodCmd + "." + (suggestion ? " Did you mean '" + BIN + " " + resourceCmd + " " + suggestion + "'?" : ""),
|
|
2478
|
+
undefined, ["Run '" + BIN + " " + resourceCmd + "' for its commands."]);
|
|
2479
|
+
}
|
|
2480
|
+
if (parsed.help) { printOp(op); await flushExit(0); }
|
|
2481
|
+
USAGE_HINT = BIN + " " + op.command[0] + " " + op.command[1] + " --help";
|
|
2482
|
+
|
|
2483
|
+
const pathSpecs = op.params.filter((p) => p.kind === "path");
|
|
2484
|
+
const pathValues = parsed.positionals.slice(2);
|
|
2485
|
+
if (pathValues.length !== pathSpecs.length) {
|
|
2486
|
+
fail(2, "Expected " + pathSpecs.length + " argument(s): " + usageLine(op));
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
const values: Record<string, unknown> = {};
|
|
2490
|
+
pathSpecs.forEach((spec, i) => { values[spec.name] = pathValues[i]; });
|
|
2491
|
+
|
|
2492
|
+
let dataBody: unknown;
|
|
2493
|
+
const dataRaw = parsed.flags.get("data");
|
|
2494
|
+
if (dataRaw === true) fail(2, "--data expects a JSON value, @<file>, or - for stdin");
|
|
2495
|
+
if (typeof dataRaw === "string") {
|
|
2496
|
+
// curl conventions: @path reads a file, - reads stdin, anything else is the literal.
|
|
2497
|
+
const text = dataRaw === "-" ? await readStdin()
|
|
2498
|
+
: dataRaw.startsWith("@") ? readTextFile("data", dataRaw.slice(1))
|
|
2499
|
+
: dataRaw;
|
|
2500
|
+
try { dataBody = JSON.parse(text); } catch (e) { fail(2, "--data is not valid JSON" + (dataRaw === "-" ? " (stdin)" : dataRaw.startsWith("@") ? " (" + dataRaw.slice(1) + ")" : "") + ": " + (e as Error).message); }
|
|
2501
|
+
}
|
|
2502
|
+
// A binary body (an upload that isn't a form) comes from --file (- reads stdin).
|
|
2503
|
+
const fileRaw = parsed.flags.get("file");
|
|
2504
|
+
if (op.bodyKind === "binary" && typeof fileRaw === "string") {
|
|
2505
|
+
dataBody = fileRaw === "-" ? new File([await readStdinBytes()], "stdin") : fileFromPath("file", fileRaw);
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
// Mirrors opReservedFlags() in the generator: API parameters never use these
|
|
2509
|
+
// names (colliding ones are emitted as --<kind>-<name>), so an unknown flag
|
|
2510
|
+
// check can be exact.
|
|
2511
|
+
const RESERVED_FLAGS = new Set(["data", "all", "select", "base-url", "debug", "validate", "non-interactive", "color", "version", "help", "yes", "force", "mode", "format", "json", "out", "fields", ...AUTH_SCALARS.map((a) => a.flag), ...(BASIC ? ["username", "password"] : []), ...GLOBALS.map((g) => g.flag)]);
|
|
2512
|
+
for (const spec of op.params) {
|
|
2513
|
+
if (spec.kind === "path") continue;
|
|
2514
|
+
const raw = parsed.flags.get(spec.flag);
|
|
2515
|
+
if (raw === undefined) continue;
|
|
2516
|
+
// A flag given more than once is an array: array-typed params collect
|
|
2517
|
+
// every value (each coerced to the element type); loosely typed (json)
|
|
2518
|
+
// params become an array of the parsed values.
|
|
2519
|
+
const all = parsed.repeated.get(spec.flag);
|
|
2520
|
+
values[spec.name] = spec.type === "array"
|
|
2521
|
+
? coerce(spec, raw, all)
|
|
2522
|
+
: all !== undefined && spec.type === "json"
|
|
2523
|
+
? all.map((v) => coerce(spec, v))
|
|
2524
|
+
: coerce(spec, raw);
|
|
2525
|
+
}
|
|
2526
|
+
for (const key of parsed.flags.keys()) {
|
|
2527
|
+
if (RESERVED_FLAGS.has(key)) continue;
|
|
2528
|
+
if (key === "file" && op.bodyKind === "binary") continue;
|
|
2529
|
+
if (!op.params.some((p) => p.flag === key)) {
|
|
2530
|
+
const suggestion = didYouMean(key, [...op.params.filter((p) => p.kind !== "path").map((p) => p.flag), ...RESERVED_FLAGS]);
|
|
2531
|
+
fail(2, "Unknown flag --" + key + "." + (suggestion ? " Did you mean --" + suggestion + "?" : ""));
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
const missing = missingRequired(op, values).filter((name) =>
|
|
2536
|
+
!(op.bodyStyle === "fields" && dataBody !== undefined && typeof dataBody === "object" && dataBody !== null && name in (dataBody as object)),
|
|
2537
|
+
);
|
|
2538
|
+
if (missing.length > 0) fail(2, "Missing required: " + missing.map((m) => "--" + (op.params.find((p) => p.name === m)?.flag ?? m)).join(", "));
|
|
2539
|
+
// A body that isn't a plain object (array, string, union) has no field
|
|
2540
|
+
// flags; when the spec requires it, --data is the only way to send it.
|
|
2541
|
+
const rawBodyRequired = op.bodyStyle === "data" && ((op.inputSchema.required as string[] | undefined) ?? []).includes("body");
|
|
2542
|
+
if (rawBodyRequired && dataBody === undefined) {
|
|
2543
|
+
fail(2, op.bodyKind === "binary"
|
|
2544
|
+
? "Missing required: --file <path> (this operation's request body is required)"
|
|
2545
|
+
: "Missing required: --data '<json>' (this operation's request body is required)");
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
// --fields id,name,owner.email keeps only those paths of the result (per item for lists).
|
|
2549
|
+
const fieldsRaw = parsed.flags.get("fields");
|
|
2550
|
+
if (fieldsRaw === true) fail(2, "--fields expects a comma-separated list of field paths, e.g. --fields id,name");
|
|
2551
|
+
if (typeof fieldsRaw === "string") {
|
|
2552
|
+
FIELDS = fieldsRaw.split(",").map((f) => f.trim()).filter((f) => f !== "").map((f) => f.split("."));
|
|
2553
|
+
if (FIELDS.length === 0) fail(2, "--fields expects at least one field path");
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
// --out <dir> materializes a file-shaped response (see cli-agent.ts bundleProperty).
|
|
2557
|
+
const bundleField = bundleProperty(op.outputSchema);
|
|
2558
|
+
const collectionField = collectionProperty(op.outputSchema);
|
|
2559
|
+
const outDir = typeof parsed.flags.get("out") === "string" ? (parsed.flags.get("out") as string) : undefined;
|
|
2560
|
+
if (outDir !== undefined && bundleField === null) {
|
|
2561
|
+
fail(2, "--out applies to commands whose response carries files ({path, content}); " + op.command.join(" ") + " does not.");
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
// The spec says this operation needs a credential and none resolved:
|
|
2565
|
+
// say so now, locally, instead of sending a request to learn it.
|
|
2566
|
+
if (op.auth === "required" && (AUTH_SCALARS.length > 0 || BASIC || OAUTH_TOKEN_URL) && credentialSource(parsed.flags) === null) {
|
|
2567
|
+
failWith({
|
|
2568
|
+
status: "action_required",
|
|
2569
|
+
code: "NO_AUTH",
|
|
2570
|
+
message: op.command.join(" ") + " needs a credential (" + wireOf(op) + " is authenticated) and none was found.",
|
|
2571
|
+
nextSteps: [
|
|
2572
|
+
...AUTH_SCALARS.map((a) => "Set " + a.env + " in the environment, pass --" + a.flag + " <value>, or run '" + BIN + " login'."),
|
|
2573
|
+
...(BASIC ? ["Set " + BASIC.envUser + " and " + BASIC.envPass + ", or pass --username and --password."] : []),
|
|
2574
|
+
...(AUTH_SCALARS.length === 0 && !BASIC ? ["Run '" + BIN + " login'."] : []),
|
|
2575
|
+
"'" + BIN + " auth check' shows what the CLI would send.",
|
|
2576
|
+
],
|
|
2577
|
+
});
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
// Destructive commands need --force. A person gets asked; an agent gets
|
|
2581
|
+
// an action_required envelope with the exact command to run, so nothing
|
|
2582
|
+
// is deleted on a guess.
|
|
2583
|
+
if (op.safety === "destructive" && !assumeYes(parsed)) {
|
|
2584
|
+
const rerun = BIN + " " + process.argv.slice(2).map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(" ") + " --force";
|
|
2585
|
+
if (nonInteractive(parsed) || !process.stdin.isTTY) {
|
|
2586
|
+
failWith({
|
|
2587
|
+
status: "action_required",
|
|
2588
|
+
code: "CONFIRMATION_REQUIRED",
|
|
2589
|
+
message: op.command.join(" ") + " is destructive (" + op.httpMethod + " " + op.path + ") and needs --force.",
|
|
2590
|
+
nextSteps: ["Confirm with the user, then run: " + rerun],
|
|
2591
|
+
});
|
|
2592
|
+
}
|
|
2593
|
+
process.stderr.write(paintErr("yellow", op.command.join(" ") + " will " + op.httpMethod + " " + op.path + ". Continue? [y/N] "));
|
|
2594
|
+
const answer = (await readLine()).trim().toLowerCase();
|
|
2595
|
+
if (answer !== "y" && answer !== "yes") failWith({ status: "action_required", code: "CONFIRMATION_REQUIRED", message: "Cancelled.", nextSteps: ["Run again with --force to skip the prompt: " + rerun] });
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
const client = await makeClient(parsed.flags);
|
|
2599
|
+
const selectValue = typeof parsed.flags.get("select") === "string" ? (parsed.flags.get("select") as string) : undefined;
|
|
2600
|
+
const args = buildArgs(op, values, dataBody, selectValue);
|
|
2601
|
+
const target = (client as unknown as Record<string, Record<string, (...a: unknown[]) => unknown>>)[op.resource]!;
|
|
2602
|
+
const callResult = target[op.method]!(...args);
|
|
2603
|
+
|
|
2604
|
+
if (op.paginated && parsed.flags.get("all") === true) {
|
|
2605
|
+
try {
|
|
2606
|
+
for await (const item of callResult as AsyncIterable<unknown>) {
|
|
2607
|
+
process.stdout.write(JSON.stringify(project(item)) + "\n");
|
|
2608
|
+
}
|
|
2609
|
+
await flushExit(0);
|
|
2610
|
+
} catch (e) {
|
|
2611
|
+
failApi(e, LAST_CLIENT_HAD_CREDENTIAL);
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
const result = await (callResult as Promise<{ ok: boolean; data?: unknown; error?: unknown }>);
|
|
2616
|
+
if (result.ok) {
|
|
2617
|
+
if (op.sse) {
|
|
2618
|
+
// Server-sent events as NDJSON, one line per event, until the stream ends.
|
|
2619
|
+
try {
|
|
2620
|
+
for await (const event of result.data as AsyncIterable<{ event?: string; id?: string; data: string }>) {
|
|
2621
|
+
process.stdout.write(JSON.stringify(project(event)) + "\n");
|
|
2622
|
+
}
|
|
2623
|
+
} catch (e) {
|
|
2624
|
+
failApi(e, LAST_CLIENT_HAD_CREDENTIAL);
|
|
2625
|
+
}
|
|
2626
|
+
await flushExit(0);
|
|
2627
|
+
}
|
|
2628
|
+
// A claim-shaped response (claim.url): say where to claim it, and leave
|
|
2629
|
+
// a breadcrumb so a later session or doctor can list what is unclaimed.
|
|
2630
|
+
if (claimProperty(op.outputSchema)) {
|
|
2631
|
+
const claim = (result.data as { claim?: { url?: string; expires_at?: string } | null } | undefined)?.claim;
|
|
2632
|
+
if (claim && typeof claim.url === "string") {
|
|
2633
|
+
const file = recordClaim(process.cwd(), BIN, { url: claim.url, ...(claim.expires_at ? { expires_at: claim.expires_at } : {}), command: BIN + " " + process.argv.slice(2).join(" "), created_at: new Date().toISOString() });
|
|
2634
|
+
process.stderr.write(paintErr("dim", "Unclaimed: a signed-in person can turn this into a project at " + claim.url + (claim.expires_at ? " (until " + claim.expires_at + ")" : "") + ". Noted in " + file + ".") + "\n");
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
if (op.paginated) {
|
|
2638
|
+
// One page, plus what fetches the next: the raw arguments (the MCP
|
|
2639
|
+
// tool's nextPage shape) and the exact command, so a script or an
|
|
2640
|
+
// agent never has to reconstruct the cursor flag.
|
|
2641
|
+
const page = result.data as { items: unknown[]; hasNextPage(): boolean; nextPageParams(): Record<string, unknown> | null };
|
|
2642
|
+
const next = page.nextPageParams();
|
|
2643
|
+
out({
|
|
2644
|
+
items: project(page.items),
|
|
2645
|
+
hasMore: next !== null,
|
|
2646
|
+
...(next !== null ? { nextPage: next, nextCommand: nextCommandFor(op, pathValues, next) } : {}),
|
|
2647
|
+
});
|
|
2648
|
+
} else if (FIELDS !== null && collectionField !== null && result.data !== null && typeof result.data === "object" && !Array.isArray(result.data) && Array.isArray((result.data as Record<string, unknown>)[collectionField])) {
|
|
2649
|
+
// Batch-style collection envelopes ({data: [...]}) use item-relative
|
|
2650
|
+
// fields, matching paginated results, while retaining envelope metadata.
|
|
2651
|
+
const data = result.data as Record<string, unknown>;
|
|
2652
|
+
out({ ...data, [collectionField]: project(data[collectionField]) });
|
|
2653
|
+
} else if (outDir !== undefined && bundleField !== null) {
|
|
2654
|
+
// --out: a file-shaped response (an array of {path, content}) lands
|
|
2655
|
+
// on disk; stdout gets the rest of the response plus a summary.
|
|
2656
|
+
const data = (result.data ?? {}) as Record<string, unknown>;
|
|
2657
|
+
const files = (data[bundleField] as { path: string; content: string }[] | undefined) ?? [];
|
|
2658
|
+
const written = writeBundle(outDir, files);
|
|
2659
|
+
const { [bundleField]: _omitted, ...rest } = data;
|
|
2660
|
+
out({ ...(project(rest) as Record<string, unknown>), out: written });
|
|
2661
|
+
} else {
|
|
2662
|
+
out(project(result.data ?? { ok: true }));
|
|
2663
|
+
}
|
|
2664
|
+
await flushExit(0);
|
|
2665
|
+
}
|
|
2666
|
+
failApi(result.error, LAST_CLIENT_HAD_CREDENTIAL);
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
function errorMessage(error: unknown): string {
|
|
2670
|
+
const base = error instanceof Error ? error.name + ": " + error.message : String(error);
|
|
2671
|
+
if (error instanceof Error && error.name === "TransportError") {
|
|
2672
|
+
return base + " — check the base URL (--base-url, TYPESHIP_BASE_URL, or '" + BIN + " config base-url') and your network";
|
|
2673
|
+
}
|
|
2674
|
+
return base;
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
function serializeError(error: unknown): unknown {
|
|
2678
|
+
if (error && typeof error === "object" && "violations" in error) {
|
|
2679
|
+
return { violations: (error as { violations: unknown }).violations };
|
|
2680
|
+
}
|
|
2681
|
+
if (error && typeof error === "object" && "status" in error) {
|
|
2682
|
+
const e = error as { status: number; body?: unknown };
|
|
2683
|
+
return { status: e.status, body: e.body };
|
|
2684
|
+
}
|
|
2685
|
+
return undefined;
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
main().catch((e) => {
|
|
2689
|
+
if (e instanceof ExitPending) return;
|
|
2690
|
+
try {
|
|
2691
|
+
fail(1, errorMessage(e));
|
|
2692
|
+
} catch {
|
|
2693
|
+
// exit already scheduled
|
|
2694
|
+
}
|
|
2695
|
+
});
|