diff --git a/src/cli/runtime/body-flags.test.ts b/src/cli/runtime/body-flags.test.ts index 9359682..12e6ece 100644 --- a/src/cli/runtime/body-flags.test.ts +++ b/src/cli/runtime/body-flags.test.ts @@ -2,10 +2,157 @@ import { describe, expect, test } from "bun:test"; import { findMissingRequired, + flattenSchema, generateBodyFlags, parseDotNotationFlags, } from "./body-flags.js"; +describe("flattenSchema", () => { + test("allOf merges properties and unions required", () => { + const result = flattenSchema({ + required: ["parentReq"], + allOf: [ + { + type: "object", + properties: { + name: { type: "string" }, + parentReq: { type: "string" }, + }, + required: ["name"], + }, + { + type: "object", + properties: { age: { type: "integer" } }, + required: ["age"], + }, + ], + }); + + expect(result.type).toBe("object"); + expect(Object.keys(result.properties ?? {})).toEqual( + expect.arrayContaining(["name", "age", "parentReq"]), + ); + expect(result.required).toEqual( + expect.arrayContaining(["name", "age", "parentReq"]), + ); + }); + + test("oneOf merges properties, intersects required, combines enums", () => { + const result = flattenSchema({ + oneOf: [ + { + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + kind: { type: "string", enum: ["a"] }, + }, + required: ["name", "email"], + }, + { + type: "object", + properties: { + name: { type: "string" }, + phone: { type: "string" }, + kind: { type: "string", enum: ["b"] }, + }, + required: ["name"], + }, + ], + }); + + expect(Object.keys(result.properties ?? {})).toEqual( + expect.arrayContaining(["name", "email", "phone", "kind"]), + ); + // required = intersection + expect(result.required).toEqual(["name"]); + // enums combined + expect(result.properties?.kind?.enum).toEqual( + expect.arrayContaining(["a", "b"]), + ); + }); + + test("oneOf/anyOf merge parent-level properties and required", () => { + for (const key of ["oneOf", "anyOf"] as const) { + const result = flattenSchema({ + required: ["shared"], + properties: { shared: { type: "string" } }, + [key]: [ + { type: "object", properties: { x: { type: "string" } } }, + { type: "object", properties: { y: { type: "integer" } } }, + ], + }); + + expect(result.properties?.shared).toEqual({ type: "string" }); + expect(result.properties?.x).toBeDefined(); + expect(result.required).toEqual(expect.arrayContaining(["shared"])); + } + }); + + test("type conflict across oneOf branches falls back to string", () => { + const result = flattenSchema({ + oneOf: [ + { type: "object", properties: { value: { type: "number" } } }, + { type: "object", properties: { value: { type: "boolean" } } }, + ], + }); + + expect(result.properties?.value?.type).toBe("string"); + }); + + test("flat schema passes through unchanged", () => { + const schema = { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }; + expect(flattenSchema(schema)).toBe(schema); + }); + + test("discriminated oneOf with allOf branches", () => { + const result = flattenSchema({ + oneOf: [ + { + allOf: [ + { + type: "object", + properties: { + name: { type: "string" }, + prompt: { type: "string" }, + type: { type: "string", enum: ["text"] }, + }, + required: ["name", "prompt", "type"], + }, + ], + }, + { + allOf: [ + { + type: "object", + properties: { + name: { type: "string" }, + prompt: { type: "array" }, + type: { type: "string", enum: ["chat"] }, + }, + required: ["name", "prompt", "type"], + }, + ], + }, + ], + }); + + expect(Object.keys(result.properties ?? {})).toEqual( + expect.arrayContaining(["name", "prompt", "type"]), + ); + expect(result.properties?.type?.enum).toEqual( + expect.arrayContaining(["text", "chat"]), + ); + expect(result.required).toEqual( + expect.arrayContaining(["name", "prompt", "type"]), + ); + }); +}); + describe("generateBodyFlags", () => { test("generates flags for simple properties", () => { const flags = generateBodyFlags( @@ -58,7 +205,6 @@ describe("generateBodyFlags", () => { ); expect(flags).toHaveLength(4); - expect(flags.find((f) => f.flag === "--name")).toBeDefined(); expect(flags.find((f) => f.flag === "--address.street")).toEqual({ flag: "--address.street", path: ["address", "street"], @@ -78,9 +224,7 @@ describe("generateBodyFlags", () => { properties: { profile: { type: "object", - properties: { - bio: { type: "string" }, - }, + properties: { bio: { type: "string" } }, }, }, }, @@ -89,13 +233,9 @@ describe("generateBodyFlags", () => { new Set(), ); - expect(flags.find((f) => f.flag === "--user.profile.bio")).toEqual({ - flag: "--user.profile.bio", - path: ["user", "profile", "bio"], - type: "string", - description: "Body field 'user.profile.bio'", - required: false, - }); + expect(flags.find((f) => f.flag === "--user.profile.bio")?.type).toBe( + "string", + ); }); test("skips reserved flags", () => { @@ -104,69 +244,189 @@ describe("generateBodyFlags", () => { type: "object", properties: { name: { type: "string" }, - data: { type: "string" }, // --data is reserved + data: { type: "string" }, + curl: { type: "boolean" }, }, }, - new Set(["--data"]), + new Set(["--data", "--curl"]), ); expect(flags).toHaveLength(1); expect(flags[0]?.flag).toBe("--name"); }); - test("skips --curl builtin flag", () => { - const reservedFlags = new Set(["--curl"]); + test("uses description from schema", () => { + const flags = generateBodyFlags( + { + type: "object", + properties: { + email: { type: "string", description: "User email address" }, + }, + }, + new Set(), + ); + + expect(flags[0]?.description).toBe("User email address"); + }); + test("array and opaque object types", () => { const flags = generateBodyFlags( { type: "object", properties: { - name: { type: "string" }, - curl: { type: "boolean" }, // conflicts with --curl builtin - email: { type: "string" }, // no conflict + tags: { type: "array", items: { type: "string" } }, + metadata: { type: "object" }, + config: { nullable: true, description: "Optional config" }, + ids: { + type: "array", + items: { type: "string" }, + description: "List of IDs", + }, }, }, - reservedFlags, + new Set(), ); - expect(flags).toHaveLength(2); - expect(flags.map((f) => f.flag).sort()).toEqual(["--email", "--name"]); + expect(flags.find((f) => f.flag === "--tags")).toMatchObject({ + type: "array", + }); + expect(flags.find((f) => f.flag === "--metadata")).toMatchObject({ + type: "json", + }); + expect(flags.find((f) => f.flag === "--config")).toMatchObject({ + type: "json", + description: "Optional config", + }); + expect(flags.find((f) => f.flag === "--ids")?.description).toBe( + "List of IDs", + ); }); - test("uses description from schema", () => { + test("property-level allOf flattens into dot-notation flags", () => { const flags = generateBodyFlags( { type: "object", properties: { - email: { type: "string", description: "User email address" }, + address: { + allOf: [ + { type: "object", properties: { street: { type: "string" } } }, + { type: "object", properties: { city: { type: "string" } } }, + ], + }, }, }, new Set(), ); - expect(flags[0]?.description).toBe("User email address"); + expect(flags.find((f) => f.flag === "--address.street")).toBeDefined(); + expect(flags.find((f) => f.flag === "--address.city")).toBeDefined(); + }); + + test("property-level oneOf type conflict generates string flag", () => { + const flags = generateBodyFlags( + { + type: "object", + properties: { + value: { + description: "A flexible value", + oneOf: [{ type: "string" }, { type: "integer" }], + }, + }, + }, + new Set(), + ); + + expect(flags).toHaveLength(1); + expect(flags[0]).toMatchObject({ + type: "string", + description: "A flexible value", + }); + }); + + test("discriminated union with allOf branches", () => { + const flags = generateBodyFlags( + { + oneOf: [ + { + allOf: [ + { + type: "object", + properties: { + name: { type: "string", description: "Name" }, + prompt: { type: "string" }, + type: { type: "string", enum: ["text"] }, + config: { type: "object" }, + labels: { type: "array", items: { type: "string" } }, + }, + required: ["name", "prompt", "type"], + }, + ], + }, + { + allOf: [ + { + type: "object", + properties: { + name: { type: "string", description: "Name" }, + prompt: { type: "array", items: { type: "object" } }, + type: { type: "string", enum: ["chat"] }, + config: { type: "object" }, + labels: { type: "array", items: { type: "string" } }, + }, + required: ["name", "prompt", "type"], + }, + ], + }, + ], + // biome-ignore lint/suspicious/noExplicitAny: JsonSchema is not exported + } as any, + new Set(), + ); + + expect(flags.map((f) => f.flag).sort()).toEqual( + expect.arrayContaining([ + "--config", + "--labels", + "--name", + "--prompt", + "--type", + ]), + ); + expect(flags.find((f) => f.flag === "--name")).toMatchObject({ + required: true, + }); + expect(flags.find((f) => f.flag === "--type")).toMatchObject({ + type: "string", + }); + expect(flags.find((f) => f.flag === "--config")).toMatchObject({ + type: "json", + }); + expect(flags.find((f) => f.flag === "--labels")).toMatchObject({ + type: "array", + }); }); }); describe("parseDotNotationFlags", () => { - test("parses flat flags", () => { + test("parses flat flags with type coercion", () => { const flagDefs = generateBodyFlags( { type: "object", properties: { name: { type: "string" }, age: { type: "integer" }, + active: { type: "boolean" }, }, }, new Set(), ); - const result = parseDotNotationFlags({ name: "Ada", age: "30" }, flagDefs); + const result = parseDotNotationFlags( + { name: "Ada", age: "30", active: true }, + flagDefs, + ); - expect(result).toEqual({ - name: "Ada", - age: 30, - }); + expect(result).toEqual({ name: "Ada", age: 30, active: true }); }); test("parses nested flags into objects", () => { @@ -187,75 +447,77 @@ describe("parseDotNotationFlags", () => { new Set(), ); - // Commander keeps dots: --address.street -> "address.street" const result = parseDotNotationFlags( - { - name: "Ada", - "address.street": "123 Main", - "address.city": "NYC", - }, + { name: "Ada", "address.street": "123 Main", "address.city": "NYC" }, flagDefs, ); expect(result).toEqual({ name: "Ada", - address: { - street: "123 Main", - city: "NYC", - }, + address: { street: "123 Main", city: "NYC" }, }); }); - test("handles boolean flags", () => { + test("array: JSON string, comma-separated, pre-parsed, bad JSON", () => { const flagDefs = generateBodyFlags( { type: "object", - properties: { - active: { type: "boolean" }, - }, + properties: { tags: { type: "array", items: { type: "string" } } }, }, new Set(), ); - const result = parseDotNotationFlags({ active: true }, flagDefs); - - expect(result).toEqual({ active: true }); + expect(parseDotNotationFlags({ tags: '["a", "b"]' }, flagDefs)).toEqual({ + tags: ["a", "b"], + }); + expect(parseDotNotationFlags({ tags: "a,b,c" }, flagDefs)).toEqual({ + tags: ["a", "b", "c"], + }); + expect( + parseDotNotationFlags({ tags: ["already", "parsed"] }, flagDefs), + ).toEqual({ + tags: ["already", "parsed"], + }); + // bad JSON falls back to comma-split + expect(parseDotNotationFlags({ tags: "[bad json" }, flagDefs).tags).toEqual( + ["[bad json"], + ); }); -}); -describe("findMissingRequired", () => { - test("finds missing required fields", () => { + test("json: parses valid JSON, falls back to string", () => { const flagDefs = generateBodyFlags( { type: "object", - properties: { - name: { type: "string" }, - email: { type: "string" }, - }, - required: ["name", "email"], + properties: { metadata: { type: "object" } }, }, new Set(), ); - const missing = findMissingRequired({ name: "Ada" }, flagDefs); - - expect(missing).toEqual(["email"]); + expect(parseDotNotationFlags({ metadata: '{"k": "v"}' }, flagDefs)).toEqual( + { + metadata: { k: "v" }, + }, + ); + expect(parseDotNotationFlags({ metadata: "not-json" }, flagDefs)).toEqual({ + metadata: "not-json", + }); }); +}); - test("returns empty when all required fields present", () => { +describe("findMissingRequired", () => { + test("finds missing required fields", () => { const flagDefs = generateBodyFlags( { type: "object", - properties: { - name: { type: "string" }, - }, - required: ["name"], + properties: { name: { type: "string" }, email: { type: "string" } }, + required: ["name", "email"], }, new Set(), ); - const missing = findMissingRequired({ name: "Ada" }, flagDefs); - - expect(missing).toEqual([]); + expect(findMissingRequired({ name: "Ada" }, flagDefs)).toEqual(["email"]); + expect( + findMissingRequired({ name: "Ada", email: "a@b.c" }, flagDefs), + ).toEqual([]); }); }); diff --git a/src/cli/runtime/body-flags.ts b/src/cli/runtime/body-flags.ts index 0d3930f..9d53993 100644 --- a/src/cli/runtime/body-flags.ts +++ b/src/cli/runtime/body-flags.ts @@ -11,32 +11,153 @@ type JsonSchema = { items?: JsonSchema; required?: string[]; description?: string; + enum?: unknown[]; + allOf?: JsonSchema[]; + oneOf?: JsonSchema[]; + anyOf?: JsonSchema[]; + nullable?: boolean; }; export type BodyFlagDef = { flag: string; // e.g. "--name" or "--address.street" path: string[]; // e.g. ["name"] or ["address", "street"] - type: "string" | "number" | "integer" | "boolean"; + type: "string" | "number" | "integer" | "boolean" | "array" | "json"; description: string; required: boolean; }; +// ── Schema flattening (discriminated unions) ────────────────────── + +/** + * Merge properties from an array of sub-schemas (allOf). + * Duplicates are safe to skip — allOf requires all branches to validate, + * so same-named properties must be compatible. We pick the first and let + * Ajv enforce the full constraints against the original schema. + * Required = union of all. + */ +function mergeAllOf(schemas: JsonSchema[]): JsonSchema { + const props: Record = {}; + const requiredSet = new Set(); + + for (const sub of schemas) { + const resolved = flattenSchema(sub); + if (resolved.properties) { + for (const [k, v] of Object.entries(resolved.properties)) { + if (!props[k]) props[k] = v; + } + } + if (resolved.required) { + for (const r of resolved.required) requiredSet.add(r); + } + } + + return { type: "object", properties: props, required: [...requiredSet] }; +} + +/** + * Merge a property definition that appears in multiple oneOf/anyOf branches. + * Same type + single-value enums → combine enums. + * Same type → keep first. Type conflict → string fallback. + */ +function mergePropertyAcrossBranches(a: JsonSchema, b: JsonSchema): JsonSchema { + const typeA = a.type ?? "string"; + const typeB = b.type ?? "string"; + + if (typeA !== typeB) { + return { type: "string", description: a.description ?? b.description }; + } + + // combine enums if both have them + if (a.enum && b.enum) { + const combined = [...new Set([...a.enum, ...b.enum])]; + return { ...a, enum: combined }; + } + + return a; +} + +/** + * Merge properties across oneOf/anyOf branches. + * Required = intersection of all branches' required sets. + */ +function mergeOneOf(branches: JsonSchema[]): JsonSchema { + const resolved = branches.map(flattenSchema); + + // merge types directly if all branches are non-object primitives + const allPrimitive = resolved.every( + (r) => r.type && r.type !== "object" && !r.properties, + ); + if (allPrimitive) { + return resolved.reduce((a, b) => mergePropertyAcrossBranches(a, b)); + } + + const props: Record = {}; + const requiredSets: Set[] = []; + + for (const r of resolved) { + if (r.properties) { + for (const [k, v] of Object.entries(r.properties)) { + props[k] = props[k] ? mergePropertyAcrossBranches(props[k], v) : v; + } + } + requiredSets.push(new Set(r.required ?? [])); + } + + // Required = intersection of all branches + let required: string[] = []; + if (requiredSets.length > 0) { + let intersection = requiredSets[0] ?? new Set(); + for (let i = 1; i < requiredSets.length; i++) { + const s = requiredSets[i]; + if (s) intersection = new Set([...intersection].filter((r) => s.has(r))); + } + required = [...intersection]; + } + + return { type: "object", properties: props, required }; +} + +/** + * Flatten a schema that uses allOf/oneOf/anyOf into a single object schema + * with merged properties. Passes through plain schemas unchanged. + */ +export function flattenSchema(schema: JsonSchema): JsonSchema { + const branches = schema.allOf ?? schema.oneOf ?? schema.anyOf; + if (!branches) return schema; + + const result = schema.allOf ? mergeAllOf(branches) : mergeOneOf(branches); + + // Parent may have its own properties/required alongside the composition + if (schema.properties) { + result.properties = { ...result.properties, ...schema.properties }; + } + if (schema.required) { + const reqSet = new Set([...(result.required ?? []), ...schema.required]); + result.required = [...reqSet]; + } + return result; +} + +// ── Flag generation ────────────────────────────────────────────── + /** * Generate flag definitions from a JSON schema. * Recursively handles nested objects using dot notation. + * Flattens discriminated unions (oneOf/allOf/anyOf) first. */ export function generateBodyFlags( schema: JsonSchema | undefined, reservedFlags: Set, ): BodyFlagDef[] { - if (!schema || schema.type !== "object" || !schema.properties) { - return []; - } + if (!schema) return []; + + const resolved = flattenSchema(schema); + if (resolved.type !== "object" || !resolved.properties) return []; const flags: BodyFlagDef[] = []; - const requiredSet = new Set(schema.required ?? []); + const requiredSet = new Set(resolved.required ?? []); - collectFlags(schema.properties, [], requiredSet, flags, reservedFlags); + collectFlags(resolved.properties, [], requiredSet, flags, reservedFlags); return flags; } @@ -58,13 +179,18 @@ function collectFlags( // Skip if this flag would conflict with an operation parameter if (reservedFlags.has(flagName)) continue; - const t = propSchema.type; + // Flatten composition (oneOf/allOf/anyOf) at the property level + const resolved = flattenSchema(propSchema); + const desc = propSchema.description ?? resolved.description; + const t = resolved.type; + const isRequired = + pathPrefix.length === 0 ? requiredAtRoot.has(name) : false; - if (t === "object" && propSchema.properties) { + if (t === "object" && resolved.properties) { // Recurse into nested object - const nestedRequired = new Set(propSchema.required ?? []); + const nestedRequired = new Set(resolved.required ?? []); collectFlags( - propSchema.properties, + resolved.properties, path, nestedRequired, out, @@ -76,19 +202,33 @@ function collectFlags( t === "integer" || t === "boolean" ) { - // Leaf property - generate a flag - const isRequired = - pathPrefix.length === 0 ? requiredAtRoot.has(name) : false; - out.push({ flag: flagName, path, type: t, - description: propSchema.description ?? `Body field '${path.join(".")}'`, + description: desc ?? `Body field '${path.join(".")}'`, + required: isRequired, + }); + } else if (t === "array") { + out.push({ + flag: flagName, + path, + type: "array", + description: + desc ?? + `Body field '${path.join(".")}' (JSON array or comma-separated)`, + required: isRequired, + }); + } else if ((t === "object" && !resolved.properties) || !t) { + // Opaque object or typeless schema (e.g. nullable: true) — accept JSON + out.push({ + flag: flagName, + path, + type: "json", + description: desc ?? `Body field '${path.join(".")}' (JSON)`, required: isRequired, }); } - // Skip arrays and other complex types for now } } @@ -147,6 +287,35 @@ function setNestedValue( current[finalKey] = Number.parseInt(String(value), 10); } else if (type === "number") { current[finalKey] = Number(String(value)); + } else if (type === "array") { + // Already an array (e.g. from Commander accumulator) — pass through + if (Array.isArray(value)) { + current[finalKey] = value; + } else { + const trimmed = String(value).trim(); + if (trimmed.startsWith("[")) { + try { + current[finalKey] = JSON.parse(trimmed); + } catch { + // Bad JSON — fall back to comma-split + current[finalKey] = trimmed + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } + } else { + current[finalKey] = trimmed + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } + } + } else if (type === "json") { + try { + current[finalKey] = JSON.parse(String(value)); + } catch { + current[finalKey] = String(value); + } } else { current[finalKey] = String(value); } diff --git a/src/cli/runtime/generated.ts b/src/cli/runtime/generated.ts index dd8aff1..6aff545 100644 --- a/src/cli/runtime/generated.ts +++ b/src/cli/runtime/generated.ts @@ -200,6 +200,32 @@ export function addGeneratedCommands( for (const def of bodyFlagDefs) { if (def.type === "boolean") { cmd.option(def.flag, def.description); + } else if (def.type === "array") { + cmd.option( + `${def.flag} `, + def.description, + (value: string, prev: unknown[] | undefined) => { + const next = [...(prev ?? [])]; + const trimmed = value.trim(); + if (trimmed.startsWith("[")) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) next.push(...parsed); + else next.push(value); + } catch { + next.push(value); + } + } else { + next.push( + ...trimmed + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); + } + return next; + }, + ); } else { cmd.option(`${def.flag} `, def.description); }