diff --git a/.changeset/new-docs-glow.md b/.changeset/new-docs-glow.md new file mode 100644 index 0000000..9943076 --- /dev/null +++ b/.changeset/new-docs-glow.md @@ -0,0 +1,5 @@ +--- +"@typesensekit/cli": patch +--- + +Add operation-level `--schema` and `--examples` helpers for discovering command input shapes. diff --git a/packages/cli/src/operation-docs.test.ts b/packages/cli/src/operation-docs.test.ts new file mode 100644 index 0000000..d776393 --- /dev/null +++ b/packages/cli/src/operation-docs.test.ts @@ -0,0 +1,50 @@ +import { operations } from "@typesensekit/core"; +import { describe, expect, it } from "vitest"; +import { + renderInputSchema, + renderOperationExamples, +} from "./operation-docs.js"; + +function operationInput(name: string) { + const operation = operations.find((candidate) => candidate.name === name); + if (!operation) throw new Error(`${name} not found`); + return operation.input; +} + +describe("operation docs", () => { + it("renders the top-level input shape for documents.search", () => { + expect( + JSON.parse(renderInputSchema(operationInput("documents.search"))), + ).toEqual({ + type: "object", + properties: { + collection: { type: "string" }, + params: { + type: "object", + additionalProperties: { + anyOf: [ + { type: "string" }, + { type: "number" }, + { type: "boolean" }, + { type: "array", items: { type: "string" } }, + { type: "array", items: { type: "number" } }, + ], + }, + }, + }, + required: ["collection", "params"], + }); + }); + + it("renders command-specific examples for common operations", () => { + expect(renderOperationExamples("documents.search")).toContain( + `tsk documents.search --input '{"collection":"products","params":{"q":"chair","query_by":"title,description"}}' --json`, + ); + expect(renderOperationExamples("presets.create")).toContain( + `tsk presets.create --input '{"name":"Semantic","value":{"query_by":"title_embedding"}}' --json`, + ); + expect(renderOperationExamples("api.call")).toContain( + `tsk api.call --input '{"method":"get","path":"/collections"}' --json`, + ); + }); +}); diff --git a/packages/cli/src/operation-docs.ts b/packages/cli/src/operation-docs.ts new file mode 100644 index 0000000..97b22ca --- /dev/null +++ b/packages/cli/src/operation-docs.ts @@ -0,0 +1,186 @@ +import { z } from "zod"; + +type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +type JsonSchema = { + type?: string; + enum?: string[]; + anyOf?: JsonSchema[]; + items?: JsonSchema; + additionalProperties?: JsonSchema | boolean; + properties?: Record; + required?: string[]; +}; + +const EXAMPLES: Record = { + "api.call": [ + { + method: "get", + path: "/collections", + }, + { + method: "put", + path: "/presets/Semantic", + body: { value: { query_by: "title_embedding" } }, + }, + ], + "collections.create": [ + { + name: "products", + fields: [ + { name: "title", type: "string" }, + { name: "price", type: "float", optional: true }, + ], + }, + ], + "collections.fields.add": [ + { + collection: "products", + field: "title_embedding", + type: "float[]", + numDim: 1536, + embedFrom: "title", + embedModel: "openai/text-embedding-3-small", + }, + ], + "collections.wait": [ + { + collection: "products", + fieldPresent: "title_embedding", + timeoutMs: 60000, + }, + ], + "documents.index": [ + { + collection: "products", + document: { id: "sku-1", title: "Lounge chair" }, + }, + ], + "documents.search": [ + { + collection: "products", + params: { + q: "chair", + query_by: "title,description", + }, + }, + ], + "keys.create": [ + { + description: "Search-only key", + actions: ["documents:search"], + collections: ["products"], + }, + ], + "presets.create": [ + { + name: "Semantic", + value: { query_by: "title_embedding" }, + }, + ], + "synonyms.create": [ + { + collection: "products", + id: "sofa-couch", + synonyms: ["sofa", "couch"], + }, + ], +}; + +function unwrapSchema(input: z.ZodTypeAny): { + schema: z.ZodTypeAny; + optional: boolean; +} { + if (input instanceof z.ZodOptional) { + const inner = unwrapSchema(input.unwrap()); + return { schema: inner.schema, optional: true }; + } + if (input instanceof z.ZodDefault) { + const inner = unwrapSchema(input.removeDefault()); + return { schema: inner.schema, optional: true }; + } + if (input instanceof z.ZodNullable) { + return unwrapSchema(input.unwrap()); + } + return { schema: input, optional: false }; +} + +function describeSchema(input: z.ZodTypeAny): JsonSchema { + const { schema } = unwrapSchema(input); + + if (schema instanceof z.ZodString) return { type: "string" }; + if (schema instanceof z.ZodNumber) return { type: "number" }; + if (schema instanceof z.ZodBoolean) return { type: "boolean" }; + if (schema instanceof z.ZodEnum) + return { type: "string", enum: schema.options }; + if (schema instanceof z.ZodArray) { + return { type: "array", items: describeSchema(schema.element) }; + } + if (schema instanceof z.ZodRecord) { + const valueType = (schema as z.ZodRecord) + .valueSchema; + return { + type: "object", + additionalProperties: valueType ? describeSchema(valueType) : true, + }; + } + if (schema instanceof z.ZodUnion) { + const options = schema.options as z.ZodTypeAny[]; + return { + anyOf: options.map((option) => describeSchema(option)), + }; + } + if (schema instanceof z.ZodObject) { + return inputObjectSchema(schema); + } + + return { type: "unknown" }; +} + +export function inputObjectSchema(input: z.ZodTypeAny): JsonSchema { + const { schema } = unwrapSchema(input); + if (!(schema instanceof z.ZodObject)) return describeSchema(schema); + + const properties: Record = {}; + const required: string[] = []; + + const shape = schema.shape as z.ZodRawShape; + for (const [name, child] of Object.entries(shape)) { + const { optional } = unwrapSchema(child); + properties[name] = describeSchema(child); + if (!optional) required.push(name); + } + + return { + type: "object", + properties, + required, + }; +} + +export function renderInputSchema(input: z.ZodTypeAny): string { + return JSON.stringify(inputObjectSchema(input), null, 2); +} + +export function renderOperationExamples(operationName: string): string { + const examples = EXAMPLES[operationName]; + if (!examples) { + return [ + `No curated examples are available for ${operationName}.`, + `Run: tsk ${operationName} --schema`, + ].join("\n"); + } + + return examples + .map( + (example) => + `tsk ${operationName} --input '${JSON.stringify(example)}' --json`, + ) + .join("\n"); +} diff --git a/packages/cli/src/operations.ts b/packages/cli/src/operations.ts index c2d98a6..cc27633 100644 --- a/packages/cli/src/operations.ts +++ b/packages/cli/src/operations.ts @@ -4,6 +4,10 @@ import { operations, } from "@typesensekit/core"; import { defineCommand } from "citty"; +import { + renderInputSchema, + renderOperationExamples, +} from "./operation-docs.js"; import { parseInput, render } from "./output.js"; import { resolveClient } from "./profile/resolve.js"; @@ -97,6 +101,14 @@ export function operationCommands() { type: "string", description: "JSON object matching this operation input schema", }, + schema: { + type: "boolean", + description: "Print this operation input schema", + }, + examples: { + type: "boolean", + description: "Print example invocations for this operation", + }, profile: { type: "string", description: "Profile name" }, config: { type: "string", description: "Profile config path" }, json: { type: "boolean", description: "Print JSON" }, @@ -107,6 +119,15 @@ export function operationCommands() { ...operationSpecificArgs(operation.name), }, async run({ args }) { + if (args.schema) { + console.log(renderInputSchema(operation.input)); + return; + } + if (args.examples) { + console.log(renderOperationExamples(operation.name)); + return; + } + const client = await resolveClient({ profile: args.profile, config: args.config,