import type { StandardSchemaV1 } from "@standard-schema/spec"; // --------------------------------------------------------------------------- // Optional wrapper for external Standard Schema schemas // // External schemas (Zod, Valibot, etc.) are treated as required by default. // Wrap with arg.optional(schema) to make them optional. // --------------------------------------------------------------------------- export interface OptionalStandardSchema { readonly _tag: "optional"; readonly schema: S; readonly _rest?: true; } // --------------------------------------------------------------------------- // Our native ArgDef — implements StandardSchemaV1 so it's interoperable // with any tooling that accepts Standard Schema. // // Input is always `string` (a raw positional arg from the message). // Output is whatever the parser produces. // --------------------------------------------------------------------------- export interface ArgDef extends StandardSchemaV1 { readonly required: true; optional(): OptionalArgDef; } export interface OptionalArgDef extends StandardSchemaV1 { readonly required: false; } // --------------------------------------------------------------------------- // What can appear as a value in an arg schema // --------------------------------------------------------------------------- export type ArgEntry = | ArgDef | OptionalArgDef | StandardSchemaV1 // any external schema — required by default | OptionalStandardSchema; // external schema wrapped as optional export type ArgSchema = Record; // Infer the parsed output type for a single entry type InferEntry = E extends OptionalStandardSchema ? StandardSchemaV1.InferOutput | undefined : E extends StandardSchemaV1 ? StandardSchemaV1.InferOutput : never; // Infer the fully parsed object type from a schema export type ParsedArgs = { [K in keyof S]: InferEntry; }; // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- function makeStandardProps( required: boolean, parser: (raw: string) => T, ): StandardSchemaV1["~standard"] { return { version: 1 as const, vendor: "hydro-args", validate(value: unknown) { if (value === undefined || value === "") { if (required) { return { issues: [{ message: "Missing required argument" }] }; } return { value: undefined as T }; } try { return { value: parser(value as string) }; } catch (e) { return { issues: [{ message: e instanceof Error ? e.message : String(e) }] }; } }, types: undefined, }; } function makeOptional(parser: (raw: string) => T): OptionalArgDef { return { required: false as const, "~standard": makeStandardProps(false, parser) as StandardSchemaV1< string, T | undefined >["~standard"], }; } function makeRequired(parser: (raw: string) => T): ArgDef { return { required: true as const, "~standard": makeStandardProps(true, parser), optional(): OptionalArgDef { return makeOptional(parser); }, }; } // --------------------------------------------------------------------------- // Arg factories — the public API // --------------------------------------------------------------------------- export const arg = { /** Raw string — returned as-is */ string(): ArgDef { return makeRequired((raw) => raw); }, /** Parsed as a float — throws if not a valid number */ number(): ArgDef { return makeRequired((raw) => { const n = Number(raw); if (Number.isNaN(n)) throw new Error(`Expected a number, got "${raw}"`); return n; }); }, /** Parses "true"/"yes"/"1" as true, "false"/"no"/"0" as false */ boolean(): ArgDef { return makeRequired((raw) => { const lower = raw.toLowerCase(); if (lower === "true" || lower === "yes" || lower === "1") return true; if (lower === "false" || lower === "no" || lower === "0") return false; throw new Error(`Expected a boolean (true/false/yes/no/1/0), got "${raw}"`); }); }, /** * Greedily consumes all remaining positional args into a single string. * Must be the last entry in the schema. * !ban @user being rude in chat → reason = "being rude in chat" */ rest(): ArgDef & { readonly _rest: true } { return { ...makeRequired((raw) => raw), _rest: true as const }; }, /** * Wraps any Standard Schema compatible schema (Zod, Valibot, ArkType, etc.) * as an optional arg. Without this wrapper, external schemas are required. * * @example * const schema = { * tag: z.string().regex(/^[a-z]+$/), // required, validated by Zod * note: arg.optional(z.string().max(100)), // optional, validated by Zod * }; */ optional(schema: S): OptionalStandardSchema { return { _tag: "optional", schema }; }, /** * Like arg.optional(schema), but greedily consumes all remaining tokens * as a single string before passing to the schema. Must be the last entry. * * @example * const schema = { * target: arg.string(), * reason: arg.restOptional(z.string().min(1).max(500)), // "Being rude in chat" * }; */ restOptional(schema: S): OptionalStandardSchema { return { _tag: "optional", schema, _rest: true as const }; }, /** * Like a required external schema arg, but greedily consumes all remaining * tokens as a single string before passing to the schema. Must be the last entry. * * Use this when you want Zod (or another library) to validate a required * multi-word value, e.g. a message that must be at least 1 character. * * @example * const schema = { * content: arg.restRequired(z.string().min(1)), // "Hello world from chat" * }; */ restRequired(schema: S): S & { readonly _rest: true } { return { ...schema, _rest: true as const }; }, } as const; // --------------------------------------------------------------------------- // Type guards // --------------------------------------------------------------------------- function isOptionalWrapper(entry: ArgEntry): entry is OptionalStandardSchema { return "_tag" in entry && (entry as OptionalStandardSchema)._tag === "optional"; } function isNativeArgDef(entry: ArgEntry): entry is ArgDef | OptionalArgDef { return "required" in entry; } function isRest(entry: ArgEntry): boolean { return ( "_rest" in entry && (entry as { _rest?: boolean })._rest === true && !isOptionalWrapper(entry) ); } function isRestOptionalWrapper( entry: ArgEntry, ): entry is OptionalStandardSchema & { _rest: true } { return isOptionalWrapper(entry) && entry._rest === true; } // --------------------------------------------------------------------------- // Parser — called by the registry before execute() // --------------------------------------------------------------------------- async function validateEntry( key: string, entry: ArgEntry, rawValue: string | undefined, ): Promise { // Optional wrapper (including restOptional) around an external schema if (isOptionalWrapper(entry) || isRestOptionalWrapper(entry)) { if (rawValue === undefined || rawValue === "") return undefined; return runValidate(key, entry.schema, rawValue); } // Our native ArgDef / OptionalArgDef (also StandardSchemaV1) if (isNativeArgDef(entry)) { // Delegate to the ~standard.validate() we set up — it handles required/optional internally return runValidate(key, entry, rawValue ?? ""); } // Plain external Standard Schema — treated as required if (rawValue === undefined || rawValue === "") { throw new Error(`Missing required argument: ${key}`); } return runValidate(key, entry, rawValue); } async function runValidate( key: string, schema: StandardSchemaV1, value: unknown, ): Promise { const standard = schema["~standard"]; if (standard && typeof standard.validate === "function") { let result = standard.validate(value); if (result instanceof Promise) result = await result; if (result.issues) { const messages = result.issues.map((i) => i.message).join(", "); throw new Error(`Argument "${key}": ${messages}`); } return result.value; } if ("safeParse" in schema && typeof schema.safeParse === "function") { const result = await schema.safeParse(value); if (!result.success) { const messages = (result as { error?: { issues?: Array<{ message: string }> } }).error?.issues ?.map((i) => i.message) .join(", "); throw new Error(`Argument "${key}": ${messages ?? "Validation failed"}`); } return (result as { data: unknown }).data; } throw new Error(`Argument "${key}": Schema does not implement StandardSchemaV1 or safeParse`); } export async function parseArgs( schema: S, raw: string[], ): Promise> { const keys = Object.keys(schema); const result: Record = {}; for (let i = 0; i < keys.length; i++) { const key = keys[i]!; const entry = schema[key]!; const isLastEntry = i === keys.length - 1; let rawValue: string | undefined; const isRestEntry = isRest(entry) || isRestOptionalWrapper(entry) || ("_rest" in entry && (entry as { _rest?: boolean })._rest === true); if (isRestEntry && isLastEntry) { // Consume all remaining tokens as one string rawValue = raw.slice(i).join(" ") || undefined; } else { rawValue = raw[i]; } result[key] = await validateEntry(key, entry, rawValue); } return result as ParsedArgs; }