@typeship-ax/cli 0.22.0 → 0.24.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/AGENTS.md +13 -9
- package/README.md +14 -27
- package/api.json +8898 -9026
- package/api.md +370 -328
- package/dist/arguments.d.ts +54 -0
- package/dist/arguments.d.ts.map +1 -0
- package/dist/arguments.js +265 -0
- package/dist/cli-agent.d.ts +73 -9
- package/dist/cli-agent.d.ts.map +1 -1
- package/dist/cli-agent.js +331 -44
- package/dist/cli.js +802 -291
- package/dist/core/http.d.ts +162 -19
- package/dist/core/http.d.ts.map +1 -1
- package/dist/core/http.js +381 -48
- package/dist/core/pagination.d.ts +42 -6
- package/dist/core/pagination.d.ts.map +1 -1
- package/dist/core/pagination.js +111 -17
- package/dist/credential-storage.d.ts +10 -3
- package/dist/credential-storage.d.ts.map +1 -1
- package/dist/credential-storage.js +15 -6
- package/dist/dates.d.ts +1 -1
- package/dist/dates.js +1 -1
- package/dist/errors.d.ts +20 -84
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +20 -108
- package/dist/fields.d.ts +36 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +187 -0
- package/dist/index.d.ts +28 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -25
- package/dist/named-credentials.d.ts +19 -0
- package/dist/named-credentials.d.ts.map +1 -1
- package/dist/named-credentials.js +81 -1
- package/dist/oauth-login.d.ts +8 -2
- package/dist/oauth-login.d.ts.map +1 -1
- package/dist/oauth-login.js +31 -19
- package/dist/oauth-request.d.ts +7 -1
- package/dist/oauth-request.d.ts.map +1 -1
- package/dist/oauth-request.js +26 -4
- package/dist/oauth-session.d.ts +13 -1
- package/dist/oauth-session.d.ts.map +1 -1
- package/dist/oauth-session.js +34 -18
- package/dist/ops.d.ts +58 -5
- package/dist/ops.d.ts.map +1 -1
- package/dist/ops.js +110 -41
- package/dist/polling-login.d.ts +8 -2
- package/dist/polling-login.d.ts.map +1 -1
- package/dist/polling-login.js +25 -11
- package/dist/resources/api-keys.d.ts +10 -7
- package/dist/resources/api-keys.d.ts.map +1 -1
- package/dist/resources/api-keys.js +10 -31
- package/dist/resources/deliveries.d.ts +89 -5
- package/dist/resources/deliveries.d.ts.map +1 -1
- package/dist/resources/deliveries.js +96 -19
- package/dist/resources/drafts.d.ts +16 -16
- package/dist/resources/drafts.d.ts.map +1 -1
- package/dist/resources/drafts.js +12 -65
- package/dist/resources/files.d.ts +4 -4
- package/dist/resources/files.d.ts.map +1 -1
- package/dist/resources/files.js +3 -12
- package/dist/resources/generations.d.ts +16 -16
- package/dist/resources/generations.d.ts.map +1 -1
- package/dist/resources/generations.js +23 -47
- package/dist/resources/organization.d.ts +4 -4
- package/dist/resources/organization.d.ts.map +1 -1
- package/dist/resources/organization.js +3 -10
- package/dist/resources/{generate.d.ts → packages.d.ts} +16 -16
- package/dist/resources/packages.d.ts.map +1 -0
- package/dist/resources/{generate.js → packages.js} +13 -29
- package/dist/resources/projects.d.ts +50 -50
- package/dist/resources/projects.d.ts.map +1 -1
- package/dist/resources/projects.js +60 -116
- package/dist/resources/releases.d.ts +22 -17
- package/dist/resources/releases.d.ts.map +1 -1
- package/dist/resources/releases.js +19 -40
- package/dist/resources/spec-revisions.d.ts +16 -7
- package/dist/resources/spec-revisions.d.ts.map +1 -1
- package/dist/resources/spec-revisions.js +7 -29
- package/dist/resources/specs.d.ts +7 -7
- package/dist/resources/specs.d.ts.map +1 -1
- package/dist/resources/specs.js +6 -34
- package/dist/resources/targets.d.ts +49 -49
- package/dist/resources/targets.d.ts.map +1 -1
- package/dist/resources/targets.js +59 -115
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +83 -81
- package/dist/search.d.ts +54 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +421 -0
- package/dist/table.d.ts +28 -0
- package/dist/table.d.ts.map +1 -0
- package/dist/table.js +167 -0
- package/dist/type-docs.d.ts +61 -0
- package/dist/type-docs.d.ts.map +1 -0
- package/dist/type-docs.js +174 -0
- package/dist/types.d.ts +499 -339
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +18 -18
- package/package.json +5 -2
- package/src/arguments.ts +254 -0
- package/src/cli-agent.ts +351 -46
- package/src/cli.ts +753 -266
- package/src/core/http.ts +457 -58
- package/src/core/pagination.ts +129 -18
- package/src/credential-storage.ts +16 -6
- package/src/dates.ts +1 -1
- package/src/errors.ts +46 -115
- package/src/fields.ts +167 -0
- package/src/index.ts +45 -28
- package/src/named-credentials.ts +66 -1
- package/src/oauth-login.ts +36 -21
- package/src/oauth-request.ts +32 -6
- package/src/oauth-session.ts +37 -19
- package/src/ops.ts +146 -45
- package/src/polling-login.ts +24 -11
- package/src/resources/api-keys.ts +34 -48
- package/src/resources/deliveries.ts +213 -32
- package/src/resources/drafts.ts +62 -109
- package/src/resources/files.ts +19 -20
- package/src/resources/generations.ts +61 -79
- package/src/resources/organization.ts +11 -16
- package/src/resources/{generate.ts → packages.ts} +43 -51
- package/src/resources/projects.ts +145 -200
- package/src/resources/releases.ts +50 -67
- package/src/resources/spec-revisions.ts +40 -49
- package/src/resources/specs.ts +39 -59
- package/src/resources/targets.ts +144 -194
- package/src/schemas.ts +83 -81
- package/src/search.ts +434 -0
- package/src/table.ts +167 -0
- package/src/type-docs.ts +205 -0
- package/src/types.ts +538 -357
- package/dist/console-login-check.d.ts +0 -21
- package/dist/console-login-check.d.ts.map +0 -1
- package/dist/console-login-check.js +0 -107
- package/dist/console-login-contract.d.ts +0 -45
- package/dist/console-login-contract.d.ts.map +0 -1
- package/dist/console-login-contract.js +0 -40
- package/dist/resources/generate.d.ts.map +0 -1
- package/dist/resources/publications.d.ts +0 -47
- package/dist/resources/publications.d.ts.map +0 -1
- package/dist/resources/publications.js +0 -70
- package/src/console-login-check.ts +0 -88
- package/src/console-login-contract.ts +0 -65
- package/src/resources/publications.ts +0 -140
package/src/search.ts
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operation search shared by the generated CLI (`docs search`) and MCP
|
|
3
|
+
* server (`search_docs`). Generated by Typeship — https://typeship.dev
|
|
4
|
+
*
|
|
5
|
+
* Agents describe a task ("assign issue", "list calls"), not an operation
|
|
6
|
+
* name, so ranking works on stems, weighs rare words over common ones
|
|
7
|
+
* (IDF), and reads the verb of the query against what each operation does:
|
|
8
|
+
* its HTTP method and whether its path names one item or a collection, or
|
|
9
|
+
* its GraphQL root field. GraphQL mutations are also indexed by their input
|
|
10
|
+
* fields, so "assign issue" finds issueUpdate through assigneeId.
|
|
11
|
+
* Deterministic and dependency-free.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The parts of an operation search reads. The CLI and MCP operation
|
|
15
|
+
* specs both satisfy it. */
|
|
16
|
+
export interface SearchableOperation {
|
|
17
|
+
tool: string;
|
|
18
|
+
resource?: string;
|
|
19
|
+
method?: string;
|
|
20
|
+
httpMethod: string;
|
|
21
|
+
path: string;
|
|
22
|
+
summary?: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
paginated?: boolean;
|
|
25
|
+
deprecated?: boolean;
|
|
26
|
+
graphql?: { kind: string; field?: string };
|
|
27
|
+
params: readonly { name: string; enum?: readonly string[]; description?: string }[];
|
|
28
|
+
inputSchema?: Record<string, unknown>;
|
|
29
|
+
outputSchema?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Reference matches per search page. */
|
|
33
|
+
export const SEARCH_PAGE_SIZE = 10;
|
|
34
|
+
|
|
35
|
+
type Action = "list" | "get" | "create" | "update" | "delete" | "search";
|
|
36
|
+
|
|
37
|
+
/** Query verbs and the operation kinds they ask for, best first. */
|
|
38
|
+
const VERBS: Record<string, Action[]> = {
|
|
39
|
+
list: ["list"], all: ["list"], browse: ["list"], enumerate: ["list"],
|
|
40
|
+
search: ["search", "list"], find: ["search", "list", "get"], query: ["search", "list"], lookup: ["get", "search"],
|
|
41
|
+
get: ["get", "list"], release: ["delete"], fetch: ["get", "list"], retrieve: ["get", "list"], read: ["get", "list"], show: ["get", "list"],
|
|
42
|
+
view: ["get", "list"], check: ["get"], describe: ["get"], inspect: ["get"], status: ["get"],
|
|
43
|
+
create: ["create"], add: ["create", "update"], new: ["create"], make: ["create"], send: ["create"], post: ["create"],
|
|
44
|
+
submit: ["create"], open: ["create"], start: ["create", "update"], upload: ["create", "update"], generate: ["create"],
|
|
45
|
+
buy: ["create"], purchase: ["create"], place: ["create"], invite: ["create"], write: ["create", "update"],
|
|
46
|
+
publish: ["create", "update"], register: ["create"],
|
|
47
|
+
update: ["update"], change: ["update"], edit: ["update"], modify: ["update"], set: ["update", "create"],
|
|
48
|
+
assign: ["update", "create"], rename: ["update"], move: ["update"], close: ["update", "delete"], reopen: ["update"],
|
|
49
|
+
mark: ["update"], archive: ["delete", "update"], unarchive: ["update"], enable: ["update"], disable: ["update"],
|
|
50
|
+
pause: ["update"], resume: ["update"], replace: ["update"], patch: ["update"], merge: ["update", "create"],
|
|
51
|
+
save: ["create", "update"], follow: ["create", "update"], unfollow: ["delete", "update"], comment: ["create"], reply: ["create"], request: ["create"], hang: ["update", "delete"],
|
|
52
|
+
delete: ["delete"], remove: ["delete", "update"], destroy: ["delete"], erase: ["delete"], drop: ["delete"],
|
|
53
|
+
revoke: ["delete"], cancel: ["delete", "update"], unassign: ["update", "delete"], clear: ["delete"],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Words that name the caller: "who am I", "my issues", "me". */
|
|
57
|
+
const IDENTITY_WORDS = new Set(["who", "whoami", "me", "my", "mine", "myself", "self"]);
|
|
58
|
+
const IDENTITY_TERMS = ["me", "self", "whoami", "authenticated"];
|
|
59
|
+
|
|
60
|
+
/** An operation about the caller: GET /user, /me/…, a viewer or me root
|
|
61
|
+
* field, users_get_authenticated. */
|
|
62
|
+
function isIdentityOperation(op: SearchableOperation): boolean {
|
|
63
|
+
if (op.graphql?.field) return /^(viewer|me|currentUser|whoami)$/.test(op.graphql.field);
|
|
64
|
+
const nameWords = words([op.tool, op.method ?? ""].join(" "));
|
|
65
|
+
if (nameWords.some((w) => w === "me" || w === "whoami" || w === "authenticated" || w === "self")) return true;
|
|
66
|
+
return /^(\/v\d+)?\/(me|user|self|whoami)(\/|$)/i.test(op.path) || /\/(users\/)?me(\/|$)/i.test(op.path);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Words that narrow a request rather than name the operation. */
|
|
70
|
+
const QUALIFIERS = new Set(["yesterday", "today", "tomorrow", "recent", "recently", "latest", "last", "week", "month", "year", "daily"]);
|
|
71
|
+
|
|
72
|
+
/** A few everyday words for what APIs call something else. */
|
|
73
|
+
const SYNONYMS: Record<string, string[]> = {
|
|
74
|
+
now: ["current"], sms: ["message"], mms: ["message"], text: ["message"], document: ["file"], kyc: ["identity", "verification"],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const STOPWORDS = new Set([
|
|
78
|
+
"a", "an", "the", "to", "of", "for", "in", "on", "at", "from", "by", "with", "and", "or", "is", "are", "am", "be",
|
|
79
|
+
"do", "does", "it", "its", "this", "that", "these", "those", "into", "onto", "up", "how", "what", "which", "can",
|
|
80
|
+
"please", "via", "using", "some", "any", "there", "i", "you", "your", "we", "our",
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
/** Field weights: a word in the operation's name says most about it. */
|
|
84
|
+
const W_NAME = 10, W_SUMMARY = 6, W_PATH = 4, W_PARAM = 3, W_ENUM = 2, W_DESCRIPTION = 1.5, W_OUTPUT = 1;
|
|
85
|
+
|
|
86
|
+
/** A light English stemmer: plurals, -ing/-ed, a few derivational
|
|
87
|
+
* suffixes, a trailing e. It only has to map a word and its common
|
|
88
|
+
* variants to one stem ("assignee", "assigned", "assigns" to "assign";
|
|
89
|
+
* "moderate", "moderations" to "moder"). */
|
|
90
|
+
export function stem(word: string): string {
|
|
91
|
+
let w = word.toLowerCase();
|
|
92
|
+
if (w.length <= 3) return w;
|
|
93
|
+
if (w.endsWith("ies") && w.length > 4) w = w.slice(0, -3) + "y";
|
|
94
|
+
else if (/(sses|xes|ches|shes|zes)$/.test(w)) w = w.slice(0, -2);
|
|
95
|
+
else if (w.endsWith("s") && !/(ss|us|is)$/.test(w)) w = w.slice(0, -1);
|
|
96
|
+
let stripped = false;
|
|
97
|
+
if (w.endsWith("ly") && w.length >= 6) w = w.slice(0, -2);
|
|
98
|
+
for (const [suffix, min] of [["ing", 4], ["ed", 3]] as const) {
|
|
99
|
+
if (w.endsWith(suffix) && w.length - suffix.length >= min) {
|
|
100
|
+
w = w.slice(0, -suffix.length);
|
|
101
|
+
stripped = true;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (stripped) {
|
|
106
|
+
if (/(at|bl|iz)$/.test(w)) w += "e";
|
|
107
|
+
else if (/([^aeioulsz])\1$/.test(w)) w = w.slice(0, -1);
|
|
108
|
+
}
|
|
109
|
+
for (const [suffix, min] of [["ations", 4], ["ation", 4], ["ments", 4], ["ment", 4], ["ption", 4], ["ions", 4], ["ion", 4], ["ers", 4], ["er", 4], ["ee", 4], ["ate", 4]] as const) {
|
|
110
|
+
if (w.endsWith(suffix) && w.length - suffix.length >= min) {
|
|
111
|
+
w = w.slice(0, -suffix.length);
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (w.endsWith("e") && w.length >= 5) w = w.slice(0, -1);
|
|
116
|
+
return w;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Lowercase words of two or more characters, with snake, kebab, camel and
|
|
120
|
+
* letter-digit seams split so "createAccount" and "accounts_create" share
|
|
121
|
+
* words. */
|
|
122
|
+
function words(text: string): string[] {
|
|
123
|
+
return text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([a-zA-Z])([0-9])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length >= 2);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function stems(text: string): string[] {
|
|
127
|
+
return words(text).filter((w) => !STOPWORDS.has(w)).map(stem);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface IndexedOperation {
|
|
131
|
+
/** stem -> best field weight, and the sum of the others. */
|
|
132
|
+
weights: Map<string, { best: number; rest: number }>;
|
|
133
|
+
/** Distinct stems of the operation's name, less its action word. */
|
|
134
|
+
nameStems: string[];
|
|
135
|
+
action: Action;
|
|
136
|
+
identity: boolean;
|
|
137
|
+
/** The summary's stems, for a query that is the summary. */
|
|
138
|
+
summary: string;
|
|
139
|
+
/** The stem of the name's verb ("add" in issue_add_label), if any. */
|
|
140
|
+
actionStem: string | null;
|
|
141
|
+
deprecated: boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface SearchIndex {
|
|
145
|
+
docs: IndexedOperation[];
|
|
146
|
+
/** Document frequency of each stem. */
|
|
147
|
+
df: Map<string, number>;
|
|
148
|
+
vocabulary: string[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// A server keeps one operation list, and a hosted one filters the same
|
|
152
|
+
// operation objects per request: cache by both.
|
|
153
|
+
const INDEXES = new WeakMap<readonly SearchableOperation[], SearchIndex>();
|
|
154
|
+
const INDEXED = new WeakMap<SearchableOperation, IndexedOperation>();
|
|
155
|
+
|
|
156
|
+
const READ_ACTIONS: readonly Action[] = ["list", "get", "search"];
|
|
157
|
+
const WRITE_ACTIONS: readonly Action[] = ["create", "update", "delete"];
|
|
158
|
+
|
|
159
|
+
/** The first of `words` (in order) that names an allowed action. */
|
|
160
|
+
function actionIn(candidates: string[], allowed: readonly Action[]): { action: Action; word: string } | undefined {
|
|
161
|
+
for (const word of candidates) {
|
|
162
|
+
const action = VERBS[word]?.[0];
|
|
163
|
+
if (action && allowed.includes(action)) return { action, word };
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** What an operation does, and the word of its name that says so: its
|
|
169
|
+
* name's verb when it has one, else its HTTP method and whether the path
|
|
170
|
+
* ends at one item or a collection. A read never creates, updates or
|
|
171
|
+
* deletes, whatever its name ("query comment" is a read of a comment). */
|
|
172
|
+
function operationAction(op: SearchableOperation): { action: Action; word: string | null } {
|
|
173
|
+
const collection = (): Action => {
|
|
174
|
+
const segments = op.path.replace(/\.[a-z]+$/i, "").split("/").filter(Boolean);
|
|
175
|
+
const last = segments.at(-1) ?? "";
|
|
176
|
+
if (last.startsWith("{") || last.startsWith(":")) return "get";
|
|
177
|
+
return op.paginated ? "list" : "get";
|
|
178
|
+
};
|
|
179
|
+
if (op.graphql?.field) {
|
|
180
|
+
// issueUpdate, createIssue: the verb may sit at either end.
|
|
181
|
+
const fieldWords = words(op.graphql.field);
|
|
182
|
+
const read = op.graphql.kind !== "mutation";
|
|
183
|
+
const found = actionIn([...fieldWords].reverse(), read ? READ_ACTIONS : WRITE_ACTIONS);
|
|
184
|
+
if (found) return found;
|
|
185
|
+
return { action: read ? (op.paginated || /s$/.test(op.graphql.field) ? "list" : "get") : "update", word: null };
|
|
186
|
+
}
|
|
187
|
+
const verb = op.httpMethod.toUpperCase();
|
|
188
|
+
const read = verb === "GET" || verb === "HEAD";
|
|
189
|
+
const found = actionIn(words(op.method ?? ""), read ? READ_ACTIONS : [...WRITE_ACTIONS, "search"]);
|
|
190
|
+
if (found) return found;
|
|
191
|
+
if (verb === "DELETE") return { action: "delete", word: null };
|
|
192
|
+
if (verb === "PUT" || verb === "PATCH") return { action: "update", word: null };
|
|
193
|
+
if (verb === "POST") return { action: "create", word: null };
|
|
194
|
+
return { action: collection(), word: null };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Literal path words: no parameters, versions or date segments. */
|
|
198
|
+
function pathWords(path: string): string {
|
|
199
|
+
return path.replace(/\.[a-z]+$/i, "").split("/").filter((s) => s && !/^[{:]/.test(s) && !/^v\d+$/i.test(s) && !/^\d/.test(s)).join(" ");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Property names and enum values of a schema, one level into objects,
|
|
203
|
+
* and one more for object-typed properties (a GraphQL input object). */
|
|
204
|
+
function schemaWords(schema: unknown, depth: number, names: string[], enums: string[]): void {
|
|
205
|
+
if (!schema || typeof schema !== "object" || depth < 0) return;
|
|
206
|
+
const node = schema as Record<string, unknown>;
|
|
207
|
+
if (Array.isArray(node.enum)) for (const value of node.enum) if (typeof value === "string") enums.push(value);
|
|
208
|
+
for (const key of ["anyOf", "oneOf", "allOf"]) {
|
|
209
|
+
if (Array.isArray(node[key])) for (const part of node[key] as unknown[]) schemaWords(part, depth, names, enums);
|
|
210
|
+
}
|
|
211
|
+
if (node.items) schemaWords(node.items, depth, names, enums);
|
|
212
|
+
const properties = node.properties as Record<string, unknown> | undefined;
|
|
213
|
+
if (properties && typeof properties === "object") {
|
|
214
|
+
for (const [name, child] of Object.entries(properties)) {
|
|
215
|
+
names.push(name);
|
|
216
|
+
schemaWords(child, depth - 1, names, enums);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function outputWords(schema: unknown, depth: number, out: string[]): void {
|
|
222
|
+
if (!schema || typeof schema !== "object" || depth < 0) return;
|
|
223
|
+
const node = schema as Record<string, unknown>;
|
|
224
|
+
if (typeof node.description === "string") out.push(node.description.slice(0, 200));
|
|
225
|
+
if (node.items) outputWords(node.items, depth, out);
|
|
226
|
+
for (const key of ["anyOf", "oneOf", "allOf"]) {
|
|
227
|
+
if (Array.isArray(node[key])) for (const part of node[key] as unknown[]) outputWords(part, depth, out);
|
|
228
|
+
}
|
|
229
|
+
const properties = node.properties as Record<string, unknown> | undefined;
|
|
230
|
+
if (properties && typeof properties === "object") {
|
|
231
|
+
for (const [name, child] of Object.entries(properties)) {
|
|
232
|
+
out.push(name);
|
|
233
|
+
outputWords(child, depth - 1, out);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function firstSentence(text: string): string {
|
|
239
|
+
return /^(.{1,200}?[.!?])(\s|$)/s.exec(text.trim())?.[1] ?? "";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function indexOperation(op: SearchableOperation): IndexedOperation {
|
|
243
|
+
const weights = new Map<string, { best: number; rest: number }>();
|
|
244
|
+
const add = (text: string, weight: number) => {
|
|
245
|
+
for (const s of new Set(stems(text))) {
|
|
246
|
+
const entry = weights.get(s);
|
|
247
|
+
if (!entry) weights.set(s, { best: weight, rest: 0 });
|
|
248
|
+
else if (weight > entry.best) { entry.rest += entry.best; entry.best = weight; }
|
|
249
|
+
else entry.rest += weight;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
// GraphQL tools are named query_<field> / mutation_<field>; the prefix is
|
|
253
|
+
// the kind, not a word a task would use.
|
|
254
|
+
const name = op.graphql?.field
|
|
255
|
+
? [op.graphql.field]
|
|
256
|
+
: [op.tool, op.resource ?? "", op.method ?? ""];
|
|
257
|
+
add(name.join(" "), W_NAME);
|
|
258
|
+
// Without a summary, the description's first sentence plays its part.
|
|
259
|
+
const summary = op.summary?.trim() || firstSentence(op.description ?? "");
|
|
260
|
+
add(summary, W_SUMMARY);
|
|
261
|
+
add(pathWords(op.path), W_PATH);
|
|
262
|
+
const paramNames: string[] = [];
|
|
263
|
+
const enumValues: string[] = [];
|
|
264
|
+
const paramText: string[] = [];
|
|
265
|
+
for (const param of op.params) {
|
|
266
|
+
paramNames.push(param.name);
|
|
267
|
+
if (param.enum) enumValues.push(...param.enum);
|
|
268
|
+
if (param.description) paramText.push(param.description.split("\n")[0]!.slice(0, 200));
|
|
269
|
+
}
|
|
270
|
+
const properties = (op.inputSchema?.properties ?? {}) as Record<string, unknown>;
|
|
271
|
+
for (const child of Object.values(properties)) schemaWords(child, 1, paramNames, enumValues);
|
|
272
|
+
add(paramNames.join(" "), W_PARAM);
|
|
273
|
+
add(enumValues.join(" "), W_ENUM);
|
|
274
|
+
add(op.description ?? "", W_DESCRIPTION);
|
|
275
|
+
add(paramText.join(" "), W_OUTPUT);
|
|
276
|
+
const output: string[] = [];
|
|
277
|
+
outputWords(op.outputSchema, 2, output);
|
|
278
|
+
add(output.join(" "), W_OUTPUT);
|
|
279
|
+
const { action, word } = operationAction(op);
|
|
280
|
+
const actionStem = word === null ? null : stem(word);
|
|
281
|
+
const nameStems = [...new Set(stems(name.join(" ")))].filter((s) => s !== actionStem);
|
|
282
|
+
return { weights, nameStems, action, actionStem, identity: isIdentityOperation(op), summary: stems(summary).join(" "), deprecated: op.deprecated === true || /^deprecated\b/i.test(op.summary ?? "") };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function indexFor(ops: readonly SearchableOperation[]): SearchIndex {
|
|
286
|
+
const cached = INDEXES.get(ops);
|
|
287
|
+
if (cached) return cached;
|
|
288
|
+
const docs = ops.map((op) => {
|
|
289
|
+
let doc = INDEXED.get(op);
|
|
290
|
+
if (!doc) INDEXED.set(op, doc = indexOperation(op));
|
|
291
|
+
return doc;
|
|
292
|
+
});
|
|
293
|
+
const df = new Map<string, number>();
|
|
294
|
+
for (const doc of docs) for (const s of doc.weights.keys()) df.set(s, (df.get(s) ?? 0) + 1);
|
|
295
|
+
const index = { docs, df, vocabulary: [...df.keys()] };
|
|
296
|
+
INDEXES.set(ops, index);
|
|
297
|
+
return index;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** A query stem's matches in the vocabulary: itself, and longer or
|
|
301
|
+
* shorter forms sharing a long prefix ("transcrib" and "transcript"),
|
|
302
|
+
* which count for less. */
|
|
303
|
+
function variants(term: string, index: SearchIndex): Map<string, number> {
|
|
304
|
+
const out = new Map<string, number>();
|
|
305
|
+
if (index.df.has(term)) out.set(term, 1);
|
|
306
|
+
if (term.length < 5) return out;
|
|
307
|
+
for (const word of index.vocabulary) {
|
|
308
|
+
if (word === term || word.length < 5) continue;
|
|
309
|
+
let common = 0;
|
|
310
|
+
while (common < word.length && common < term.length && word[common] === term[common]) common++;
|
|
311
|
+
if (common >= 6 && Math.max(word.length, term.length) - common <= 3) out.set(word, 0.6);
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
interface QueryTerm {
|
|
317
|
+
/** Vocabulary stem -> match strength. */
|
|
318
|
+
matches: Map<string, number>;
|
|
319
|
+
/** How much the term counts: the task's object most, its verb and
|
|
320
|
+
* qualifiers ("yesterday") least. */
|
|
321
|
+
weight: number;
|
|
322
|
+
/** The query's verb: the operation kinds it asks for, best first. */
|
|
323
|
+
verb: Action[] | null;
|
|
324
|
+
/** The first noun, usually the object of the task. */
|
|
325
|
+
primary: boolean;
|
|
326
|
+
stem: string;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function parseQuery(query: string, index: SearchIndex): { terms: QueryTerm[]; verb: Action[] | null } {
|
|
330
|
+
const raw = words(query);
|
|
331
|
+
let verb: Action[] | null = null;
|
|
332
|
+
let identity = false;
|
|
333
|
+
const terms: QueryTerm[] = [];
|
|
334
|
+
const seen = new Set<string>();
|
|
335
|
+
for (const word of raw) {
|
|
336
|
+
if (IDENTITY_WORDS.has(word)) { identity = true; continue; }
|
|
337
|
+
if (STOPWORDS.has(word)) continue;
|
|
338
|
+
const s = stem(word);
|
|
339
|
+
if (seen.has(s)) continue;
|
|
340
|
+
seen.add(s);
|
|
341
|
+
const actions = VERBS[word] ?? VERBS[s] ?? null;
|
|
342
|
+
const isVerb = actions !== null && actions.length > 0 && verb === null;
|
|
343
|
+
if (isVerb) verb = actions;
|
|
344
|
+
const matches = variants(s, index);
|
|
345
|
+
for (const synonym of SYNONYMS[word] ?? []) for (const [v, strength] of variants(stem(synonym), index)) matches.set(v, Math.max(matches.get(v) ?? 0, strength * 0.8));
|
|
346
|
+
// The verb is scored against what the operation does, so its word
|
|
347
|
+
// counts for little; the first noun is usually the object of the task.
|
|
348
|
+
const qualifier = QUALIFIERS.has(word);
|
|
349
|
+
const primary = !isVerb && !qualifier && !terms.some((t) => t.primary);
|
|
350
|
+
const weight = isVerb || qualifier ? 0.3 : primary ? 1.3 : 1;
|
|
351
|
+
terms.push({ matches, weight, verb: isVerb ? actions : null, primary, stem: s });
|
|
352
|
+
}
|
|
353
|
+
if (identity) {
|
|
354
|
+
const matches = new Map<string, number>();
|
|
355
|
+
for (const t of IDENTITY_TERMS) for (const [s, strength] of variants(stem(t), index)) matches.set(s, Math.max(matches.get(s) ?? 0, strength));
|
|
356
|
+
terms.push({ matches, weight: 0.8, verb: null, primary: false, stem: "@me" });
|
|
357
|
+
}
|
|
358
|
+
return { terms, verb };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export interface RankedOperation<T> {
|
|
362
|
+
op: T;
|
|
363
|
+
score: number;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Operations for a free-text query, best first. Weak matches (under a
|
|
368
|
+
* fifth of the best score, or matching only the query's verb) are left
|
|
369
|
+
* out, and deprecated operations follow every current one.
|
|
370
|
+
*/
|
|
371
|
+
export function rankOperations<T extends SearchableOperation>(ops: readonly T[], query: string): RankedOperation<T>[] {
|
|
372
|
+
const index = indexFor(ops);
|
|
373
|
+
const { terms, verb } = parseQuery(query, index);
|
|
374
|
+
if (terms.length === 0) return [];
|
|
375
|
+
const idf = (s: string) => Math.log(1 + index.docs.length / (index.df.get(s) ?? index.docs.length));
|
|
376
|
+
const nonVerbTerms = terms.filter((t) => t.verb === null).length;
|
|
377
|
+
const primary = terms.find((t) => t.primary);
|
|
378
|
+
const verbStem = terms.find((t) => t.verb !== null)?.stem ?? null;
|
|
379
|
+
const identityTerm = terms.find((t) => t.stem === "@me");
|
|
380
|
+
const identityIdf = Math.log(1 + index.docs.length / Math.max(1, index.docs.filter((d) => d.identity).length));
|
|
381
|
+
const scored: { op: T; score: number; deprecated: boolean }[] = [];
|
|
382
|
+
const phrase = words(query).filter((w) => !STOPWORDS.has(w)).map(stem).join(" ");
|
|
383
|
+
index.docs.forEach((doc, position) => {
|
|
384
|
+
let score = 0;
|
|
385
|
+
let matchedNonVerb = 0;
|
|
386
|
+
let primaryInName = false;
|
|
387
|
+
let verbInName = false;
|
|
388
|
+
const covered = new Set<string>();
|
|
389
|
+
for (const term of terms) {
|
|
390
|
+
let best = term === identityTerm && doc.identity ? W_NAME * identityIdf : 0;
|
|
391
|
+
for (const [s, strength] of term.matches) {
|
|
392
|
+
const entry = doc.weights.get(s);
|
|
393
|
+
if (!entry) continue;
|
|
394
|
+
const value = (entry.best + 0.1 * Math.min(entry.rest, 10)) * strength * idf(s);
|
|
395
|
+
if (value > best) best = value;
|
|
396
|
+
if (doc.nameStems.includes(s)) {
|
|
397
|
+
covered.add(s);
|
|
398
|
+
if (term === primary) primaryInName = true;
|
|
399
|
+
// The query's verb as a noun of the name: upload_url for "upload".
|
|
400
|
+
if (term.verb !== null) verbInName = true;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (best > 0 && term.verb === null) matchedNonVerb++;
|
|
404
|
+
score += best * term.weight;
|
|
405
|
+
}
|
|
406
|
+
if (score === 0) return;
|
|
407
|
+
if (nonVerbTerms > 0 && matchedNonVerb === 0) return;
|
|
408
|
+
// How much of the operation's name the query accounts for: call_list
|
|
409
|
+
// for "list calls" over sip_auth_calls_credential_list_mapping_delete.
|
|
410
|
+
const coverage = doc.nameStems.length === 0 ? 1 : covered.size / doc.nameStems.length;
|
|
411
|
+
score *= 0.6 + 0.8 * coverage;
|
|
412
|
+
// The query's verb against what the operation does.
|
|
413
|
+
if (verb) {
|
|
414
|
+
const rank = verb.indexOf(doc.action);
|
|
415
|
+
score *= rank === 0 ? 1.5 : rank > 0 ? 1.2 : doc.action === "search" && verb.includes("list") ? 1.2 : verbInName ? 1 : 0.55;
|
|
416
|
+
// The very word: issue_add_label for "add", over a generic create.
|
|
417
|
+
if (verbStem !== null && doc.actionStem === verbStem) score *= 1.2;
|
|
418
|
+
}
|
|
419
|
+
// Every query word matched: a stronger signal than any single word.
|
|
420
|
+
if (matchedNonVerb === nonVerbTerms && nonVerbTerms > 1) score *= 1.2;
|
|
421
|
+
if (phrase.includes(" ") && doc.nameStems.join(" ").includes(phrase)) score *= 1.2;
|
|
422
|
+
if (phrase.includes(" ") && doc.summary.includes(phrase)) score *= 1.3;
|
|
423
|
+
// The operation is named for the task's object: issue_update, not
|
|
424
|
+
// workflow_state_update, for "change issue state".
|
|
425
|
+
if (primaryInName) score *= 1.3;
|
|
426
|
+
scored.push({ op: ops[position]!, score, deprecated: doc.deprecated });
|
|
427
|
+
});
|
|
428
|
+
if (scored.length === 0) return [];
|
|
429
|
+
const top = Math.max(...scored.map((s) => s.score));
|
|
430
|
+
return scored
|
|
431
|
+
.filter((s) => s.score >= top * 0.2)
|
|
432
|
+
.sort((a, b) => Number(a.deprecated) - Number(b.deprecated) || b.score - a.score || a.op.tool.localeCompare(b.op.tool))
|
|
433
|
+
.map(({ op, score }) => ({ op, score: Math.round(score * 100) / 100 }));
|
|
434
|
+
}
|
package/src/table.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--format table` for the generated CLI: an API result as text for a
|
|
3
|
+
* person at a terminal. Generated by Typeship — https://typeship.dev
|
|
4
|
+
*
|
|
5
|
+
* JSON stays the default and the automation format; this view is opt-in and
|
|
6
|
+
* lossy by design. Lists become columns chosen by name (identifiers, then
|
|
7
|
+
* names, then status-like fields) while they fit the terminal width; a single
|
|
8
|
+
* object becomes one "field value" line per field in the same order. Cells longer than
|
|
9
|
+
* MAX_CELL characters end in "…", and the footer names every column or field
|
|
10
|
+
* that was left out. Pure: no I/O, so tests render without a terminal.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The longest cell before it is cut with "…". */
|
|
14
|
+
export const MAX_CELL = 40;
|
|
15
|
+
/** A single object shows at most this many (flattened) fields. */
|
|
16
|
+
export const MAX_FIELDS = 60;
|
|
17
|
+
|
|
18
|
+
export interface TableOptions {
|
|
19
|
+
/** Terminal width in columns; lists add columns until it is used. */
|
|
20
|
+
width?: number;
|
|
21
|
+
/** Wraps header text, e.g. in bold when color is on. */
|
|
22
|
+
heading?: (text: string) => string;
|
|
23
|
+
/** The array property that holds a collection response's items ({data: [...]}). */
|
|
24
|
+
collectionField?: string | null;
|
|
25
|
+
/** The command's resource (shipments), so shipment_id is recognized as the row's identifier. */
|
|
26
|
+
resource?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Row = Record<string, unknown>;
|
|
30
|
+
|
|
31
|
+
const ID_KEYS = new Set(["id", "sid", "uuid", "key", "slug", "code", "symbol", "number", "handle"]);
|
|
32
|
+
const NAME_KEYS = new Set(["name", "display_name", "displayname", "friendly_name", "friendlyname", "title", "label", "email", "username", "login", "to", "from"]);
|
|
33
|
+
const STATUS_KEYS = new Set(["status", "state"]);
|
|
34
|
+
const DESCRIPTOR_KEYS = new Set(["type", "kind", "direction", "enabled", "active", "amount", "currency", "price", "total"]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Lower is shown first: identifiers, names, status, descriptors, everything
|
|
38
|
+
* else, dates, then references and URLs. `primaryId` is the row's own
|
|
39
|
+
* identifier when it is spelled like a reference (shipment_id with no id).
|
|
40
|
+
*/
|
|
41
|
+
function rank(key: string, primaryId: string | undefined): number {
|
|
42
|
+
const k = key.toLowerCase();
|
|
43
|
+
if (ID_KEYS.has(k) || key === primaryId) return 0;
|
|
44
|
+
if (NAME_KEYS.has(k)) return 1;
|
|
45
|
+
if (STATUS_KEYS.has(k)) return 2;
|
|
46
|
+
if (DESCRIPTOR_KEYS.has(k)) return 3;
|
|
47
|
+
if (/(^|_)(created|updated)(_at|_on)?$|^date_|^(created|updated)|At$/.test(key)) return 5;
|
|
48
|
+
if (/(_id|_sid|Id|_ids|_uri|_url|Url|Uri)$/.test(key) || k === "uri" || k === "url") return 6;
|
|
49
|
+
return 4;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Keys in display order. The resource's own *_id (shipment_id for shipments) counts as the identifier when no id-like key exists. */
|
|
53
|
+
function byRank(keys: string[], resource: string | undefined): string[] {
|
|
54
|
+
const bare = (text: string) => text.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
55
|
+
const own = resource ? bare(resource).replace(/ies$/, "y").replace(/(ses|xes|s)$/, (m) => m === "s" ? "" : m.slice(0, -2)) + "id" : undefined;
|
|
56
|
+
const primaryId = keys.some((key) => ID_KEYS.has(key.toLowerCase())) ? undefined
|
|
57
|
+
: keys.find((key) => bare(key) === own) ?? keys.find((key) => /(_id|Id)$/.test(key));
|
|
58
|
+
return keys.map((key, index) => ({ key, index, rank: rank(key, primaryId) })).sort((a, b) => a.rank - b.rank || a.index - b.index).map((c) => c.key);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isPlainObject(value: unknown): value is Row {
|
|
62
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function scalar(value: unknown): string {
|
|
66
|
+
if (value === null || value === undefined) return "";
|
|
67
|
+
if (typeof value === "string") return value.replace(/\s+/g, " ").trim();
|
|
68
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
if (value.length === 0) return "[]";
|
|
71
|
+
return value.every((v) => v === null || typeof v !== "object") ? value.map(scalar).join(", ") : "[" + value.length + " items]";
|
|
72
|
+
}
|
|
73
|
+
if (isPlainObject(value)) {
|
|
74
|
+
const entries = Object.entries(value);
|
|
75
|
+
if (entries.length === 0) return "{}";
|
|
76
|
+
return entries.every(([, v]) => v === null || typeof v !== "object")
|
|
77
|
+
? entries.map(([k, v]) => k + "=" + scalar(v)).join(" ")
|
|
78
|
+
: "{" + entries.length + " fields}";
|
|
79
|
+
}
|
|
80
|
+
return String(value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function cut(text: string, max: number): string {
|
|
84
|
+
return text.length > max ? text.slice(0, Math.max(1, max - 1)) + "…" : text;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function pad(text: string, width: number): string {
|
|
88
|
+
return text + " ".repeat(Math.max(0, width - text.length));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The rows and footer lines of a list-shaped result, or null for a single object. */
|
|
92
|
+
function listOf(value: unknown, collectionField: string | null | undefined): { rows: unknown[]; footer: string[] } | null {
|
|
93
|
+
if (Array.isArray(value)) return { rows: value, footer: [] };
|
|
94
|
+
if (!isPlainObject(value)) return null;
|
|
95
|
+
// The CLI's page envelope: {items, hasMore, nextPage?, nextCommand?, request_id?}.
|
|
96
|
+
if (Array.isArray(value.items) && typeof value.hasMore === "boolean") {
|
|
97
|
+
return { rows: value.items, footer: typeof value.nextCommand === "string" ? ["More results: " + value.nextCommand] : [] };
|
|
98
|
+
}
|
|
99
|
+
if (collectionField && Array.isArray(value[collectionField])) {
|
|
100
|
+
const rest = Object.entries(value).filter(([k, v]) => k !== collectionField && v !== null && typeof v !== "object");
|
|
101
|
+
return { rows: value[collectionField] as unknown[], footer: rest.length ? [rest.map(([k, v]) => k + ": " + scalar(v)).join(" ")] : [] };
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function renderRows(rows: unknown[], footer: string[], options: TableOptions): string {
|
|
107
|
+
const heading = options.heading ?? ((text: string) => text);
|
|
108
|
+
if (rows.length === 0) return ["No items.", ...footer].join("\n") + "\n";
|
|
109
|
+
if (!rows.every(isPlainObject)) {
|
|
110
|
+
return [...rows.map((row) => cut(scalar(row), Math.max(MAX_CELL, options.width ?? 120))), "", rows.length + (rows.length === 1 ? " item" : " items"), ...footer].join("\n") + "\n";
|
|
111
|
+
}
|
|
112
|
+
const keys: string[] = [];
|
|
113
|
+
for (const row of rows as Row[]) for (const key of Object.keys(row)) if (!keys.includes(key)) keys.push(key);
|
|
114
|
+
const ordered = byRank(keys, options.resource);
|
|
115
|
+
const width = Math.max(40, options.width ?? 120);
|
|
116
|
+
const shown: { key: string; width: number }[] = [];
|
|
117
|
+
let used = 0;
|
|
118
|
+
for (const key of ordered) {
|
|
119
|
+
const cells = (rows as Row[]).map((row) => cut(scalar(row[key]), MAX_CELL));
|
|
120
|
+
// A column empty in every row says nothing.
|
|
121
|
+
if (cells.every((cell) => cell === "")) continue;
|
|
122
|
+
const columnWidth = Math.max(Math.min(key.length, MAX_CELL), ...cells.map((cell) => cell.length));
|
|
123
|
+
if (shown.length > 0 && used + 2 + columnWidth > width) continue;
|
|
124
|
+
shown.push({ key, width: columnWidth });
|
|
125
|
+
used += (shown.length > 1 ? 2 : 0) + columnWidth;
|
|
126
|
+
}
|
|
127
|
+
const line = (cells: string[]) => cells.map((cell, i) => i === cells.length - 1 ? cell : pad(cell, shown[i]!.width)).join(" ").trimEnd();
|
|
128
|
+
const lines = [
|
|
129
|
+
heading(line(shown.map((c) => cut(c.key.toUpperCase(), c.width)))),
|
|
130
|
+
...(rows as Row[]).map((row) => line(shown.map((c) => cut(scalar(row[c.key]), MAX_CELL)))),
|
|
131
|
+
"",
|
|
132
|
+
];
|
|
133
|
+
const hidden = ordered.filter((key) => !shown.some((c) => c.key === key));
|
|
134
|
+
lines.push(rows.length + (rows.length === 1 ? " item" : " items") + (hidden.length ? "; not shown: " + hidden.join(", ") : ""));
|
|
135
|
+
return [...lines, ...footer].join("\n") + "\n";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** One object as "field value" lines, identifiers and status first; nested objects flatten to dotted paths. */
|
|
139
|
+
function renderObject(value: Row, options: TableOptions): string {
|
|
140
|
+
const fields: [string, string][] = [];
|
|
141
|
+
const walk = (object: Row, prefix: string, depth: number) => {
|
|
142
|
+
const keys = depth === 0 ? byRank(Object.keys(object), options.resource) : Object.keys(object);
|
|
143
|
+
for (const key of keys) {
|
|
144
|
+
const v = object[key];
|
|
145
|
+
const path = prefix + key;
|
|
146
|
+
if (isPlainObject(v) && depth < 2 && Object.keys(v).length > 0) walk(v, path + ".", depth + 1);
|
|
147
|
+
else fields.push([path, scalar(v)]);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
walk(value, "", 0);
|
|
151
|
+
if (fields.length === 0) return "No fields.\n";
|
|
152
|
+
const heading = options.heading ?? ((text: string) => text);
|
|
153
|
+
const shown = fields.slice(0, MAX_FIELDS);
|
|
154
|
+
const keyWidth = Math.min(MAX_CELL, Math.max(...shown.map(([key]) => key.length)));
|
|
155
|
+
const valueWidth = Math.max(MAX_CELL, (options.width ?? 120) - keyWidth - 2);
|
|
156
|
+
const lines = shown.map(([key, text]) => (heading(pad(cut(key, keyWidth), keyWidth)) + " " + cut(text, valueWidth)).trimEnd());
|
|
157
|
+
if (fields.length > shown.length) lines.push("", (fields.length - shown.length) + " more fields not shown; use --format json or --fields.");
|
|
158
|
+
return lines.join("\n") + "\n";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The text --format table prints for a result that would otherwise print as JSON. */
|
|
162
|
+
export function renderTable(value: unknown, options: TableOptions = {}): string {
|
|
163
|
+
const list = listOf(value, options.collectionField);
|
|
164
|
+
if (list) return renderRows(list.rows, list.footer, options);
|
|
165
|
+
if (isPlainObject(value)) return renderObject(value, options);
|
|
166
|
+
return scalar(value) + "\n";
|
|
167
|
+
}
|