Yet another Fluxer bot built with TypeScript and Bun
1import type { StandardSchemaV1 } from "@standard-schema/spec";
2
3// ---------------------------------------------------------------------------
4// Optional wrapper for external Standard Schema schemas
5//
6// External schemas (Zod, Valibot, etc.) are treated as required by default.
7// Wrap with arg.optional(schema) to make them optional.
8// ---------------------------------------------------------------------------
9
10export interface OptionalStandardSchema<S extends StandardSchemaV1> {
11 readonly _tag: "optional";
12 readonly schema: S;
13 readonly _rest?: true;
14}
15
16// ---------------------------------------------------------------------------
17// Our native ArgDef — implements StandardSchemaV1 so it's interoperable
18// with any tooling that accepts Standard Schema.
19//
20// Input is always `string` (a raw positional arg from the message).
21// Output is whatever the parser produces.
22// ---------------------------------------------------------------------------
23
24export interface ArgDef<T> extends StandardSchemaV1<string, T> {
25 readonly required: true;
26 optional(): OptionalArgDef<T>;
27}
28
29export interface OptionalArgDef<T> extends StandardSchemaV1<string, T | undefined> {
30 readonly required: false;
31}
32
33// ---------------------------------------------------------------------------
34// What can appear as a value in an arg schema
35// ---------------------------------------------------------------------------
36
37export type ArgEntry =
38 | ArgDef<unknown>
39 | OptionalArgDef<unknown>
40 | StandardSchemaV1 // any external schema — required by default
41 | OptionalStandardSchema<StandardSchemaV1>; // external schema wrapped as optional
42
43export type ArgSchema = Record<string, ArgEntry>;
44
45// Infer the parsed output type for a single entry
46type InferEntry<E extends ArgEntry> =
47 E extends OptionalStandardSchema<infer S>
48 ? StandardSchemaV1.InferOutput<S> | undefined
49 : E extends StandardSchemaV1
50 ? StandardSchemaV1.InferOutput<E>
51 : never;
52
53// Infer the fully parsed object type from a schema
54export type ParsedArgs<S extends ArgSchema> = {
55 [K in keyof S]: InferEntry<S[K]>;
56};
57
58// ---------------------------------------------------------------------------
59// Internal helpers
60// ---------------------------------------------------------------------------
61
62function makeStandardProps<T>(
63 required: boolean,
64 parser: (raw: string) => T,
65): StandardSchemaV1<string, T>["~standard"] {
66 return {
67 version: 1 as const,
68 vendor: "hydro-args",
69 validate(value: unknown) {
70 if (value === undefined || value === "") {
71 if (required) {
72 return { issues: [{ message: "Missing required argument" }] };
73 }
74 return { value: undefined as T };
75 }
76 try {
77 return { value: parser(value as string) };
78 } catch (e) {
79 return { issues: [{ message: e instanceof Error ? e.message : String(e) }] };
80 }
81 },
82 types: undefined,
83 };
84}
85
86function makeOptional<T>(parser: (raw: string) => T): OptionalArgDef<T> {
87 return {
88 required: false as const,
89 "~standard": makeStandardProps(false, parser) as StandardSchemaV1<
90 string,
91 T | undefined
92 >["~standard"],
93 };
94}
95
96function makeRequired<T>(parser: (raw: string) => T): ArgDef<T> {
97 return {
98 required: true as const,
99 "~standard": makeStandardProps(true, parser),
100 optional(): OptionalArgDef<T> {
101 return makeOptional(parser);
102 },
103 };
104}
105
106// ---------------------------------------------------------------------------
107// Arg factories — the public API
108// ---------------------------------------------------------------------------
109
110export const arg = {
111 /** Raw string — returned as-is */
112 string(): ArgDef<string> {
113 return makeRequired((raw) => raw);
114 },
115
116 /** Parsed as a float — throws if not a valid number */
117 number(): ArgDef<number> {
118 return makeRequired((raw) => {
119 const n = Number(raw);
120 if (Number.isNaN(n)) throw new Error(`Expected a number, got "${raw}"`);
121 return n;
122 });
123 },
124
125 /** Parses "true"/"yes"/"1" as true, "false"/"no"/"0" as false */
126 boolean(): ArgDef<boolean> {
127 return makeRequired((raw) => {
128 const lower = raw.toLowerCase();
129 if (lower === "true" || lower === "yes" || lower === "1") return true;
130 if (lower === "false" || lower === "no" || lower === "0") return false;
131 throw new Error(`Expected a boolean (true/false/yes/no/1/0), got "${raw}"`);
132 });
133 },
134
135 /**
136 * Greedily consumes all remaining positional args into a single string.
137 * Must be the last entry in the schema.
138 * !ban @user being rude in chat → reason = "being rude in chat"
139 */
140 rest(): ArgDef<string> & { readonly _rest: true } {
141 return { ...makeRequired((raw) => raw), _rest: true as const };
142 },
143
144 /**
145 * Wraps any Standard Schema compatible schema (Zod, Valibot, ArkType, etc.)
146 * as an optional arg. Without this wrapper, external schemas are required.
147 *
148 * @example
149 * const schema = {
150 * tag: z.string().regex(/^[a-z]+$/), // required, validated by Zod
151 * note: arg.optional(z.string().max(100)), // optional, validated by Zod
152 * };
153 */
154 optional<S extends StandardSchemaV1>(schema: S): OptionalStandardSchema<S> {
155 return { _tag: "optional", schema };
156 },
157
158 /**
159 * Like arg.optional(schema), but greedily consumes all remaining tokens
160 * as a single string before passing to the schema. Must be the last entry.
161 *
162 * @example
163 * const schema = {
164 * target: arg.string(),
165 * reason: arg.restOptional(z.string().min(1).max(500)), // "Being rude in chat"
166 * };
167 */
168 restOptional<S extends StandardSchemaV1>(schema: S): OptionalStandardSchema<S> {
169 return { _tag: "optional", schema, _rest: true as const };
170 },
171
172 /**
173 * Like a required external schema arg, but greedily consumes all remaining
174 * tokens as a single string before passing to the schema. Must be the last entry.
175 *
176 * Use this when you want Zod (or another library) to validate a required
177 * multi-word value, e.g. a message that must be at least 1 character.
178 *
179 * @example
180 * const schema = {
181 * content: arg.restRequired(z.string().min(1)), // "Hello world from chat"
182 * };
183 */
184 restRequired<S extends StandardSchemaV1>(schema: S): S & { readonly _rest: true } {
185 return { ...schema, _rest: true as const };
186 },
187} as const;
188
189// ---------------------------------------------------------------------------
190// Type guards
191// ---------------------------------------------------------------------------
192
193function isOptionalWrapper(entry: ArgEntry): entry is OptionalStandardSchema<StandardSchemaV1> {
194 return "_tag" in entry && (entry as OptionalStandardSchema<StandardSchemaV1>)._tag === "optional";
195}
196
197function isNativeArgDef(entry: ArgEntry): entry is ArgDef<unknown> | OptionalArgDef<unknown> {
198 return "required" in entry;
199}
200
201function isRest(entry: ArgEntry): boolean {
202 return (
203 "_rest" in entry && (entry as { _rest?: boolean })._rest === true && !isOptionalWrapper(entry)
204 );
205}
206
207function isRestOptionalWrapper(
208 entry: ArgEntry,
209): entry is OptionalStandardSchema<StandardSchemaV1> & { _rest: true } {
210 return isOptionalWrapper(entry) && entry._rest === true;
211}
212
213// ---------------------------------------------------------------------------
214// Parser — called by the registry before execute()
215// ---------------------------------------------------------------------------
216
217async function validateEntry(
218 key: string,
219 entry: ArgEntry,
220 rawValue: string | undefined,
221): Promise<unknown> {
222 // Optional wrapper (including restOptional) around an external schema
223 if (isOptionalWrapper(entry) || isRestOptionalWrapper(entry)) {
224 if (rawValue === undefined || rawValue === "") return undefined;
225 return runValidate(key, entry.schema, rawValue);
226 }
227
228 // Our native ArgDef / OptionalArgDef (also StandardSchemaV1)
229 if (isNativeArgDef(entry)) {
230 // Delegate to the ~standard.validate() we set up — it handles required/optional internally
231 return runValidate(key, entry, rawValue ?? "");
232 }
233
234 // Plain external Standard Schema — treated as required
235 if (rawValue === undefined || rawValue === "") {
236 throw new Error(`Missing required argument: ${key}`);
237 }
238 return runValidate(key, entry, rawValue);
239}
240
241async function runValidate(
242 key: string,
243 schema: StandardSchemaV1,
244 value: unknown,
245): Promise<unknown> {
246 const standard = schema["~standard"];
247
248 if (standard && typeof standard.validate === "function") {
249 let result = standard.validate(value);
250 if (result instanceof Promise) result = await result;
251 if (result.issues) {
252 const messages = result.issues.map((i) => i.message).join(", ");
253 throw new Error(`Argument "${key}": ${messages}`);
254 }
255 return result.value;
256 }
257
258 if ("safeParse" in schema && typeof schema.safeParse === "function") {
259 const result = await schema.safeParse(value);
260 if (!result.success) {
261 const messages = (result as { error?: { issues?: Array<{ message: string }> } }).error?.issues
262 ?.map((i) => i.message)
263 .join(", ");
264 throw new Error(`Argument "${key}": ${messages ?? "Validation failed"}`);
265 }
266 return (result as { data: unknown }).data;
267 }
268
269 throw new Error(`Argument "${key}": Schema does not implement StandardSchemaV1 or safeParse`);
270}
271
272export async function parseArgs<S extends ArgSchema>(
273 schema: S,
274 raw: string[],
275): Promise<ParsedArgs<S>> {
276 const keys = Object.keys(schema);
277 const result: Record<string, unknown> = {};
278
279 for (let i = 0; i < keys.length; i++) {
280 const key = keys[i]!;
281 const entry = schema[key]!;
282 const isLastEntry = i === keys.length - 1;
283
284 let rawValue: string | undefined;
285
286 const isRestEntry =
287 isRest(entry) ||
288 isRestOptionalWrapper(entry) ||
289 ("_rest" in entry && (entry as { _rest?: boolean })._rest === true);
290 if (isRestEntry && isLastEntry) {
291 // Consume all remaining tokens as one string
292 rawValue = raw.slice(i).join(" ") || undefined;
293 } else {
294 rawValue = raw[i];
295 }
296
297 result[key] = await validateEntry(key, entry, rawValue);
298 }
299
300 return result as ParsedArgs<S>;
301}