specli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CLAUDE.md +111 -0
  2. package/PLAN.md +274 -0
  3. package/README.md +474 -0
  4. package/biome.jsonc +1 -0
  5. package/bun.lock +98 -0
  6. package/cli.ts +74 -0
  7. package/fixtures/openapi-array-items.json +22 -0
  8. package/fixtures/openapi-auth.json +34 -0
  9. package/fixtures/openapi-body.json +41 -0
  10. package/fixtures/openapi-collision.json +21 -0
  11. package/fixtures/openapi-oauth.json +54 -0
  12. package/fixtures/openapi-servers.json +35 -0
  13. package/fixtures/openapi.json +87 -0
  14. package/index.ts +1 -0
  15. package/package.json +27 -0
  16. package/scripts/smoke-specs.ts +64 -0
  17. package/src/cli/auth-requirements.test.ts +27 -0
  18. package/src/cli/auth-requirements.ts +91 -0
  19. package/src/cli/auth-schemes.test.ts +66 -0
  20. package/src/cli/auth-schemes.ts +187 -0
  21. package/src/cli/capabilities.test.ts +94 -0
  22. package/src/cli/capabilities.ts +88 -0
  23. package/src/cli/command-id.test.ts +32 -0
  24. package/src/cli/command-id.ts +16 -0
  25. package/src/cli/command-index.ts +19 -0
  26. package/src/cli/command-model.test.ts +44 -0
  27. package/src/cli/command-model.ts +128 -0
  28. package/src/cli/compile.ts +119 -0
  29. package/src/cli/crypto.ts +9 -0
  30. package/src/cli/derive-name.ts +101 -0
  31. package/src/cli/exec.ts +72 -0
  32. package/src/cli/main.ts +336 -0
  33. package/src/cli/naming.test.ts +86 -0
  34. package/src/cli/naming.ts +224 -0
  35. package/src/cli/operations.test.ts +57 -0
  36. package/src/cli/operations.ts +152 -0
  37. package/src/cli/params.test.ts +70 -0
  38. package/src/cli/params.ts +71 -0
  39. package/src/cli/pluralize.ts +41 -0
  40. package/src/cli/positional.test.ts +65 -0
  41. package/src/cli/positional.ts +75 -0
  42. package/src/cli/request-body.test.ts +35 -0
  43. package/src/cli/request-body.ts +94 -0
  44. package/src/cli/runtime/argv.ts +14 -0
  45. package/src/cli/runtime/auth/resolve.ts +31 -0
  46. package/src/cli/runtime/body.ts +24 -0
  47. package/src/cli/runtime/collect.ts +6 -0
  48. package/src/cli/runtime/context.ts +62 -0
  49. package/src/cli/runtime/execute.ts +138 -0
  50. package/src/cli/runtime/generated.ts +200 -0
  51. package/src/cli/runtime/headers.ts +37 -0
  52. package/src/cli/runtime/index.ts +3 -0
  53. package/src/cli/runtime/profile/secrets.ts +42 -0
  54. package/src/cli/runtime/profile/store.ts +98 -0
  55. package/src/cli/runtime/request.test.ts +153 -0
  56. package/src/cli/runtime/request.ts +487 -0
  57. package/src/cli/runtime/server-url.ts +44 -0
  58. package/src/cli/runtime/template.ts +26 -0
  59. package/src/cli/runtime/validate/ajv.ts +13 -0
  60. package/src/cli/runtime/validate/coerce.ts +71 -0
  61. package/src/cli/runtime/validate/error.ts +29 -0
  62. package/src/cli/runtime/validate/index.ts +4 -0
  63. package/src/cli/runtime/validate/schema.ts +54 -0
  64. package/src/cli/schema-shape.ts +36 -0
  65. package/src/cli/schema.ts +76 -0
  66. package/src/cli/server.test.ts +35 -0
  67. package/src/cli/server.ts +88 -0
  68. package/src/cli/spec-id.ts +12 -0
  69. package/src/cli/spec-loader.ts +58 -0
  70. package/src/cli/stable-json.ts +35 -0
  71. package/src/cli/strings.ts +21 -0
  72. package/src/cli/types.ts +59 -0
  73. package/src/compiled.ts +23 -0
  74. package/src/macros/env.ts +25 -0
  75. package/src/macros/spec.ts +17 -0
  76. package/tsconfig.json +29 -0
@@ -0,0 +1,86 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { planOperation } from "./naming.ts";
3
+ import type { NormalizedOperation } from "./types.ts";
4
+
5
+ describe("planOperation", () => {
6
+ test("REST: GET /contacts -> contacts list", () => {
7
+ const op: NormalizedOperation = {
8
+ key: "GET /contacts",
9
+ method: "GET",
10
+ path: "/contacts",
11
+ operationId: "Contacts.List",
12
+ tags: ["Contacts"],
13
+ parameters: [],
14
+ };
15
+
16
+ const planned = planOperation(op);
17
+ expect(planned.style).toBe("rest");
18
+ expect(planned.resource).toBe("contacts");
19
+ expect(planned.action).toBe("list");
20
+ expect(planned.pathArgs).toEqual([]);
21
+ });
22
+
23
+ test("REST: singleton /ping stays ping and prefers operationId action", () => {
24
+ const op: NormalizedOperation = {
25
+ key: "GET /ping",
26
+ method: "GET",
27
+ path: "/ping",
28
+ operationId: "Ping.Get",
29
+ tags: [],
30
+ parameters: [],
31
+ };
32
+
33
+ const planned = planOperation(op);
34
+ expect(planned.style).toBe("rest");
35
+ expect(planned.resource).toBe("ping");
36
+ expect(planned.action).toBe("get");
37
+ });
38
+
39
+ test("REST: singular path pluralizes to contacts", () => {
40
+ const op: NormalizedOperation = {
41
+ key: "GET /contact/{id}",
42
+ method: "GET",
43
+ path: "/contact/{id}",
44
+ tags: [],
45
+ parameters: [],
46
+ };
47
+
48
+ const planned = planOperation(op);
49
+ expect(planned.style).toBe("rest");
50
+ expect(planned.resource).toBe("contacts");
51
+ expect(planned.action).toBe("get");
52
+ expect(planned.pathArgs).toEqual(["id"]);
53
+ });
54
+
55
+ test("RPC: POST /Contacts.List -> contacts list", () => {
56
+ const op: NormalizedOperation = {
57
+ key: "POST /Contacts.List",
58
+ method: "POST",
59
+ path: "/Contacts.List",
60
+ operationId: "Contacts.List",
61
+ tags: [],
62
+ parameters: [],
63
+ };
64
+
65
+ const planned = planOperation(op);
66
+ expect(planned.style).toBe("rpc");
67
+ expect(planned.resource).toBe("contacts");
68
+ expect(planned.action).toBe("list");
69
+ });
70
+
71
+ test("RPC: Retrieve canonicalizes to get", () => {
72
+ const op: NormalizedOperation = {
73
+ key: "POST /Contacts.Retrieve",
74
+ method: "POST",
75
+ path: "/Contacts.Retrieve",
76
+ operationId: "Contacts.Retrieve",
77
+ tags: [],
78
+ parameters: [],
79
+ };
80
+
81
+ const planned = planOperation(op);
82
+ expect(planned.style).toBe("rpc");
83
+ expect(planned.resource).toBe("contacts");
84
+ expect(planned.action).toBe("get");
85
+ });
86
+ });
@@ -0,0 +1,224 @@
1
+ import { pluralize } from "./pluralize.ts";
2
+ import { kebabCase } from "./strings.ts";
3
+ import type { NormalizedOperation } from "./types.ts";
4
+
5
+ export type PlannedOperation = NormalizedOperation & {
6
+ resource: string;
7
+ action: string;
8
+ pathArgs: string[];
9
+ style: "rest" | "rpc";
10
+ canonicalAction: string;
11
+ aliasOf?: string;
12
+ };
13
+
14
+ const GENERIC_TAGS = new Set(["default", "defaults", "api"]);
15
+
16
+ function getPathSegments(path: string): string[] {
17
+ return path
18
+ .split("/")
19
+ .map((s) => s.trim())
20
+ .filter(Boolean);
21
+ }
22
+
23
+ function getPathArgs(path: string): string[] {
24
+ const args: string[] = [];
25
+ const re = /\{([^}]+)\}/g;
26
+
27
+ while (true) {
28
+ const match = re.exec(path);
29
+ if (!match) break;
30
+ // biome-ignore lint/style/noNonNullAssertion: unknown
31
+ args.push(match[1]!);
32
+ }
33
+
34
+ return args;
35
+ }
36
+
37
+ function pickResourceFromTags(tags: string[]): string | undefined {
38
+ if (!tags.length) return undefined;
39
+ const first = tags[0]?.trim();
40
+ if (!first) return undefined;
41
+ if (GENERIC_TAGS.has(first.toLowerCase())) return undefined;
42
+ return first;
43
+ }
44
+
45
+ function splitOperationId(operationId: string): {
46
+ prefix?: string;
47
+ suffix?: string;
48
+ } {
49
+ const trimmed = operationId.trim();
50
+ if (!trimmed) return {};
51
+
52
+ // Prefer dot-notation when present: Contacts.List
53
+ if (trimmed.includes(".")) {
54
+ const [prefix, ...rest] = trimmed.split(".");
55
+ return { prefix, suffix: rest.join(".") };
56
+ }
57
+
58
+ // Try separators: Contacts_List, Contacts__List
59
+ if (trimmed.includes("__")) {
60
+ const [prefix, ...rest] = trimmed.split("__");
61
+ return { prefix, suffix: rest.join("__") };
62
+ }
63
+
64
+ if (trimmed.includes("_")) {
65
+ const [prefix, ...rest] = trimmed.split("_");
66
+ return { prefix, suffix: rest.join("_") };
67
+ }
68
+
69
+ return { suffix: trimmed };
70
+ }
71
+
72
+ function inferStyle(op: NormalizedOperation): "rest" | "rpc" {
73
+ // Path-based RPC convention (common in gRPC-ish HTTP gateways)
74
+ // - POST /Contacts.List
75
+ // - POST /Contacts/Service.List
76
+ if (op.path.includes(".")) return "rpc";
77
+
78
+ // operationId dot-notation alone is not enough to call it RPC; many REST APIs
79
+ // have dotted ids. We treat dotted operationId as a weak signal.
80
+ if (op.operationId?.includes(".") && op.method === "POST") return "rpc";
81
+
82
+ return "rest";
83
+ }
84
+
85
+ function inferResource(op: NormalizedOperation): string {
86
+ const tag = pickResourceFromTags(op.tags);
87
+ if (tag) return pluralize(kebabCase(tag));
88
+
89
+ if (op.operationId) {
90
+ const { prefix } = splitOperationId(op.operationId);
91
+ if (prefix) {
92
+ const fromId = kebabCase(prefix);
93
+ if (fromId === "ping") return "ping";
94
+ return pluralize(fromId);
95
+ }
96
+ }
97
+
98
+ const segments = getPathSegments(op.path);
99
+ let first = segments[0] ?? "api";
100
+
101
+ // If first segment is rpc-ish, like Contacts.List, split it.
102
+ // biome-ignore lint/style/noNonNullAssertion: split always returns at least one element
103
+ first = first.includes(".") ? first.split(".")[0]! : first;
104
+
105
+ // Singletons like /ping generally shouldn't become `pings`.
106
+ if (first.toLowerCase() === "ping") return "ping";
107
+
108
+ // Strip path params if they appear in first segment (rare)
109
+ const cleaned = first.replace(/^\{.+\}$/, "");
110
+ return pluralize(kebabCase(cleaned || "api"));
111
+ }
112
+
113
+ function canonicalizeAction(action: string): string {
114
+ const a = kebabCase(action);
115
+
116
+ // Common RPC verbs -> REST canonical verbs
117
+ if (a === "retrieve" || a === "read") return "get";
118
+ if (a === "list" || a === "search") return "list";
119
+ if (a === "create") return "create";
120
+ if (a === "update" || a === "patch") return "update";
121
+ if (a === "delete" || a === "remove") return "delete";
122
+
123
+ return a;
124
+ }
125
+
126
+ function inferRestAction(op: NormalizedOperation): string {
127
+ // If operationId is present and looks intentional, prefer it.
128
+ // This helps with singleton endpoints like GET /ping (Ping.Get) vs collections.
129
+ if (op.operationId) {
130
+ const { suffix } = splitOperationId(op.operationId);
131
+ if (suffix) {
132
+ const fromId = canonicalizeAction(suffix);
133
+ if (
134
+ fromId === "get" ||
135
+ fromId === "list" ||
136
+ fromId === "create" ||
137
+ fromId === "update" ||
138
+ fromId === "delete"
139
+ ) {
140
+ return fromId;
141
+ }
142
+ }
143
+ }
144
+
145
+ const method = op.method.toUpperCase();
146
+ const args = getPathArgs(op.path);
147
+ const hasId = args.length > 0;
148
+
149
+ if (method === "GET" && !hasId) return "list";
150
+ if (method === "POST" && !hasId) return "create";
151
+
152
+ if (method === "GET" && hasId) return "get";
153
+ if ((method === "PUT" || method === "PATCH") && hasId) return "update";
154
+ if (method === "DELETE" && hasId) return "delete";
155
+
156
+ return kebabCase(method);
157
+ }
158
+
159
+ function inferRpcAction(op: NormalizedOperation): string {
160
+ // Prefer operationId suffix: Contacts.List -> list
161
+ if (op.operationId) {
162
+ const { suffix } = splitOperationId(op.operationId);
163
+ if (suffix) return canonicalizeAction(suffix);
164
+ }
165
+
166
+ // Else take last segment and split by '.'
167
+ const segments = getPathSegments(op.path);
168
+ const last = segments[segments.length - 1] ?? "";
169
+ if (last.includes(".")) {
170
+ const part = last.split(".").pop() ?? last;
171
+ return canonicalizeAction(part);
172
+ }
173
+
174
+ return kebabCase(op.method);
175
+ }
176
+
177
+ export function planOperation(op: NormalizedOperation): PlannedOperation {
178
+ const style = inferStyle(op);
179
+ const resource = inferResource(op);
180
+ const action = style === "rpc" ? inferRpcAction(op) : inferRestAction(op);
181
+
182
+ return {
183
+ ...op,
184
+ key: op.key,
185
+ style,
186
+ resource,
187
+ action,
188
+ canonicalAction: action,
189
+ pathArgs: getPathArgs(op.path).map((a) => kebabCase(a)),
190
+ };
191
+ }
192
+
193
+ export function planOperations(ops: NormalizedOperation[]): PlannedOperation[] {
194
+ const planned = ops.map(planOperation);
195
+
196
+ // Stable collision handling: if resource+action repeats, add a suffix.
197
+ const counts = new Map<string, number>();
198
+ for (const op of planned) {
199
+ const key = `${op.resource}:${op.action}`;
200
+ counts.set(key, (counts.get(key) ?? 0) + 1);
201
+ }
202
+
203
+ const seen = new Map<string, number>();
204
+ return planned.map((op) => {
205
+ const key = `${op.resource}:${op.action}`;
206
+ const total = counts.get(key) ?? 0;
207
+ if (total <= 1) return op;
208
+
209
+ const idx = (seen.get(key) ?? 0) + 1;
210
+ seen.set(key, idx);
211
+
212
+ const suffix = op.operationId
213
+ ? kebabCase(op.operationId)
214
+ : kebabCase(`${op.method}-${op.path}`);
215
+
216
+ const disambiguatedAction = `${op.action}-${suffix}-${idx}`;
217
+
218
+ return {
219
+ ...op,
220
+ action: disambiguatedAction,
221
+ aliasOf: `${op.resource} ${op.canonicalAction}`,
222
+ };
223
+ });
224
+ }
@@ -0,0 +1,57 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { indexOperations } from "./operations.ts";
4
+ import type { OpenApiDoc } from "./types.ts";
5
+
6
+ describe("indexOperations", () => {
7
+ test("indexes basic operations", () => {
8
+ const doc: OpenApiDoc = {
9
+ openapi: "3.0.3",
10
+ paths: {
11
+ "/contacts": {
12
+ get: {
13
+ operationId: "Contacts.List",
14
+ tags: ["Contacts"],
15
+ parameters: [
16
+ {
17
+ in: "query",
18
+ name: "limit",
19
+ schema: { type: "integer" },
20
+ },
21
+ ],
22
+ },
23
+ },
24
+ "/contacts/{id}": {
25
+ get: {
26
+ operationId: "Contacts.Get",
27
+ tags: ["Contacts"],
28
+ parameters: [
29
+ {
30
+ in: "path",
31
+ name: "id",
32
+ required: true,
33
+ schema: { type: "string" },
34
+ },
35
+ ],
36
+ },
37
+ },
38
+ },
39
+ };
40
+
41
+ const ops = indexOperations(doc);
42
+ expect(ops).toHaveLength(2);
43
+
44
+ expect(ops[0]?.key).toBe("GET /contacts");
45
+ expect(ops[0]?.path).toBe("/contacts");
46
+ expect(ops[0]?.method).toBe("GET");
47
+ expect(ops[0]?.parameters).toHaveLength(1);
48
+ expect(ops[0]?.parameters[0]?.in).toBe("query");
49
+
50
+ expect(ops[1]?.key).toBe("GET /contacts/{id}");
51
+ expect(ops[1]?.path).toBe("/contacts/{id}");
52
+ expect(ops[1]?.method).toBe("GET");
53
+ expect(ops[1]?.parameters).toHaveLength(1);
54
+ expect(ops[1]?.parameters[0]?.in).toBe("path");
55
+ expect(ops[1]?.parameters[0]?.required).toBe(true);
56
+ });
57
+ });
@@ -0,0 +1,152 @@
1
+ import type {
2
+ NormalizedOperation,
3
+ NormalizedParameter,
4
+ NormalizedRequestBody,
5
+ OpenApiDoc,
6
+ } from "./types.ts";
7
+
8
+ function operationKey(method: string, path: string): string {
9
+ return `${method.toUpperCase()} ${path}`;
10
+ }
11
+
12
+ const HTTP_METHODS = [
13
+ "get",
14
+ "post",
15
+ "put",
16
+ "patch",
17
+ "delete",
18
+ "options",
19
+ "head",
20
+ "trace",
21
+ ] as const;
22
+
23
+ type RawParameter = {
24
+ in?: string;
25
+ name?: string;
26
+ required?: boolean;
27
+ description?: string;
28
+ schema?: unknown;
29
+ };
30
+
31
+ type RawRequestBody = {
32
+ required?: boolean;
33
+ content?: Record<string, { schema?: unknown } | undefined>;
34
+ };
35
+
36
+ type RawOperation = {
37
+ operationId?: string;
38
+ tags?: string[];
39
+ summary?: string;
40
+ description?: string;
41
+ deprecated?: boolean;
42
+ security?: OpenApiDoc["security"];
43
+ parameters?: RawParameter[];
44
+ requestBody?: RawRequestBody;
45
+ };
46
+
47
+ type RawPathItem = {
48
+ parameters?: RawParameter[];
49
+ } & Partial<Record<(typeof HTTP_METHODS)[number], RawOperation>>;
50
+
51
+ function normalizeParam(p: RawParameter): NormalizedParameter | undefined {
52
+ if (!p || typeof p !== "object") return undefined;
53
+ const loc = p.in;
54
+ const name = p.name;
55
+ if (
56
+ loc !== "path" &&
57
+ loc !== "query" &&
58
+ loc !== "header" &&
59
+ loc !== "cookie"
60
+ ) {
61
+ return undefined;
62
+ }
63
+ if (!name) return undefined;
64
+
65
+ return {
66
+ in: loc,
67
+ name,
68
+ required: Boolean(p.required || loc === "path"),
69
+ description: p.description,
70
+ schema: p.schema,
71
+ };
72
+ }
73
+
74
+ function mergeParameters(
75
+ pathParams: RawParameter[] | undefined,
76
+ opParams: RawParameter[] | undefined,
77
+ ): NormalizedParameter[] {
78
+ const merged = new Map<string, NormalizedParameter>();
79
+
80
+ for (const p of pathParams ?? []) {
81
+ const normalized = normalizeParam(p);
82
+ if (!normalized) continue;
83
+ merged.set(`${normalized.in}:${normalized.name}`, normalized);
84
+ }
85
+
86
+ for (const p of opParams ?? []) {
87
+ const normalized = normalizeParam(p);
88
+ if (!normalized) continue;
89
+ merged.set(`${normalized.in}:${normalized.name}`, normalized);
90
+ }
91
+
92
+ return [...merged.values()];
93
+ }
94
+
95
+ function normalizeRequestBody(
96
+ rb: RawRequestBody | undefined,
97
+ ): NormalizedRequestBody | undefined {
98
+ if (!rb) return undefined;
99
+
100
+ const content = rb.content ?? {};
101
+ const contentTypes = Object.keys(content);
102
+ const schemasByContentType: Record<string, unknown | undefined> = {};
103
+
104
+ for (const contentType of contentTypes) {
105
+ schemasByContentType[contentType] = content[contentType]?.schema;
106
+ }
107
+
108
+ return {
109
+ required: Boolean(rb.required),
110
+ contentTypes,
111
+ schemasByContentType,
112
+ };
113
+ }
114
+
115
+ export function indexOperations(doc: OpenApiDoc): NormalizedOperation[] {
116
+ const out: NormalizedOperation[] = [];
117
+ const paths = doc.paths ?? {};
118
+
119
+ for (const [path, rawPathItem] of Object.entries(paths)) {
120
+ if (!rawPathItem || typeof rawPathItem !== "object") continue;
121
+
122
+ const pathItem = rawPathItem as RawPathItem;
123
+
124
+ for (const method of HTTP_METHODS) {
125
+ const op = pathItem[method];
126
+ if (!op) continue;
127
+
128
+ const parameters = mergeParameters(pathItem.parameters, op.parameters);
129
+ const normalizedMethod = method.toUpperCase();
130
+ out.push({
131
+ key: operationKey(normalizedMethod, path),
132
+ method: normalizedMethod,
133
+ path,
134
+ operationId: op.operationId,
135
+ tags: op.tags ?? [],
136
+ summary: op.summary,
137
+ description: op.description,
138
+ deprecated: op.deprecated,
139
+ security: (op.security ?? doc.security) as OpenApiDoc["security"],
140
+ parameters,
141
+ requestBody: normalizeRequestBody(op.requestBody),
142
+ });
143
+ }
144
+ }
145
+
146
+ out.sort((a, b) => {
147
+ if (a.path !== b.path) return a.path.localeCompare(b.path);
148
+ return a.method.localeCompare(b.method);
149
+ });
150
+
151
+ return out;
152
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { deriveParamSpecs } from "./params.ts";
4
+ import type { NormalizedOperation } from "./types.ts";
5
+
6
+ describe("deriveParamSpecs", () => {
7
+ test("derives basic types + flags", () => {
8
+ const op: NormalizedOperation = {
9
+ key: "GET /contacts",
10
+ method: "GET",
11
+ path: "/contacts",
12
+ tags: [],
13
+ parameters: [
14
+ {
15
+ in: "query",
16
+ name: "limit",
17
+ required: false,
18
+ schema: {
19
+ type: "integer",
20
+ format: "int32",
21
+ enum: ["1", "2"],
22
+ },
23
+ },
24
+ {
25
+ in: "header",
26
+ name: "X-Request-Id",
27
+ required: false,
28
+ schema: { type: "string" },
29
+ },
30
+ ],
31
+ };
32
+
33
+ const specs = deriveParamSpecs(op);
34
+ expect(specs).toHaveLength(2);
35
+
36
+ const limit = specs.find((p) => p.name === "limit");
37
+ expect(limit?.kind).toBe("flag");
38
+ expect(limit?.flag).toBe("--limit");
39
+ expect(limit?.type).toBe("integer");
40
+ expect(limit?.format).toBe("int32");
41
+ expect(limit?.enum).toEqual(["1", "2"]);
42
+
43
+ const reqId = specs.find((p) => p.name === "X-Request-Id");
44
+ expect(reqId?.kind).toBe("flag");
45
+ expect(reqId?.flag).toBe("--x-request-id");
46
+ expect(reqId?.type).toBe("string");
47
+ });
48
+
49
+ test("derives array item types", () => {
50
+ const op: NormalizedOperation = {
51
+ key: "GET /things",
52
+ method: "GET",
53
+ path: "/things",
54
+ tags: [],
55
+ parameters: [
56
+ {
57
+ in: "query",
58
+ name: "ids",
59
+ required: false,
60
+ schema: { type: "array", items: { type: "integer" } },
61
+ },
62
+ ],
63
+ };
64
+
65
+ const specs = deriveParamSpecs(op);
66
+ expect(specs).toHaveLength(1);
67
+ expect(specs[0]?.type).toBe("array");
68
+ expect(specs[0]?.itemType).toBe("integer");
69
+ });
70
+ });
@@ -0,0 +1,71 @@
1
+ import {
2
+ getSchemaEnumStrings,
3
+ getSchemaFormat,
4
+ getSchemaType,
5
+ } from "./schema-shape.ts";
6
+ import { kebabCase } from "./strings.ts";
7
+ import type { NormalizedOperation, NormalizedParameter } from "./types.ts";
8
+
9
+ export type ParamType = import("./schema-shape.ts").ParamType;
10
+
11
+ export type ParamSpec = {
12
+ kind: "positional" | "flag";
13
+ in: NormalizedParameter["in"];
14
+ name: string;
15
+ flag: string;
16
+ required: boolean;
17
+ description?: string;
18
+ type: ParamType;
19
+ format?: string;
20
+ enum?: string[];
21
+
22
+ // Arrays
23
+ itemType?: ParamType;
24
+ itemFormat?: string;
25
+ itemEnum?: string[];
26
+
27
+ // Original schema for Ajv validation and future advanced flag expansion.
28
+ schema?: import("./types.ts").JsonSchema;
29
+ };
30
+
31
+ export function deriveParamSpecs(op: NormalizedOperation): ParamSpec[] {
32
+ const out: ParamSpec[] = [];
33
+
34
+ for (const p of op.parameters) {
35
+ const flag = `--${kebabCase(p.name)}`;
36
+ const type = getSchemaType(p.schema);
37
+ const schemaObj =
38
+ p.schema && typeof p.schema === "object"
39
+ ? (p.schema as import("./types.ts").JsonSchema)
40
+ : undefined;
41
+
42
+ const itemsSchema =
43
+ schemaObj && type === "array" && typeof schemaObj.items === "object"
44
+ ? (schemaObj.items as unknown)
45
+ : undefined;
46
+
47
+ out.push({
48
+ kind: p.in === "path" ? "positional" : "flag",
49
+ in: p.in,
50
+ name: p.name,
51
+ flag,
52
+ required: p.required,
53
+ description: p.description,
54
+ type,
55
+ format: getSchemaFormat(p.schema),
56
+ enum: getSchemaEnumStrings(p.schema),
57
+ itemType: type === "array" ? getSchemaType(itemsSchema) : undefined,
58
+ itemFormat: type === "array" ? getSchemaFormat(itemsSchema) : undefined,
59
+ itemEnum:
60
+ type === "array" ? getSchemaEnumStrings(itemsSchema) : undefined,
61
+ schema: schemaObj,
62
+ });
63
+ }
64
+
65
+ out.sort((a, b) => {
66
+ if (a.in !== b.in) return a.in.localeCompare(b.in);
67
+ return a.name.localeCompare(b.name);
68
+ });
69
+
70
+ return out;
71
+ }
@@ -0,0 +1,41 @@
1
+ const IRREGULAR: Record<string, string> = {
2
+ person: "people",
3
+ man: "men",
4
+ woman: "women",
5
+ child: "children",
6
+ tooth: "teeth",
7
+ foot: "feet",
8
+ mouse: "mice",
9
+ goose: "geese",
10
+ };
11
+
12
+ const UNCOUNTABLE = new Set([
13
+ "metadata",
14
+ "information",
15
+ "equipment",
16
+ "money",
17
+ "series",
18
+ "species",
19
+ ]);
20
+
21
+ export function pluralize(word: string): string {
22
+ const w = word.trim();
23
+ if (!w) return w;
24
+
25
+ const lower = w.toLowerCase();
26
+ if (UNCOUNTABLE.has(lower)) return lower;
27
+ if (IRREGULAR[lower]) return IRREGULAR[lower];
28
+
29
+ // already plural-ish
30
+ if (lower.endsWith("s")) return lower;
31
+
32
+ if (/[bcdfghjklmnpqrstvwxyz]y$/.test(lower)) {
33
+ return lower.replace(/y$/, "ies");
34
+ }
35
+
36
+ if (/(ch|sh|x|z)$/.test(lower)) {
37
+ return `${lower}es`;
38
+ }
39
+
40
+ return `${lower}s`;
41
+ }