@ontrails/cli 1.0.0-beta.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.
Files changed (70) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/.turbo/turbo-lint.log +3 -0
  3. package/.turbo/turbo-typecheck.log +1 -0
  4. package/CHANGELOG.md +20 -0
  5. package/README.md +166 -0
  6. package/dist/build.d.ts +25 -0
  7. package/dist/build.d.ts.map +1 -0
  8. package/dist/build.js +167 -0
  9. package/dist/build.js.map +1 -0
  10. package/dist/command.d.ts +47 -0
  11. package/dist/command.d.ts.map +1 -0
  12. package/dist/command.js +9 -0
  13. package/dist/command.js.map +1 -0
  14. package/dist/commander/blaze.d.ts +31 -0
  15. package/dist/commander/blaze.d.ts.map +1 -0
  16. package/dist/commander/blaze.js +42 -0
  17. package/dist/commander/blaze.js.map +1 -0
  18. package/dist/commander/index.d.ts +5 -0
  19. package/dist/commander/index.d.ts.map +1 -0
  20. package/dist/commander/index.js +3 -0
  21. package/dist/commander/index.js.map +1 -0
  22. package/dist/commander/to-commander.d.ts +12 -0
  23. package/dist/commander/to-commander.d.ts.map +1 -0
  24. package/dist/commander/to-commander.js +148 -0
  25. package/dist/commander/to-commander.js.map +1 -0
  26. package/dist/flags.d.ts +17 -0
  27. package/dist/flags.d.ts.map +1 -0
  28. package/dist/flags.js +180 -0
  29. package/dist/flags.js.map +1 -0
  30. package/dist/index.d.ts +11 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +13 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/layers.d.ts +21 -0
  35. package/dist/layers.d.ts.map +1 -0
  36. package/dist/layers.js +156 -0
  37. package/dist/layers.js.map +1 -0
  38. package/dist/on-result.d.ts +12 -0
  39. package/dist/on-result.d.ts.map +1 -0
  40. package/dist/on-result.js +21 -0
  41. package/dist/on-result.js.map +1 -0
  42. package/dist/output.d.ts +20 -0
  43. package/dist/output.d.ts.map +1 -0
  44. package/dist/output.js +82 -0
  45. package/dist/output.js.map +1 -0
  46. package/dist/prompt.d.ts +29 -0
  47. package/dist/prompt.d.ts.map +1 -0
  48. package/dist/prompt.js +12 -0
  49. package/dist/prompt.js.map +1 -0
  50. package/package.json +29 -0
  51. package/src/__tests__/blaze.test.ts +78 -0
  52. package/src/__tests__/build.test.ts +219 -0
  53. package/src/__tests__/flags.test.ts +176 -0
  54. package/src/__tests__/layers.test.ts +218 -0
  55. package/src/__tests__/on-result.test.ts +64 -0
  56. package/src/__tests__/output.test.ts +115 -0
  57. package/src/__tests__/to-commander.test.ts +133 -0
  58. package/src/build.ts +267 -0
  59. package/src/command.ts +73 -0
  60. package/src/commander/blaze.ts +67 -0
  61. package/src/commander/index.ts +5 -0
  62. package/src/commander/to-commander.ts +186 -0
  63. package/src/flags.ts +250 -0
  64. package/src/index.ts +28 -0
  65. package/src/layers.ts +231 -0
  66. package/src/on-result.ts +27 -0
  67. package/src/output.ts +101 -0
  68. package/src/prompt.ts +40 -0
  69. package/tsconfig.json +9 -0
  70. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Output formatting and mode resolution for CLI output.
3
+ */
4
+ export type OutputMode = 'text' | 'json' | 'jsonl';
5
+ export declare const output: (value: unknown, mode: OutputMode) => void;
6
+ /**
7
+ * Determine the output mode from parsed CLI flags and environment.
8
+ *
9
+ * Resolution order (highest priority wins):
10
+ * 1. `flags.json === true` -> "json"
11
+ * 2. `flags.jsonl === true` -> "jsonl"
12
+ * 3. `flags.output` as string -> validate against OutputMode
13
+ * 4. `TRAILS_JSON=1` env var -> "json"
14
+ * 5. `TRAILS_JSONL=1` env var -> "jsonl"
15
+ * 6. Default: "text"
16
+ */
17
+ export declare const resolveOutputMode: (flags: Record<string, unknown>) => {
18
+ mode: OutputMode;
19
+ };
20
+ //# sourceMappingURL=output.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"output.d.ts","sourceRoot":"","sources":["../src/output.ts"],"names":[],"mappings":"AAAA;;GAEG;AAMH,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAiCnD,eAAO,MAAM,MAAM,GAAI,OAAO,OAAO,EAAE,MAAM,UAAU,KAAG,IAGzD,CAAC;AAsCF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iBAAiB,GAC5B,OAAO,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC7B;IACD,IAAI,EAAE,UAAU,CAAC;CAIlB,CAAC"}
package/dist/output.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Output formatting and mode resolution for CLI output.
3
+ */
4
+ // ---------------------------------------------------------------------------
5
+ // output()
6
+ // ---------------------------------------------------------------------------
7
+ /**
8
+ * Write a value to stdout in the specified format.
9
+ *
10
+ * - **text**: strings written directly; objects JSON-stringified with 2-space indent
11
+ * - **json**: always JSON.stringify with 2-space indent
12
+ * - **jsonl**: arrays emit one JSON line per element; scalars emit one line
13
+ */
14
+ const outputWriters = {
15
+ json: (value) => process.stdout.write(`${JSON.stringify(value, null, 2)}\n`),
16
+ jsonl: (value) => {
17
+ if (Array.isArray(value)) {
18
+ for (const item of value) {
19
+ process.stdout.write(`${JSON.stringify(item)}\n`);
20
+ }
21
+ }
22
+ else {
23
+ process.stdout.write(`${JSON.stringify(value)}\n`);
24
+ }
25
+ },
26
+ text: (value) => {
27
+ if (typeof value === 'string') {
28
+ process.stdout.write(`${value}\n`);
29
+ }
30
+ else {
31
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
32
+ }
33
+ },
34
+ };
35
+ export const output = (value, mode) => {
36
+ const writer = outputWriters[mode];
37
+ writer(value);
38
+ };
39
+ // ---------------------------------------------------------------------------
40
+ // resolveOutputMode()
41
+ // ---------------------------------------------------------------------------
42
+ const VALID_MODES = new Set(['text', 'json', 'jsonl']);
43
+ /** Resolve mode from flags alone (--json, --jsonl, --output). */
44
+ const resolveFlagMode = (flags) => {
45
+ if (flags['json'] === true) {
46
+ return 'json';
47
+ }
48
+ if (flags['jsonl'] === true) {
49
+ return 'jsonl';
50
+ }
51
+ if (typeof flags['output'] === 'string' &&
52
+ VALID_MODES.has(flags['output'])) {
53
+ return flags['output'];
54
+ }
55
+ return undefined;
56
+ };
57
+ /** Resolve mode from environment variables. */
58
+ const resolveEnvMode = () => {
59
+ if (process.env['TRAILS_JSON'] === '1') {
60
+ return 'json';
61
+ }
62
+ if (process.env['TRAILS_JSONL'] === '1') {
63
+ return 'jsonl';
64
+ }
65
+ return undefined;
66
+ };
67
+ /**
68
+ * Determine the output mode from parsed CLI flags and environment.
69
+ *
70
+ * Resolution order (highest priority wins):
71
+ * 1. `flags.json === true` -> "json"
72
+ * 2. `flags.jsonl === true` -> "jsonl"
73
+ * 3. `flags.output` as string -> validate against OutputMode
74
+ * 4. `TRAILS_JSON=1` env var -> "json"
75
+ * 5. `TRAILS_JSONL=1` env var -> "jsonl"
76
+ * 6. Default: "text"
77
+ */
78
+ export const resolveOutputMode = (flags) => {
79
+ const mode = resolveFlagMode(flags) ?? resolveEnvMode() ?? 'text';
80
+ return { mode };
81
+ };
82
+ //# sourceMappingURL=output.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"output.js","sourceRoot":"","sources":["../src/output.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Surface-agnostic input resolution contracts for CLI commands.
3
+ *
4
+ * The core CLI package does not depend on any concrete prompt library.
5
+ * Callers can provide a resolver that gathers missing input however they want
6
+ * (Clack, forms, conversational UI, or no prompting at all).
7
+ */
8
+ import type { Field } from '@ontrails/core';
9
+ /**
10
+ * Options passed to an input resolver.
11
+ *
12
+ * `isTTY` is provided so callers can override interactivity in tests.
13
+ */
14
+ export interface ResolveInputOptions {
15
+ readonly isTTY?: boolean | undefined;
16
+ }
17
+ /**
18
+ * A resolver that fills in missing values for derived schema fields.
19
+ *
20
+ * The resolver receives the current input as field-name keyed values and
21
+ * returns a merged record with any newly gathered answers.
22
+ */
23
+ export type InputResolver = (fields: readonly Field[], provided: Record<string, unknown>, options?: ResolveInputOptions) => Promise<Record<string, unknown>>;
24
+ /** Default passthrough resolver for non-interactive execution. */
25
+ export declare const passthroughResolver: InputResolver;
26
+ /** Shared TTY check for resolver implementations. */
27
+ export declare const isInteractive: (options?: ResolveInputOptions) => boolean;
28
+ export type { Field };
29
+ //# sourceMappingURL=prompt.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"prompt.d.ts","sourceRoot":"","sources":["../src/prompt.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAE5C;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CACtC;AAED;;;;;GAKG;AACH,MAAM,MAAM,aAAa,GAAG,CAC1B,MAAM,EAAE,SAAS,KAAK,EAAE,EACxB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,OAAO,CAAC,EAAE,mBAAmB,KAC1B,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAEtC,kEAAkE;AAClE,eAAO,MAAM,mBAAmB,EAAE,aAClB,CAAC;AAEjB,qDAAqD;AACrD,eAAO,MAAM,aAAa,GAAI,UAAU,mBAAmB,KAAG,OACd,CAAC;AAEjD,YAAY,EAAE,KAAK,EAAE,CAAC"}
package/dist/prompt.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Surface-agnostic input resolution contracts for CLI commands.
3
+ *
4
+ * The core CLI package does not depend on any concrete prompt library.
5
+ * Callers can provide a resolver that gathers missing input however they want
6
+ * (Clack, forms, conversational UI, or no prompting at all).
7
+ */
8
+ /** Default passthrough resolver for non-interactive execution. */
9
+ export const passthroughResolver = async (_fields, provided) => await provided;
10
+ /** Shared TTY check for resolver implementations. */
11
+ export const isInteractive = (options) => options?.isTTY ?? process.stdin.isTTY ?? false;
12
+ //# sourceMappingURL=prompt.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"prompt.js","sourceRoot":"","sources":["../src/prompt.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAyBH,kEAAkE;AAClE,MAAM,CAAC,MAAM,mBAAmB,GAAkB,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAE,CAC5E,MAAM,QAAQ,CAAC;AAEjB,qDAAqD;AACrD,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,OAA6B,EAAW,EAAE,CACtE,OAAO,EAAE,KAAK,IAAI,OAAO,CAAC,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC"}
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@ontrails/cli",
3
+ "version": "1.0.0-beta.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.ts",
7
+ "./commander": "./src/commander/index.ts",
8
+ "./package.json": "./package.json"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc -b",
12
+ "test": "bun test",
13
+ "typecheck": "tsc --noEmit",
14
+ "lint": "oxlint ./src",
15
+ "clean": "rm -rf dist *.tsbuildinfo"
16
+ },
17
+ "dependencies": {
18
+ "@ontrails/core": "workspace:*"
19
+ },
20
+ "peerDependencies": {
21
+ "commander": "^14.0.3",
22
+ "zod": "catalog:"
23
+ },
24
+ "peerDependenciesMeta": {
25
+ "commander": {
26
+ "optional": true
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,78 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { Result, trail, topo } from '@ontrails/core';
4
+ import { z } from 'zod';
5
+
6
+ import { buildCliCommands } from '../build.js';
7
+ import { toCommander } from '../commander/to-commander.js';
8
+ import { defaultOnResult } from '../on-result.js';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Tests
12
+ // ---------------------------------------------------------------------------
13
+
14
+ describe('blaze', () => {
15
+ test('smoke test: buildCliCommands + toCommander wiring does not throw', () => {
16
+ const t = trail('ping', {
17
+ implementation: () => Result.ok('pong'),
18
+ input: z.object({}),
19
+ });
20
+ const app = topo('smoke-test', { ping: t });
21
+
22
+ // Reproduce blaze() steps without calling parse()
23
+ const commands = buildCliCommands(app, {
24
+ onResult: defaultOnResult,
25
+ });
26
+ const program = toCommander(commands, { name: 'smoke-test' });
27
+
28
+ expect(program.name()).toBe('smoke-test');
29
+ expect(program.commands).toHaveLength(1);
30
+ expect(program.commands[0]?.name()).toBe('ping');
31
+ });
32
+
33
+ test('uses defaultOnResult when none provided', () => {
34
+ const t = trail('echo', {
35
+ implementation: (input: { msg: string }) => Result.ok(input.msg),
36
+ input: z.object({ msg: z.string() }),
37
+ });
38
+ const app = topo('default-on-result', { echo: t });
39
+
40
+ // buildCliCommands without onResult should still work
41
+ const commands = buildCliCommands(app, {
42
+ onResult: defaultOnResult,
43
+ });
44
+ const program = toCommander(commands, { name: app.name });
45
+
46
+ expect(program.commands).toHaveLength(1);
47
+ expect(program.commands[0]?.name()).toBe('echo');
48
+ });
49
+
50
+ test('end-to-end: define trail, build commands, execute, verify output', async () => {
51
+ const written: string[] = [];
52
+ const originalWrite = process.stdout.write;
53
+ process.stdout.write = ((chunk: string) => {
54
+ written.push(chunk);
55
+ return true;
56
+ }) as typeof process.stdout.write;
57
+
58
+ try {
59
+ const t = trail('greet', {
60
+ implementation: (input: { name: string }) =>
61
+ Result.ok(`Hello, ${input.name}!`),
62
+ input: z.object({ name: z.string() }),
63
+ });
64
+ const app = topo('e2e-test', { greet: t });
65
+
66
+ const commands = buildCliCommands(app, {
67
+ onResult: defaultOnResult,
68
+ });
69
+
70
+ // Execute directly (bypassing Commander parse)
71
+ await commands[0]?.execute({}, { name: 'World' });
72
+
73
+ expect(written.join('')).toBe('Hello, World!\n');
74
+ } finally {
75
+ process.stdout.write = originalWrite;
76
+ }
77
+ });
78
+ });
@@ -0,0 +1,219 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { Result, createTrailContext, trail, topo } from '@ontrails/core';
4
+ import type { TrailContext } from '@ontrails/core';
5
+ import { z } from 'zod';
6
+
7
+ import type { ActionResultContext } from '../build.js';
8
+ import { buildCliCommands } from '../build.js';
9
+ import type { AnyTrail } from '../command.js';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Helpers
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const makeApp = (...trails: AnyTrail[]) => {
16
+ const mod: Record<string, unknown> = {};
17
+ for (const t of trails) {
18
+ mod[t.id] = t;
19
+ }
20
+ return topo('test-app', mod);
21
+ };
22
+
23
+ const requireCommand = (commands: ReturnType<typeof buildCliCommands>) => {
24
+ const [command] = commands;
25
+ expect(command).toBeDefined();
26
+ if (!command) {
27
+ throw new Error('Expected command');
28
+ }
29
+ return command;
30
+ };
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Tests
34
+ // ---------------------------------------------------------------------------
35
+
36
+ describe('buildCliCommands', () => {
37
+ test('builds commands from a simple app with one trail', () => {
38
+ const t = trail('greet', {
39
+ implementation: (input: { name: string }) =>
40
+ Result.ok(`Hello, ${input.name}`),
41
+ input: z.object({ name: z.string() }),
42
+ });
43
+ const app = makeApp(t);
44
+ const commands = buildCliCommands(app);
45
+ expect(commands).toHaveLength(1);
46
+ expect(commands[0]?.name).toBe('greet');
47
+ expect(commands[0]?.group).toBeUndefined();
48
+ });
49
+
50
+ test('builds grouped subcommands from dotted trail IDs', () => {
51
+ const show = trail('entity.show', {
52
+ implementation: (input: { id: string }) => Result.ok({ id: input.id }),
53
+ input: z.object({ id: z.string() }),
54
+ });
55
+ const add = trail('entity.add', {
56
+ implementation: (input: { name: string }) =>
57
+ Result.ok({ name: input.name }),
58
+ input: z.object({ name: z.string() }),
59
+ });
60
+ const app = makeApp(show, add);
61
+ const commands = buildCliCommands(app);
62
+ expect(commands).toHaveLength(2);
63
+ expect(commands[0]?.group).toBe('entity');
64
+ expect(commands[0]?.name).toBe('show');
65
+ expect(commands[1]?.group).toBe('entity');
66
+ expect(commands[1]?.name).toBe('add');
67
+ });
68
+
69
+ test('derives flags from input schema', () => {
70
+ const t = trail('search', {
71
+ implementation: () => Result.ok([]),
72
+ input: z.object({
73
+ limit: z.number().optional(),
74
+ query: z.string(),
75
+ }),
76
+ });
77
+ const app = makeApp(t);
78
+ const { flags } = requireCommand(buildCliCommands(app));
79
+
80
+ expect(flags).toHaveLength(2);
81
+ const queryFlag = flags.find((f) => f.name === 'query');
82
+ const limitFlag = flags.find((f) => f.name === 'limit');
83
+ expect(queryFlag?.required).toBe(true);
84
+ expect(limitFlag?.required).toBe(false);
85
+ });
86
+
87
+ test('adds --dry-run for destructive trails', () => {
88
+ const t = trail('entity.delete', {
89
+ destructive: true,
90
+ implementation: () => Result.ok(),
91
+ input: z.object({ id: z.string() }),
92
+ });
93
+ const app = makeApp(t);
94
+ const commands = buildCliCommands(app);
95
+ const dryRunFlag = commands[0]?.flags.find((f) => f.name === 'dry-run');
96
+ expect(dryRunFlag).toBeDefined();
97
+ expect(dryRunFlag?.type).toBe('boolean');
98
+ });
99
+
100
+ test('calls onResult with correct context', async () => {
101
+ let captured: ActionResultContext | undefined;
102
+ const t = trail('ping', {
103
+ implementation: (input: { msg: string }) => Result.ok(input.msg),
104
+ input: z.object({ msg: z.string() }),
105
+ });
106
+ const app = makeApp(t);
107
+ const commands = buildCliCommands(app, {
108
+ onResult: (ctx) => {
109
+ captured = ctx;
110
+ return Promise.resolve();
111
+ },
112
+ });
113
+
114
+ await commands[0]?.execute({}, { msg: 'hello' });
115
+
116
+ expect(captured).toBeDefined();
117
+ expect(captured?.trail.id).toBe('ping');
118
+ expect(captured?.input).toEqual({ msg: 'hello' });
119
+ expect(captured?.result.isOk()).toBe(true);
120
+ });
121
+
122
+ test('validates input before calling implementation', async () => {
123
+ let implCalled = false;
124
+ const t = trail('strict', {
125
+ implementation: () => {
126
+ implCalled = true;
127
+ return Result.ok('done');
128
+ },
129
+ input: z.object({ name: z.string() }),
130
+ });
131
+ const app = makeApp(t);
132
+ const commands = buildCliCommands(app);
133
+
134
+ // Pass invalid input (missing name)
135
+ const [cmd] = commands;
136
+ expect(cmd).toBeDefined();
137
+ const result = await cmd?.execute({}, {});
138
+ expect(result).toBeDefined();
139
+ expect(result?.isErr()).toBe(true);
140
+ expect(implCalled).toBe(false);
141
+ });
142
+
143
+ test('applies layers in order', async () => {
144
+ const order: string[] = [];
145
+ const t = trail('layered', {
146
+ implementation: (input: { x: string }) => {
147
+ order.push('impl');
148
+ return Result.ok(input.x);
149
+ },
150
+ input: z.object({ x: z.string() }),
151
+ });
152
+ const app = makeApp(t);
153
+ const commands = buildCliCommands(app, {
154
+ layers: [
155
+ {
156
+ name: 'outer',
157
+ wrap: (_trail, impl) => async (input, ctx) => {
158
+ order.push('outer-before');
159
+ const r = await impl(input, ctx);
160
+ order.push('outer-after');
161
+ return r;
162
+ },
163
+ },
164
+ {
165
+ name: 'inner',
166
+ wrap: (_trail, impl) => async (input, ctx) => {
167
+ order.push('inner-before');
168
+ const r = await impl(input, ctx);
169
+ order.push('inner-after');
170
+ return r;
171
+ },
172
+ },
173
+ ],
174
+ });
175
+
176
+ await commands[0]?.execute({}, { x: 'test' });
177
+ expect(order).toEqual([
178
+ 'outer-before',
179
+ 'inner-before',
180
+ 'impl',
181
+ 'inner-after',
182
+ 'outer-after',
183
+ ]);
184
+ });
185
+
186
+ test('uses provided createContext factory', async () => {
187
+ let usedRequestId: string | undefined;
188
+ const t = trail('ctx-test', {
189
+ implementation: (_input: Record<string, never>, ctx: TrailContext) => {
190
+ usedRequestId = ctx.requestId;
191
+ return Result.ok('ok');
192
+ },
193
+ input: z.object({}),
194
+ });
195
+ const app = makeApp(t);
196
+ const commands = buildCliCommands(app, {
197
+ createContext: () => createTrailContext({ requestId: 'custom-123' }),
198
+ });
199
+
200
+ await commands[0]?.execute({}, {});
201
+ expect(usedRequestId).toBe('custom-123');
202
+ });
203
+
204
+ test('converts kebab-case flags back to camelCase for input', async () => {
205
+ let receivedInput: unknown;
206
+ const t = trail('camel', {
207
+ implementation: (input) => {
208
+ receivedInput = input;
209
+ return Result.ok('ok');
210
+ },
211
+ input: z.object({ sortOrder: z.string() }),
212
+ });
213
+ const app = makeApp(t);
214
+ const commands = buildCliCommands(app);
215
+
216
+ await commands[0]?.execute({}, { 'sort-order': 'asc' });
217
+ expect(receivedInput).toEqual({ sortOrder: 'asc' });
218
+ });
219
+ });
@@ -0,0 +1,176 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { z } from 'zod';
4
+
5
+ import {
6
+ cwdPreset,
7
+ deriveFlags,
8
+ dryRunPreset,
9
+ outputModePreset,
10
+ } from '../flags.js';
11
+
12
+ const requireFlag = (flags: ReturnType<typeof deriveFlags>, name: string) => {
13
+ const flag = flags.find((entry) => entry.name === name);
14
+ expect(flag).toBeDefined();
15
+ if (!flag) {
16
+ throw new Error(`Expected flag: ${name}`);
17
+ }
18
+ return flag;
19
+ };
20
+
21
+ describe('deriveFlags', () => {
22
+ describe('primitive fields', () => {
23
+ test('z.string() derives a string flag', () => {
24
+ const flags = deriveFlags(z.object({ name: z.string() }));
25
+ const flag = requireFlag(flags, 'name');
26
+
27
+ expect(flags).toHaveLength(1);
28
+ expect(flag.type).toBe('string');
29
+ expect(flag.required).toBe(true);
30
+ expect(flag.variadic).toBe(false);
31
+ });
32
+
33
+ test('z.number() derives a number flag', () => {
34
+ const flags = deriveFlags(z.object({ count: z.number() }));
35
+ const flag = requireFlag(flags, 'count');
36
+
37
+ expect(flags).toHaveLength(1);
38
+ expect(flag.type).toBe('number');
39
+ expect(flag.required).toBe(true);
40
+ });
41
+
42
+ test('z.boolean() derives a boolean flag', () => {
43
+ const flags = deriveFlags(z.object({ verbose: z.boolean() }));
44
+ const flag = requireFlag(flags, 'verbose');
45
+
46
+ expect(flags).toHaveLength(1);
47
+ expect(flag.type).toBe('boolean');
48
+ expect(flag.required).toBe(true);
49
+ });
50
+ });
51
+
52
+ describe('complex field shapes', () => {
53
+ test('z.enum() derives a string flag with choices', () => {
54
+ const flags = deriveFlags(
55
+ z.object({ format: z.enum(['json', 'text', 'csv']) })
56
+ );
57
+ const flag = requireFlag(flags, 'format');
58
+
59
+ expect(flags).toHaveLength(1);
60
+ expect(flag.type).toBe('string');
61
+ expect(flag.choices).toEqual(['json', 'text', 'csv']);
62
+ });
63
+
64
+ test('z.array(z.string()) derives a variadic string[] flag', () => {
65
+ const flags = deriveFlags(z.object({ tags: z.array(z.string()) }));
66
+ const flag = requireFlag(flags, 'tags');
67
+
68
+ expect(flags).toHaveLength(1);
69
+ expect(flag.type).toBe('string[]');
70
+ expect(flag.variadic).toBe(true);
71
+ });
72
+
73
+ test('z.array(z.number()) derives a variadic number[] flag', () => {
74
+ const flags = deriveFlags(z.object({ ids: z.array(z.number()) }));
75
+ const flag = requireFlag(flags, 'ids');
76
+
77
+ expect(flags).toHaveLength(1);
78
+ expect(flag.type).toBe('number[]');
79
+ expect(flag.variadic).toBe(true);
80
+ });
81
+ });
82
+
83
+ describe('modifiers and naming', () => {
84
+ test('z.optional() sets required: false', () => {
85
+ const flags = deriveFlags(z.object({ label: z.string().optional() }));
86
+ const flag = requireFlag(flags, 'label');
87
+
88
+ expect(flags).toHaveLength(1);
89
+ expect(flag.required).toBe(false);
90
+ });
91
+
92
+ test('z.default() sets required: false and populates default', () => {
93
+ const flags = deriveFlags(z.object({ limit: z.number().default(10) }));
94
+ const flag = requireFlag(flags, 'limit');
95
+
96
+ expect(flags).toHaveLength(1);
97
+ expect(flag.required).toBe(false);
98
+ expect(flag.default).toBe(10);
99
+ });
100
+
101
+ test('.describe() populates flag description', () => {
102
+ const flags = deriveFlags(
103
+ z.object({ query: z.string().describe('Search query') })
104
+ );
105
+
106
+ expect(flags).toHaveLength(1);
107
+ expect(requireFlag(flags, 'query').description).toBe('Search query');
108
+ });
109
+
110
+ test('camelCase field names convert to kebab-case flag names', () => {
111
+ const flags = deriveFlags(
112
+ z.object({ maxItems: z.number(), sortOrder: z.string() })
113
+ );
114
+ const names = flags.map((f) => f.name);
115
+
116
+ expect(flags).toHaveLength(2);
117
+ expect(names).toContain('sort-order');
118
+ expect(names).toContain('max-items');
119
+ });
120
+ });
121
+
122
+ describe('edge cases', () => {
123
+ test('non-object schema returns empty flags', () => {
124
+ const flags = deriveFlags(z.string());
125
+ expect(flags).toHaveLength(0);
126
+ });
127
+
128
+ test('handles multiple fields together', () => {
129
+ const schema = z.object({
130
+ count: z.number().optional(),
131
+ name: z.string(),
132
+ tags: z.array(z.string()),
133
+ verbose: z.boolean().default(false),
134
+ });
135
+ const flags = deriveFlags(schema);
136
+ expect(flags).toHaveLength(4);
137
+ });
138
+ });
139
+ });
140
+
141
+ describe('outputModePreset', () => {
142
+ test('returns --output, --json, --jsonl flags', () => {
143
+ const flags = outputModePreset();
144
+ expect(flags).toHaveLength(3);
145
+
146
+ const outputFlag = requireFlag(flags, 'output');
147
+ const jsonFlag = requireFlag(flags, 'json');
148
+ const jsonlFlag = requireFlag(flags, 'jsonl');
149
+
150
+ expect(outputFlag.short).toBe('o');
151
+ expect(outputFlag.choices).toEqual(['text', 'json', 'jsonl']);
152
+ expect(outputFlag.default).toBe('text');
153
+ expect(jsonFlag.type).toBe('boolean');
154
+ expect(jsonlFlag.type).toBe('boolean');
155
+ });
156
+ });
157
+
158
+ describe('cwdPreset', () => {
159
+ test('returns --cwd flag', () => {
160
+ const flags = cwdPreset();
161
+ expect(flags).toHaveLength(1);
162
+ expect(flags[0]?.name).toBe('cwd');
163
+ expect(flags[0]?.type).toBe('string');
164
+ expect(flags[0]?.required).toBe(false);
165
+ });
166
+ });
167
+
168
+ describe('dryRunPreset', () => {
169
+ test('returns --dry-run flag', () => {
170
+ const flags = dryRunPreset();
171
+ expect(flags).toHaveLength(1);
172
+ expect(flags[0]?.name).toBe('dry-run');
173
+ expect(flags[0]?.type).toBe('boolean');
174
+ expect(flags[0]?.default).toBe(false);
175
+ });
176
+ });