@utdk/cli 0.1.0-dev.646adf4

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.
@@ -0,0 +1,478 @@
1
+ /**
2
+ * Provider and operation discovery for @utdk/cli.
3
+ *
4
+ * Scans the utdk package directory for providers and reads their OpenAPI
5
+ * specifications to enumerate operations. Name transformations mirror
6
+ * those in packages/utdk/client.ts so CLI operation names match the
7
+ * accessor paths on the created client.
8
+ *
9
+ * Also builds ToolRuntimeMetadataMap entries for path/query/header/body
10
+ * parameter routing, so the generated client can correctly assemble HTTP
11
+ * requests even when the provider's hand-written metadata.ts is empty.
12
+ */
13
+
14
+ import { existsSync, readdirSync, readFileSync } from "fs";
15
+ import { dirname, join, resolve } from "path";
16
+ import { fileURLToPath } from "url";
17
+ import type { ToolRuntimeMetadata, ToolRuntimeMetadataMap } from "utdk/client";
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ // Compiled to packages/utdk-cli/dist/; navigate to packages/utdk/
21
+ export const UTDK_ROOT = resolve(__dirname, "..", "..", "utdk");
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Types
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export type AuthConfig = {
28
+ auth_type: "api_key" | "oauth2" | "basic";
29
+ api_key?: string;
30
+ var_name?: string;
31
+ location?: string;
32
+ token_url?: string;
33
+ client_id?: string;
34
+ client_secret?: string;
35
+ scope?: string;
36
+ };
37
+
38
+ export type ProviderInfo = {
39
+ name: string;
40
+ description?: string;
41
+ auth: AuthConfig[];
42
+ };
43
+
44
+ export type ParameterInfo = {
45
+ name: string;
46
+ in: "query" | "path" | "header" | "cookie";
47
+ required: boolean;
48
+ description?: string;
49
+ type?: string;
50
+ };
51
+
52
+ export type BodyInfo = {
53
+ kind: "none" | "properties" | "raw";
54
+ propertyKeys: string[];
55
+ contentType?: string;
56
+ allowsAdditionalProperties?: boolean;
57
+ };
58
+
59
+ export type OperationInfo = {
60
+ accessPath: string[];
61
+ operationId: string;
62
+ method: string;
63
+ path: string;
64
+ summary?: string;
65
+ description?: string;
66
+ parameters: ParameterInfo[];
67
+ body: BodyInfo;
68
+ };
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Name transformation (mirrors client.ts identically)
72
+ // ---------------------------------------------------------------------------
73
+
74
+ function sanitizeIdentifier(name: string): string {
75
+ return name.replace(/[^a-zA-Z0-9_]/g, "_").replace(/^[0-9]/, "_$&");
76
+ }
77
+
78
+ function splitIdentifierWords(name: string): string[] {
79
+ return name
80
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
81
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
82
+ .replace(/([A-Za-z])([0-9])/g, "$1 $2")
83
+ .replace(/([0-9])([A-Za-z])/g, "$1 $2")
84
+ .replace(/[^a-zA-Z0-9]+/g, " ")
85
+ .trim()
86
+ .split(/\s+/)
87
+ .filter(Boolean);
88
+ }
89
+
90
+ export function toCamelCase(name: string): string {
91
+ const cleaned = splitIdentifierWords(name);
92
+ if (cleaned.length === 0) return "_";
93
+ const [first = "_", ...rest] = cleaned;
94
+ return sanitizeIdentifier(
95
+ first.toLowerCase() +
96
+ rest.map((s) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()).join(""),
97
+ );
98
+ }
99
+
100
+ /** Converts an OpenAPI operationId to a client access path (same logic as client.ts). */
101
+ function operationIdToBasePath(operationId: string): string[] {
102
+ const segments = operationId.split("/").filter(Boolean);
103
+ const path = (segments.length > 0 ? segments : [operationId]).map(toCamelCase);
104
+ // Collapse duplicate consecutive leaf (e.g. a/a → ['a','call'])
105
+ if (path.length > 1 && path[path.length - 1] === path[path.length - 2]) {
106
+ path[path.length - 1] = "call";
107
+ }
108
+ return path;
109
+ }
110
+
111
+ function hasPathConflict(candidate: string[], used: Set<string>): boolean {
112
+ const key = candidate.join(".");
113
+ for (const u of used) {
114
+ if (u === key || u.startsWith(`${key}.`) || key.startsWith(`${u}.`)) return true;
115
+ }
116
+ return false;
117
+ }
118
+
119
+ function resolveAccessPath(basePath: string[], used: Set<string>): string[] {
120
+ const baseLeaf = basePath[basePath.length - 1] ?? "_";
121
+ let path = [...basePath];
122
+ let suffix = 2;
123
+ while (hasPathConflict(path, used)) {
124
+ path = [...basePath.slice(0, -1), `${baseLeaf}_${suffix}`];
125
+ suffix++;
126
+ }
127
+ used.add(path.join("."));
128
+ return path;
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Provider listing
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /** Returns all provider names found in the utdk package directory. */
136
+ export function listProviders(): string[] {
137
+ if (!existsSync(UTDK_ROOT)) return [];
138
+ const entries = readdirSync(UTDK_ROOT, { withFileTypes: true });
139
+ return entries
140
+ .filter((e) => e.isDirectory() && existsSync(join(UTDK_ROOT, e.name, "openapi.json")))
141
+ .map((e) => e.name)
142
+ .sort();
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Provider info
147
+ // ---------------------------------------------------------------------------
148
+
149
+ type ProviderPkg = {
150
+ description?: string;
151
+ utdk?: {
152
+ auth?: AuthConfig[];
153
+ openapi?: { title?: string };
154
+ };
155
+ };
156
+
157
+ /** Returns metadata for a single provider, or undefined if not found. */
158
+ export function getProviderInfo(providerName: string): ProviderInfo | undefined {
159
+ const providerDir = join(UTDK_ROOT, providerName);
160
+ if (!existsSync(providerDir)) return undefined;
161
+
162
+ const pkgPath = join(providerDir, "package.json");
163
+ try {
164
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as ProviderPkg;
165
+ return {
166
+ name: providerName,
167
+ description: pkg.description ?? pkg.utdk?.openapi?.title,
168
+ auth: pkg.utdk?.auth ?? [],
169
+ };
170
+ } catch {
171
+ return { name: providerName, auth: [] };
172
+ }
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // OpenAPI parsing helpers
177
+ // ---------------------------------------------------------------------------
178
+
179
+ type OpenApiSchema = {
180
+ type?: string;
181
+ properties?: Record<string, OpenApiSchema>;
182
+ additionalProperties?: boolean | OpenApiSchema;
183
+ $ref?: string;
184
+ };
185
+
186
+ type OpenApiParameter = {
187
+ name: string;
188
+ in?: string;
189
+ required?: boolean;
190
+ description?: string;
191
+ schema?: OpenApiSchema;
192
+ $ref?: string;
193
+ };
194
+
195
+ type OpenApiRequestBody = {
196
+ required?: boolean;
197
+ content?: Record<string, { schema?: OpenApiSchema | { $ref: string } }>;
198
+ };
199
+
200
+ type OpenApiOperation = {
201
+ operationId?: string;
202
+ summary?: string;
203
+ description?: string;
204
+ parameters?: Array<OpenApiParameter | { $ref: string }>;
205
+ requestBody?: OpenApiRequestBody | { $ref: string };
206
+ // Swagger 2 body/formData live in parameters; handled implicitly above
207
+ };
208
+
209
+ type OpenApiPathItem = Record<string, OpenApiOperation | undefined>;
210
+
211
+ type OpenApiDoc = {
212
+ paths?: Record<string, OpenApiPathItem>;
213
+ components?: { schemas?: Record<string, OpenApiSchema> };
214
+ // Swagger 2
215
+ definitions?: Record<string, OpenApiSchema>;
216
+ };
217
+
218
+ const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
219
+ const JSON_CONTENT_TYPES = new Set([
220
+ "application/json",
221
+ "application/json-patch+json",
222
+ "application/merge-patch+json",
223
+ "application/vnd.api+json",
224
+ ]);
225
+
226
+ /** Resolve a JSON $ref to a schema object within the document. */
227
+ function resolveRef(doc: OpenApiDoc, ref: string): OpenApiSchema | undefined {
228
+ if (!ref.startsWith("#/")) return undefined;
229
+ const parts = ref.slice(2).split("/");
230
+ let current: unknown = doc;
231
+ for (const part of parts) {
232
+ if (current == null || typeof current !== "object") return undefined;
233
+ current = (current as Record<string, unknown>)[part.replace(/~1/g, "/").replace(/~0/g, "~")];
234
+ }
235
+ return current as OpenApiSchema | undefined;
236
+ }
237
+
238
+ function resolveSchemaRef(doc: OpenApiDoc, schema: OpenApiSchema): OpenApiSchema {
239
+ if (schema.$ref) {
240
+ return resolveRef(doc, schema.$ref) ?? schema;
241
+ }
242
+ return schema;
243
+ }
244
+
245
+ /** Resolve a $ref parameter to a concrete OpenApiParameter, or return the input if already concrete. */
246
+ function resolveParameter(
247
+ doc: OpenApiDoc,
248
+ param: OpenApiParameter | { $ref: string },
249
+ ): OpenApiParameter | undefined {
250
+ if (!("name" in param)) {
251
+ // It's a $ref
252
+ const resolved = resolveRef(doc, (param as { $ref: string }).$ref);
253
+ if (resolved && "name" in (resolved as object)) {
254
+ return resolved as unknown as OpenApiParameter;
255
+ }
256
+ return undefined;
257
+ }
258
+ return param;
259
+ }
260
+
261
+ /** Extract BodyInfo from an OpenAPI 3.x requestBody or Swagger 2 body param. */
262
+ function extractBodyInfo(
263
+ doc: OpenApiDoc,
264
+ operation: OpenApiOperation,
265
+ ): BodyInfo {
266
+ // Check requestBody (OpenAPI 3.x)
267
+ if (operation.requestBody) {
268
+ let rb = operation.requestBody as OpenApiRequestBody | { $ref: string };
269
+ if ("$ref" in rb) {
270
+ const resolved = resolveRef(doc, rb.$ref);
271
+ if (resolved) rb = resolved as unknown as OpenApiRequestBody;
272
+ else return { kind: "none", propertyKeys: [] };
273
+ }
274
+ const content = (rb as OpenApiRequestBody).content;
275
+ if (!content) return { kind: "raw", propertyKeys: [] };
276
+
277
+ // Prefer JSON content types
278
+ const jsonEntry = Object.entries(content).find(([ct]) =>
279
+ JSON_CONTENT_TYPES.has(ct) || ct.includes("json"),
280
+ );
281
+ const [contentType, mediaType] = jsonEntry ?? Object.entries(content)[0] ?? [];
282
+ if (!contentType || !mediaType) return { kind: "none", propertyKeys: [] };
283
+
284
+ const rawSchema = mediaType.schema;
285
+ if (!rawSchema) return { kind: "raw", propertyKeys: [], contentType };
286
+
287
+ const schema = resolveSchemaRef(doc, rawSchema as OpenApiSchema);
288
+ if (schema.type === "object" || schema.properties) {
289
+ const keys = Object.keys(schema.properties ?? {});
290
+ const allowsAdditional =
291
+ schema.additionalProperties === true ||
292
+ (typeof schema.additionalProperties === "object" && schema.additionalProperties !== null);
293
+ return {
294
+ kind: "properties",
295
+ propertyKeys: keys,
296
+ contentType,
297
+ allowsAdditionalProperties: allowsAdditional || undefined,
298
+ };
299
+ }
300
+ return { kind: "raw", propertyKeys: [], contentType };
301
+ }
302
+
303
+ // Swagger 2: check for body or formData parameters
304
+ const params = (operation.parameters ?? []) as Array<OpenApiParameter | { $ref: string }>;
305
+ const bodyParam = params.find(
306
+ (p): p is OpenApiParameter =>
307
+ "name" in p && ((p as OpenApiParameter).in === "body" || (p as OpenApiParameter).in === "formData"),
308
+ );
309
+ if (!bodyParam) return { kind: "none", propertyKeys: [] };
310
+
311
+ const schema = bodyParam.schema ? resolveSchemaRef(doc, bodyParam.schema) : undefined;
312
+ if (!schema) return { kind: "raw", propertyKeys: [], contentType: "application/json" };
313
+
314
+ if (schema.type === "object" || schema.properties) {
315
+ const keys = Object.keys(schema.properties ?? {});
316
+ return { kind: "properties", propertyKeys: keys, contentType: "application/json" };
317
+ }
318
+ return { kind: "raw", propertyKeys: [], contentType: "application/json" };
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // Operation listing
323
+ // ---------------------------------------------------------------------------
324
+
325
+ /** Parses the OpenAPI spec for a provider and returns all operations with their metadata. */
326
+ export function listOperations(providerName: string): OperationInfo[] {
327
+ const openApiPath = join(UTDK_ROOT, providerName, "openapi.json");
328
+ if (!existsSync(openApiPath)) return [];
329
+
330
+ let doc: OpenApiDoc;
331
+ try {
332
+ doc = JSON.parse(readFileSync(openApiPath, "utf-8")) as OpenApiDoc;
333
+ } catch {
334
+ return [];
335
+ }
336
+
337
+ const operations: OperationInfo[] = [];
338
+ const usedPaths = new Set<string>();
339
+
340
+ for (const [apiPath, pathItem] of Object.entries(doc.paths ?? {})) {
341
+ for (const [method, operation] of Object.entries(pathItem)) {
342
+ if (!HTTP_METHODS.has(method) || !operation) continue;
343
+
344
+ const operationId =
345
+ operation.operationId ??
346
+ `${method}${apiPath.replace(/[^a-zA-Z0-9]/g, "_")}`;
347
+
348
+ const basePath = operationIdToBasePath(operationId);
349
+ const accessPath = resolveAccessPath(basePath, usedPaths);
350
+
351
+ // Merge path-item-level params (shared across all methods) with operation params.
352
+ // Operation params override path-item params with the same name.
353
+ const pathItemParams = (
354
+ (pathItem as Record<string, unknown>)["parameters"] as Array<
355
+ OpenApiParameter | { $ref: string }
356
+ > | undefined
357
+ ) ?? [];
358
+
359
+ const rawParams = [
360
+ ...pathItemParams,
361
+ ...(operation.parameters ?? []),
362
+ ] as Array<OpenApiParameter | { $ref: string }>;
363
+
364
+ // Deduplicate: operation-level params override path-level params of the same name.
365
+ const seen = new Set<string>();
366
+ const dedupedParams: Array<OpenApiParameter | { $ref: string }> = [];
367
+ // Process operation params first (higher priority), then path params.
368
+ const operationParams = (operation.parameters ?? []) as Array<
369
+ OpenApiParameter | { $ref: string }
370
+ >;
371
+ for (const p of [...operationParams, ...pathItemParams]) {
372
+ const resolved = resolveParameter(doc, p);
373
+ if (!resolved) continue;
374
+ if (!seen.has(resolved.name)) {
375
+ seen.add(resolved.name);
376
+ dedupedParams.push(resolved);
377
+ }
378
+ }
379
+
380
+ const parameters: ParameterInfo[] = dedupedParams
381
+ .map((p) => resolveParameter(doc, p))
382
+ .filter(
383
+ (p): p is OpenApiParameter =>
384
+ p !== undefined && p.in !== "body" && p.in !== "formData",
385
+ )
386
+ .map((p) => ({
387
+ name: p.name,
388
+ in: (p.in as ParameterInfo["in"]) ?? "query",
389
+ required: p.required ?? false,
390
+ description: p.description,
391
+ type: p.schema?.type,
392
+ }));
393
+
394
+ void rawParams; // used above via pathItemParams/operationParams
395
+
396
+ const body = extractBodyInfo(doc, operation);
397
+
398
+ operations.push({
399
+ accessPath,
400
+ operationId,
401
+ method: method.toUpperCase(),
402
+ path: apiPath,
403
+ summary: operation.summary,
404
+ description: operation.description,
405
+ parameters,
406
+ body,
407
+ });
408
+ }
409
+ }
410
+
411
+ return operations;
412
+ }
413
+
414
+ /** Returns the OperationInfo for a specific access-path string like "users.getByUsername". */
415
+ export function findOperation(
416
+ providerName: string,
417
+ operationPath: string,
418
+ ): OperationInfo | undefined {
419
+ const ops = listOperations(providerName);
420
+ return ops.find(
421
+ (op) =>
422
+ op.accessPath.join(".") === operationPath ||
423
+ op.accessPath.join(".").toLowerCase() === operationPath.toLowerCase() ||
424
+ op.operationId === operationPath,
425
+ );
426
+ }
427
+
428
+ // ---------------------------------------------------------------------------
429
+ // Tool metadata builder
430
+ // ---------------------------------------------------------------------------
431
+
432
+ /**
433
+ * Build a ToolRuntimeMetadataMap from the provider's OpenAPI spec.
434
+ *
435
+ * The map keys equal the bare operationId (which is what OpenApiConverter
436
+ * uses as tool.name). Providing this map to createClient() gives the
437
+ * request assembler correct path/query/header/body routing without
438
+ * relying on the provider's (often empty) hand-written metadata.ts.
439
+ */
440
+ export function buildToolMetadata(ops: OperationInfo[]): ToolRuntimeMetadataMap {
441
+ const map: ToolRuntimeMetadataMap = {};
442
+
443
+ for (const op of ops) {
444
+ // tool.name from OpenApiConverter equals the bare operationId (no provider prefix).
445
+ const toolName = op.operationId;
446
+
447
+ const pathKeys = op.parameters
448
+ .filter((p) => p.in === "path")
449
+ .map((p) => p.name);
450
+ const queryKeys = op.parameters
451
+ .filter((p) => p.in === "query")
452
+ .map((p) => p.name);
453
+ const headerKeys = op.parameters
454
+ .filter((p) => p.in === "header")
455
+ .map((p) => p.name);
456
+
457
+ const meta: ToolRuntimeMetadata = {
458
+ accessPath: op.accessPath,
459
+ bodyKind: op.body.kind,
460
+ bodyPropertyKeys: op.body.propertyKeys,
461
+ ...(op.body.allowsAdditionalProperties
462
+ ? { bodyAllowsAdditionalProperties: true }
463
+ : {}),
464
+ ...(op.body.contentType ? { contentType: op.body.contentType } : {}),
465
+ headerParameterKeys: headerKeys,
466
+ method: op.method,
467
+ routeTemplate: op.path,
468
+ pathConflictKeys: [],
469
+ pathParameterKeys: pathKeys,
470
+ queryConflictKeys: [],
471
+ queryParameterKeys: queryKeys,
472
+ };
473
+
474
+ map[toolName] = meta;
475
+ }
476
+
477
+ return map;
478
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "moduleResolution": "Bundler",
5
+ "outDir": "dist",
6
+ "rootDir": "src",
7
+ "declaration": true,
8
+ "sourceMap": true
9
+ },
10
+ "include": ["src/**/*.ts"]
11
+ }