@fuzdev/fuz_util 0.46.0 → 0.48.0

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.
@@ -15,11 +15,11 @@ import { z } from 'zod';
15
15
  */
16
16
  export declare const DeclarationKind: z.ZodEnum<{
17
17
  function: "function";
18
- json: "json";
19
18
  type: "type";
19
+ constructor: "constructor";
20
+ json: "json";
20
21
  variable: "variable";
21
22
  class: "class";
22
- constructor: "constructor";
23
23
  component: "component";
24
24
  css: "css";
25
25
  }>;
@@ -79,11 +79,11 @@ export declare const DeclarationJson: z.ZodObject<{
79
79
  name: z.ZodString;
80
80
  kind: z.ZodEnum<{
81
81
  function: "function";
82
- json: "json";
83
82
  type: "type";
83
+ constructor: "constructor";
84
+ json: "json";
84
85
  variable: "variable";
85
86
  class: "class";
86
- constructor: "constructor";
87
87
  component: "component";
88
88
  css: "css";
89
89
  }>;
@@ -171,11 +171,11 @@ export declare const ModuleJson: z.ZodObject<{
171
171
  name: z.ZodString;
172
172
  kind: z.ZodEnum<{
173
173
  function: "function";
174
- json: "json";
175
174
  type: "type";
175
+ constructor: "constructor";
176
+ json: "json";
176
177
  variable: "variable";
177
178
  class: "class";
178
- constructor: "constructor";
179
179
  component: "component";
180
180
  css: "css";
181
181
  }>;
@@ -278,11 +278,11 @@ export declare const SourceJson: z.ZodObject<{
278
278
  name: z.ZodString;
279
279
  kind: z.ZodEnum<{
280
280
  function: "function";
281
- json: "json";
282
281
  type: "type";
282
+ constructor: "constructor";
283
+ json: "json";
283
284
  variable: "variable";
284
285
  class: "class";
285
- constructor: "constructor";
286
286
  component: "component";
287
287
  css: "css";
288
288
  }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/fuz_util",
3
- "version": "0.46.0",
3
+ "version": "0.48.0",
4
4
  "description": "utility belt for JS",
5
5
  "glyph": "🦕",
6
6
  "logo": "logo.svg",
@@ -65,21 +65,21 @@
65
65
  "@fuzdev/fuz_css": "^0.44.1",
66
66
  "@fuzdev/fuz_ui": "^0.179.0",
67
67
  "@ryanatkn/eslint-config": "^0.9.0",
68
- "@ryanatkn/gro": "^0.186.0",
68
+ "@ryanatkn/gro": "^0.188.0",
69
69
  "@sveltejs/adapter-static": "^3.0.10",
70
- "@sveltejs/kit": "^2.49.1",
70
+ "@sveltejs/kit": "^2.50.1",
71
71
  "@sveltejs/package": "^2.5.7",
72
- "@sveltejs/vite-plugin-svelte": "^6.2.1",
72
+ "@sveltejs/vite-plugin-svelte": "^6.2.4",
73
73
  "@types/node": "^24.10.1",
74
74
  "@webref/css": "^8.2.0",
75
75
  "dequal": "^2.0.3",
76
76
  "eslint": "^9.39.1",
77
- "eslint-plugin-svelte": "^3.13.1",
77
+ "eslint-plugin-svelte": "^3.14.0",
78
78
  "esm-env": "^1.2.2",
79
79
  "fast-deep-equal": "^3.1.3",
80
80
  "prettier": "^3.7.4",
81
81
  "prettier-plugin-svelte": "^3.4.1",
82
- "svelte": "^5.45.6",
82
+ "svelte": "^5.48.2",
83
83
  "svelte-check": "^4.3.4",
84
84
  "tslib": "^2.8.1",
85
85
  "typescript": "^5.9.3",
@@ -0,0 +1,546 @@
1
+ import {z} from 'zod';
2
+
3
+ /**
4
+ * CLI arguments container.
5
+ * Positional arguments stored in `_`, named flags/options as string keys.
6
+ * Produced by `argv_parse` or external parsers (mri, minimist, etc.).
7
+ */
8
+ export interface Args {
9
+ _?: Array<string>;
10
+ [key: string]: ArgValue;
11
+ }
12
+
13
+ /**
14
+ * Parsed CLI arguments with guaranteed positionals array.
15
+ * Returned by `argv_parse` which always initializes `_`.
16
+ */
17
+ export interface ParsedArgs extends Args {
18
+ _: Array<string>;
19
+ }
20
+
21
+ /**
22
+ * Value types supported in CLI arguments.
23
+ */
24
+ export type ArgValue = string | number | boolean | undefined | Array<string | number | boolean>;
25
+
26
+ /**
27
+ * Schema description for help text generation.
28
+ * Not used by args_parse/args_serialize directly - provided for consumers
29
+ * building CLI help output.
30
+ */
31
+ export interface ArgSchema {
32
+ type: string;
33
+ default: ArgValue;
34
+ description: string;
35
+ }
36
+
37
+ /**
38
+ * Result of alias extraction from a schema.
39
+ * Includes canonical keys for downstream conflict detection.
40
+ */
41
+ export interface ArgsAliasesResult {
42
+ aliases: Map<string, string>; // alias → canonical
43
+ canonical_keys: Set<string>; // all canonical key names
44
+ }
45
+
46
+ // Internal cache entry structure
47
+ interface SchemaCacheEntry {
48
+ aliases: Map<string, string>; // alias → canonical
49
+ canonical_keys: Set<string>; // all canonical key names
50
+ boolean_keys: Set<string>; // keys with boolean type (for no- sync)
51
+ conflict_error: z.ZodError | null; // null if schema is valid
52
+ }
53
+
54
+ // WeakMap cache for schema analysis - avoids redundant reflection
55
+ const schema_cache: WeakMap<z.ZodType, SchemaCacheEntry> = new WeakMap();
56
+
57
+ // Internal: Unwrap nested schema types (Optional, Default, Transform, Pipe)
58
+ const unwrap_schema = (def: z.core.$ZodTypeDef): z.ZodType | undefined => {
59
+ if ('innerType' in def) return def.innerType as z.ZodType; // Optional, Nullable
60
+ if ('in' in def) return def.in as z.ZodType; // Pipe
61
+ if ('schema' in def) return def.schema as z.ZodType; // Default, Transform
62
+ return undefined;
63
+ };
64
+
65
+ // Internal: Check if schema type is boolean (recursing through wrappers)
66
+ const is_boolean_field = (schema: z.ZodType): boolean => {
67
+ const def = schema._zod.def;
68
+ if (def.type === 'boolean') return true;
69
+ const inner = unwrap_schema(def);
70
+ if (inner) return is_boolean_field(inner);
71
+ return false;
72
+ };
73
+
74
+ // Internal: Schema analysis result
75
+ interface SchemaAnalysisResult {
76
+ aliases: Map<string, string>;
77
+ canonical_keys: Set<string>;
78
+ boolean_keys: Set<string>;
79
+ errors: Array<{
80
+ type: 'alias_canonical_conflict' | 'duplicate_alias';
81
+ alias: string;
82
+ canonical: string;
83
+ conflict_with: string;
84
+ }>;
85
+ }
86
+
87
+ // Internal: Analyze schema for aliases, canonical keys, boolean keys, and conflicts
88
+ const analyze_schema = (schema: z.ZodType): SchemaAnalysisResult => {
89
+ const aliases: Map<string, string> = new Map();
90
+ const canonical_keys: Set<string> = new Set();
91
+ const boolean_keys: Set<string> = new Set();
92
+ const errors: SchemaAnalysisResult['errors'] = [];
93
+ const def = schema._zod.def;
94
+
95
+ // Unwrap to get object def (handle wrapped types like optional, default, etc.)
96
+ let obj_def = def;
97
+ while (!('shape' in obj_def)) {
98
+ const inner = unwrap_schema(obj_def);
99
+ if (!inner) return {aliases, canonical_keys, boolean_keys, errors};
100
+ obj_def = inner._zod.def;
101
+ }
102
+
103
+ const shape = (obj_def as z.core.$ZodObjectDef).shape;
104
+
105
+ // First pass: collect all canonical keys
106
+ for (const key of Object.keys(shape)) {
107
+ canonical_keys.add(key);
108
+ }
109
+
110
+ // Second pass: process fields for aliases and booleans
111
+ for (const [key, field] of Object.entries(shape)) {
112
+ const field_schema = field as z.ZodType;
113
+
114
+ // Track boolean fields for no- prefix sync
115
+ if (is_boolean_field(field_schema)) {
116
+ boolean_keys.add(key);
117
+ }
118
+
119
+ const meta = field_schema.meta();
120
+ if (meta?.aliases) {
121
+ for (const alias of meta.aliases as Array<string>) {
122
+ // Check for alias-canonical conflict
123
+ if (canonical_keys.has(alias)) {
124
+ errors.push({
125
+ type: 'alias_canonical_conflict',
126
+ alias,
127
+ canonical: key,
128
+ conflict_with: alias,
129
+ });
130
+ }
131
+ // Check for duplicate alias
132
+ else if (aliases.has(alias)) {
133
+ errors.push({
134
+ type: 'duplicate_alias',
135
+ alias,
136
+ canonical: key,
137
+ conflict_with: aliases.get(alias)!,
138
+ });
139
+ } else {
140
+ aliases.set(alias, key);
141
+ }
142
+ }
143
+ }
144
+ }
145
+ return {aliases, canonical_keys, boolean_keys, errors};
146
+ };
147
+
148
+ // Internal: Convert analysis errors to ZodError for consistent API
149
+ const to_conflict_error = (errors: SchemaAnalysisResult['errors']): z.ZodError => {
150
+ return new z.ZodError(
151
+ errors.map((err) => ({
152
+ code: 'custom' as const,
153
+ path: [err.alias],
154
+ message:
155
+ err.type === 'alias_canonical_conflict'
156
+ ? `Alias '${err.alias}' for '${err.canonical}' conflicts with canonical key '${err.conflict_with}'`
157
+ : `Alias '${err.alias}' is used by both '${err.canonical}' and '${err.conflict_with}'`,
158
+ })),
159
+ );
160
+ };
161
+
162
+ // Internal: Get or create cache entry for schema
163
+ const get_schema_cache = (schema: z.ZodType): SchemaCacheEntry => {
164
+ let entry = schema_cache.get(schema);
165
+ if (!entry) {
166
+ const analysis = analyze_schema(schema);
167
+ entry = {
168
+ aliases: analysis.aliases,
169
+ canonical_keys: analysis.canonical_keys,
170
+ boolean_keys: analysis.boolean_keys,
171
+ conflict_error: analysis.errors.length > 0 ? to_conflict_error(analysis.errors) : null,
172
+ };
173
+ schema_cache.set(schema, entry);
174
+ }
175
+ return entry;
176
+ };
177
+
178
+ /**
179
+ * Validates a zod schema for CLI arg usage.
180
+ *
181
+ * Checks for:
182
+ * - Alias conflicts with canonical keys
183
+ * - Duplicate aliases across different keys
184
+ *
185
+ * Results are cached per schema (WeakMap). Safe to call multiple times.
186
+ *
187
+ * @param schema Zod object schema with optional alias metadata
188
+ * @returns Validation result with success flag and optional error
189
+ */
190
+ export const args_validate_schema = (
191
+ schema: z.ZodType,
192
+ ): {success: true} | {success: false; error: z.ZodError} => {
193
+ const cache = get_schema_cache(schema);
194
+ if (cache.conflict_error) {
195
+ return {success: false, error: cache.conflict_error};
196
+ }
197
+ return {success: true};
198
+ };
199
+
200
+ /**
201
+ * Validates parsed CLI args against a zod schema.
202
+ *
203
+ * Handles CLI-specific concerns before validation:
204
+ * 1. Validates schema (cached) - returns error if alias conflicts exist
205
+ * 2. Expands aliases defined in schema `.meta({aliases: ['v']})`
206
+ * 3. Strips alias keys (required for strictObject schemas)
207
+ * 4. Validates with zod
208
+ * 5. After validation, syncs `no-` prefixed boolean flags with their base keys
209
+ *
210
+ * Schema analysis is cached per schema (WeakMap) for performance.
211
+ *
212
+ * @param unparsed_args Args object from CLI parser (mri, minimist, etc.)
213
+ * @param schema Zod object schema with optional alias metadata
214
+ * @returns Zod SafeParseResult with expanded/synced data on success
215
+ */
216
+ export const args_parse = <TOutput extends Record<string, ArgValue> = Args>(
217
+ unparsed_args: Args,
218
+ schema: z.ZodType<TOutput>,
219
+ ): z.ZodSafeParseResult<TOutput> => {
220
+ const cache = get_schema_cache(schema);
221
+
222
+ // Return conflict error if schema has issues
223
+ if (cache.conflict_error) {
224
+ return {success: false, error: cache.conflict_error as z.ZodError<TOutput>};
225
+ }
226
+
227
+ // Build expanded args - copy canonical, expand aliases, strip alias keys
228
+ const expanded: Record<string, ArgValue> = {};
229
+ for (const [key, value] of Object.entries(unparsed_args)) {
230
+ if (cache.aliases.has(key)) {
231
+ const canonical = cache.aliases.get(key)!;
232
+ // Only expand if canonical not already present (canonical takes precedence)
233
+ if (!(canonical in expanded) && !(canonical in unparsed_args)) {
234
+ expanded[canonical] = value;
235
+ }
236
+ // Don't copy alias key (strip it)
237
+ } else {
238
+ expanded[key] = value;
239
+ }
240
+ }
241
+
242
+ // Validate with zod
243
+ const parsed = schema.safeParse(expanded);
244
+
245
+ if (parsed.success) {
246
+ // Mutate data with the correct source of truth for no- prefixed args
247
+ const data = parsed.data as Record<string, ArgValue>;
248
+ for (const key in data) {
249
+ if (key.startsWith('no-')) {
250
+ const base_key = key.substring(3);
251
+ // Only sync if both keys are booleans in the schema
252
+ if (cache.boolean_keys.has(key) && cache.boolean_keys.has(base_key)) {
253
+ if (!(key in unparsed_args) && !(key in expanded)) {
254
+ data[key] = !data[base_key];
255
+ } else if (!(base_key in unparsed_args) && !(base_key in expanded)) {
256
+ data[base_key] = !data[key];
257
+ }
258
+ }
259
+ }
260
+ }
261
+ }
262
+
263
+ return parsed;
264
+ };
265
+
266
+ /**
267
+ * Serializes Args to CLI string array for subprocess forwarding.
268
+ *
269
+ * Handles CLI conventions:
270
+ * - Positionals first, then flags
271
+ * - Single-char keys get single dash, multi-char get double dash
272
+ * - Boolean `true` becomes bare flag, `false` is skipped
273
+ * - `undefined` values are skipped
274
+ * - `no-` prefixed keys skipped when base key is truthy (avoid contradiction)
275
+ * - When schema provided, extracts aliases and prefers shortest form
276
+ *
277
+ * Schema analysis is cached per schema (WeakMap) for performance.
278
+ *
279
+ * @param args Args object to serialize
280
+ * @param schema Optional zod schema to extract aliases for short form preference
281
+ * @returns Array of CLI argument strings
282
+ */
283
+ export const args_serialize = (args: Args, schema?: z.ZodType): Array<string> => {
284
+ const result: Array<string> = [];
285
+
286
+ // Build reverse map (canonical → shortest alias) if schema provided
287
+ let shortest_names: Map<string, string> | undefined;
288
+ if (schema) {
289
+ const cache = get_schema_cache(schema);
290
+ shortest_names = new Map();
291
+ // Group aliases by canonical key
292
+ const aliases_by_canonical: Map<string, Array<string>> = new Map();
293
+ for (const [alias, canonical] of cache.aliases) {
294
+ if (!aliases_by_canonical.has(canonical)) {
295
+ aliases_by_canonical.set(canonical, []);
296
+ }
297
+ aliases_by_canonical.get(canonical)!.push(alias);
298
+ }
299
+ // Find shortest for each canonical
300
+ for (const [canonical, aliases] of aliases_by_canonical) {
301
+ const all_names = [canonical, ...aliases];
302
+ const shortest = all_names.reduce((a, b) => (a.length <= b.length ? a : b));
303
+ shortest_names.set(canonical, shortest);
304
+ }
305
+ }
306
+
307
+ const add_value = (name: string, value: string | number | boolean | undefined): void => {
308
+ if (value === undefined) return;
309
+ if (value === false) return; // Can't represent false as bare flag
310
+ result.push(name);
311
+ if (typeof value !== 'boolean') {
312
+ result.push(value + '');
313
+ }
314
+ };
315
+
316
+ let positionals: Array<string> | null = null;
317
+ for (const [key, value] of Object.entries(args)) {
318
+ if (key === '_') {
319
+ positionals = value ? (value as Array<string | number | boolean>).map((v) => v + '') : [];
320
+ } else {
321
+ // Skip no-X if X exists and is truthy
322
+ if (key.startsWith('no-')) {
323
+ const base = key.substring(3);
324
+ if (base in args && args[base]) {
325
+ continue; // Skip redundant no- flag
326
+ }
327
+ }
328
+
329
+ // Determine the name to use (prefer shortest if schema provided)
330
+ const use_key = shortest_names?.get(key) ?? key;
331
+ const name = `${use_key.length === 1 ? '-' : '--'}${use_key}`;
332
+
333
+ if (Array.isArray(value)) {
334
+ for (const v of value) add_value(name, v);
335
+ } else {
336
+ add_value(name, value);
337
+ }
338
+ }
339
+ }
340
+
341
+ return positionals ? [...positionals, ...result] : result;
342
+ };
343
+
344
+ /**
345
+ * Extracts alias mappings and canonical keys from a zod schema's metadata.
346
+ *
347
+ * Useful for consumers building custom tooling (help generators, conflict detection, etc.).
348
+ * Results are cached per schema (WeakMap).
349
+ *
350
+ * Note: Returns copies of the cached data to prevent mutation of internal cache.
351
+ *
352
+ * @param schema Zod object schema with optional `.meta({aliases})` on fields
353
+ * @returns Object with aliases map and canonical_keys set
354
+ */
355
+ export const args_extract_aliases = (schema: z.ZodType): ArgsAliasesResult => {
356
+ const cache = get_schema_cache(schema);
357
+ return {
358
+ aliases: new Map(cache.aliases),
359
+ canonical_keys: new Set(cache.canonical_keys),
360
+ };
361
+ };
362
+
363
+ // Internal: Try to coerce a string value to number if it looks numeric
364
+ const coerce_value = (value: string): string | number => {
365
+ // Handle empty string
366
+ if (value === '') return value;
367
+ // Try to parse as number
368
+ const num = Number(value);
369
+ // Return number if valid and finite, otherwise keep as string
370
+ // This matches mri behavior: "123" -> 123, "12.5" -> 12.5, "1e5" -> 100000
371
+ // But "123abc" -> "123abc", "NaN" -> "NaN", "Infinity" -> "Infinity"
372
+ if (!Number.isNaN(num) && Number.isFinite(num) && value.trim() !== '') {
373
+ return num;
374
+ }
375
+ return value;
376
+ };
377
+
378
+ // Internal: Set a value on args, handling arrays for repeated flags
379
+ const set_arg = (args: Args, key: string, value: string | number | boolean): void => {
380
+ if (key in args) {
381
+ // Convert to array or push to existing array
382
+ const existing = args[key];
383
+ if (Array.isArray(existing)) {
384
+ existing.push(value);
385
+ } else {
386
+ args[key] = [existing!, value];
387
+ }
388
+ } else {
389
+ args[key] = value;
390
+ }
391
+ };
392
+
393
+ /**
394
+ * Parses raw CLI argv array into an Args object.
395
+ *
396
+ * A lightweight, dependency-free alternative to mri/minimist with compatible behavior.
397
+ *
398
+ * Features:
399
+ * - `--flag` → `{flag: true}`
400
+ * - `--flag value` → `{flag: 'value'}` or `{flag: 123}` (numeric coercion)
401
+ * - `--flag=value` → equals syntax
402
+ * - `--flag=` → `{flag: ''}` (empty string, differs from mri which returns true)
403
+ * - `-f` → `{f: true}` (short flag)
404
+ * - `-f value` → `{f: 'value'}`
405
+ * - `-abc` → `{a: true, b: true, c: true}` (combined short flags)
406
+ * - `-abc value` → `{a: true, b: true, c: 'value'}` (last flag gets value)
407
+ * - `--no-flag` → `{flag: false}` (negation prefix)
408
+ * - `--` → stops flag parsing, rest become positionals
409
+ * - Positionals collected in `_` array
410
+ * - Repeated flags become arrays
411
+ *
412
+ * Intentional differences from mri:
413
+ * - `--flag=` returns `''` (mri returns `true`)
414
+ * - `--flag= next` returns `{flag: '', _: ['next']}` (mri takes `next` as the value)
415
+ * - `---flag` returns `{'-flag': true}` (mri strips all dashes)
416
+ * - `['--flag', '']` preserves `''` (mri coerces to `0`)
417
+ * - `--__proto__` works as a normal key (mri silently fails)
418
+ *
419
+ * The returned object uses `Object.create(null)` to prevent prototype pollution
420
+ * and allow any key name including `__proto__` and `constructor`.
421
+ *
422
+ * @param argv Raw argument array (typically process.argv.slice(2))
423
+ * @returns Parsed Args object with guaranteed `_` array (null prototype)
424
+ */
425
+ export const argv_parse = (argv: Array<string>): ParsedArgs => {
426
+ // Use Object.create(null) to allow __proto__ as a normal key
427
+ // This prevents prototype pollution and makes all key names work
428
+ const args = Object.create(null) as ParsedArgs;
429
+ args._ = [];
430
+ const positionals = args._;
431
+
432
+ let i = 0;
433
+ let flags_done = false; // Set to true after seeing --
434
+
435
+ while (i < argv.length) {
436
+ const arg = argv[i]!;
437
+
438
+ // After --, everything is a positional
439
+ if (flags_done) {
440
+ positionals.push(arg);
441
+ i++;
442
+ continue;
443
+ }
444
+
445
+ // -- stops flag parsing
446
+ if (arg === '--') {
447
+ flags_done = true;
448
+ i++;
449
+ continue;
450
+ }
451
+
452
+ // Long flag: --flag or --flag=value or --no-flag
453
+ if (arg.startsWith('--')) {
454
+ const rest = arg.slice(2);
455
+
456
+ // Handle --flag=value
457
+ const equals_index = rest.indexOf('=');
458
+ if (equals_index !== -1) {
459
+ const key = rest.slice(0, equals_index);
460
+ const value = rest.slice(equals_index + 1);
461
+ // Empty value after = becomes empty string (explicit value assignment)
462
+ // This differs from mri which treats it as boolean true
463
+ set_arg(args, key, coerce_value(value));
464
+ i++;
465
+ continue;
466
+ }
467
+
468
+ // Handle --no-flag (negation) - includes --no- which sets '' to false
469
+ if (rest.startsWith('no-')) {
470
+ const key = rest.slice(3); // May be empty string for --no-
471
+ args[key] = false;
472
+ i++;
473
+ continue;
474
+ }
475
+
476
+ // Handle --flag or --flag value
477
+ const key = rest;
478
+ const next = argv[i + 1];
479
+
480
+ // If next arg exists and doesn't look like a flag, it's the value
481
+ if (next !== undefined && !next.startsWith('-')) {
482
+ set_arg(args, key, coerce_value(next));
483
+ i += 2;
484
+ } else {
485
+ // Boolean flag
486
+ args[key] = true;
487
+ i++;
488
+ }
489
+ continue;
490
+ }
491
+
492
+ // Single dash is ignored (matches mri)
493
+ if (arg === '-') {
494
+ i++;
495
+ continue;
496
+ }
497
+
498
+ // Short flag(s): -f or -abc or -f value
499
+ if (arg.startsWith('-') && arg.length > 1) {
500
+ const chars = arg.slice(1);
501
+
502
+ // Handle -f=value (short flag with equals)
503
+ const equals_index = chars.indexOf('=');
504
+ if (equals_index !== -1) {
505
+ const key = chars.slice(0, equals_index);
506
+ const value = chars.slice(equals_index + 1);
507
+ // For -abc=value, set a and b to true, c gets value
508
+ for (let j = 0; j < key.length - 1; j++) {
509
+ args[key[j]!] = true;
510
+ }
511
+ if (key.length > 0) {
512
+ set_arg(args, key[key.length - 1]!, coerce_value(value));
513
+ }
514
+ i++;
515
+ continue;
516
+ }
517
+
518
+ // Handle combined flags: -abc means -a -b -c
519
+ // Last flag can take a value if next arg isn't a flag
520
+ const next = argv[i + 1];
521
+ const has_value = next !== undefined && !next.startsWith('-');
522
+
523
+ for (let j = 0; j < chars.length; j++) {
524
+ const char = chars[j]!;
525
+ const is_last = j === chars.length - 1;
526
+
527
+ if (is_last && has_value) {
528
+ // Last char gets the value
529
+ set_arg(args, char, coerce_value(next));
530
+ i += 2;
531
+ } else {
532
+ // Boolean flag
533
+ args[char] = true;
534
+ if (is_last) i++;
535
+ }
536
+ }
537
+ continue;
538
+ }
539
+
540
+ // Positional argument
541
+ positionals.push(arg);
542
+ i++;
543
+ }
544
+
545
+ return args;
546
+ };