@outfitter/cli 0.5.2 → 1.0.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 (110) hide show
  1. package/README.md +179 -60
  2. package/dist/actions.d.ts +5 -2
  3. package/dist/actions.js +2 -2
  4. package/dist/cli.d.ts +1 -1
  5. package/dist/cli.js +8 -1
  6. package/dist/colors/index.js +1 -17
  7. package/dist/command.d.ts +3 -43
  8. package/dist/command.js +241 -13
  9. package/dist/envelope.d.ts +5 -0
  10. package/dist/envelope.js +160 -0
  11. package/dist/flags.d.ts +5 -189
  12. package/dist/flags.js +5 -1
  13. package/dist/hints.d.ts +34 -0
  14. package/dist/hints.js +26 -0
  15. package/dist/index.d.ts +3 -2
  16. package/dist/index.js +2 -17
  17. package/dist/input.d.ts +3 -124
  18. package/dist/input.js +14 -359
  19. package/dist/internal/envelope-helpers.d.ts +4 -0
  20. package/dist/internal/envelope-helpers.js +24 -0
  21. package/dist/internal/envelope-types.d.ts +3 -0
  22. package/dist/internal/flag-builders.d.ts +3 -0
  23. package/dist/internal/flag-builders.js +155 -0
  24. package/dist/internal/flag-types.d.ts +3 -0
  25. package/dist/internal/flag-types.js +13 -0
  26. package/dist/internal/hint-action-graph.d.ts +5 -0
  27. package/dist/internal/hint-action-graph.js +11 -0
  28. package/dist/internal/hint-command-tree.d.ts +5 -0
  29. package/dist/internal/hint-command-tree.js +9 -0
  30. package/dist/internal/hint-error-recovery.d.ts +2 -0
  31. package/dist/internal/hint-error-recovery.js +7 -0
  32. package/dist/internal/hint-types.d.ts +4 -0
  33. package/dist/internal/hint-types.js +1 -0
  34. package/dist/internal/input-helpers.d.ts +18 -0
  35. package/dist/internal/input-helpers.js +11 -0
  36. package/dist/internal/input-normalization.d.ts +3 -0
  37. package/dist/internal/input-normalization.js +9 -0
  38. package/dist/internal/input-parsers.d.ts +3 -0
  39. package/dist/internal/input-parsers.js +19 -0
  40. package/dist/internal/input-security.d.ts +22 -0
  41. package/dist/internal/input-security.js +11 -0
  42. package/dist/internal/output-formatting.d.ts +3 -0
  43. package/dist/internal/output-formatting.js +21 -0
  44. package/dist/internal/presets.d.ts +3 -0
  45. package/dist/{shared/@outfitter/cli-pdb7znbq.js → internal/presets.js} +49 -223
  46. package/dist/internal/schema-commands.d.ts +3 -0
  47. package/dist/{shared/@outfitter/cli-0cjts94k.js → internal/schema-commands.js} +12 -100
  48. package/dist/internal/schema-formatting.d.ts +2 -0
  49. package/dist/internal/schema-formatting.js +7 -0
  50. package/dist/internal/schema-types.d.ts +2 -0
  51. package/dist/internal/schema-types.js +1 -0
  52. package/dist/output.d.ts +4 -3
  53. package/dist/output.js +10 -2
  54. package/dist/pagination.d.ts +1 -1
  55. package/dist/query.d.ts +84 -2
  56. package/dist/query.js +8 -45
  57. package/dist/schema-input.d.ts +80 -0
  58. package/dist/schema-input.js +15 -0
  59. package/dist/schema.d.ts +4 -1
  60. package/dist/schema.js +1 -1
  61. package/dist/shared/@outfitter/cli-10wxfc78.d.ts +45 -0
  62. package/dist/shared/@outfitter/cli-16wg5mka.d.ts +71 -0
  63. package/dist/shared/@outfitter/cli-1q5redaj.js +267 -0
  64. package/dist/shared/@outfitter/cli-2dfxs239.js +98 -0
  65. package/dist/shared/@outfitter/cli-30mt7c5w.d.ts +112 -0
  66. package/dist/shared/@outfitter/cli-3jta1h1h.js +134 -0
  67. package/dist/shared/@outfitter/cli-4h85mpth.js +76 -0
  68. package/dist/shared/@outfitter/cli-6shkwxdc.js +28 -0
  69. package/dist/shared/@outfitter/cli-89335n9a.js +16 -0
  70. package/dist/shared/@outfitter/cli-8999qjdd.js +3 -0
  71. package/dist/shared/@outfitter/cli-8cfxdady.js +60 -0
  72. package/dist/shared/@outfitter/cli-bcajqy33.d.ts +25 -0
  73. package/dist/shared/@outfitter/cli-c09332vm.d.ts +39 -0
  74. package/dist/shared/@outfitter/cli-cgha038c.d.ts +3 -0
  75. package/dist/shared/@outfitter/{cli-zahqsaby.js → cli-d40m2x1d.js} +19 -3
  76. package/dist/shared/@outfitter/{cli-7wp5nj0s.js → cli-dg0cz7rw.js} +39 -81
  77. package/dist/shared/@outfitter/cli-dv8kk4jw.d.ts +24 -0
  78. package/dist/shared/@outfitter/cli-g43887b7.js +20 -0
  79. package/dist/shared/@outfitter/cli-gqtkhgw4.js +52 -0
  80. package/dist/shared/@outfitter/cli-h4ejpmjs.d.ts +104 -0
  81. package/dist/shared/@outfitter/cli-htzez8v2.js +70 -0
  82. package/dist/shared/@outfitter/cli-hvg2m5gf.js +79 -0
  83. package/dist/shared/@outfitter/cli-n54zs151.d.ts +78 -0
  84. package/dist/shared/@outfitter/cli-nbpgw7z7.d.ts +15 -0
  85. package/dist/shared/@outfitter/cli-nkt399zf.d.ts +94 -0
  86. package/dist/shared/@outfitter/cli-pmd04gtv.d.ts +60 -0
  87. package/dist/shared/@outfitter/{cli-xy3gs50c.d.ts → cli-q6csxmeh.d.ts} +19 -12
  88. package/dist/shared/@outfitter/cli-qcskd96y.d.ts +11 -0
  89. package/dist/shared/@outfitter/cli-ry7btmy4.js +118 -0
  90. package/dist/shared/@outfitter/cli-sy99pjyj.js +32 -0
  91. package/dist/shared/@outfitter/cli-tm2fzngs.d.ts +23 -0
  92. package/dist/shared/@outfitter/cli-vvvhjwks.js +106 -0
  93. package/dist/shared/@outfitter/cli-wjv7g1aq.d.ts +16 -0
  94. package/dist/shared/@outfitter/{cli-98aa9104.d.ts → cli-x6qr7bnd.d.ts} +338 -16
  95. package/dist/shared/@outfitter/cli-xde45xcc.d.ts +53 -0
  96. package/dist/shared/@outfitter/cli-xw8ys1je.d.ts +123 -0
  97. package/dist/shared/@outfitter/cli-yfewnyc2.d.ts +43 -0
  98. package/dist/shared/@outfitter/cli-zkzj0q4q.js +99 -0
  99. package/dist/shared/@outfitter/cli-zv3ah6f0.js +3 -0
  100. package/dist/streaming.d.ts +47 -0
  101. package/dist/streaming.js +13 -0
  102. package/dist/terminal/index.js +1 -19
  103. package/dist/truncation.d.ts +104 -0
  104. package/dist/truncation.js +111 -0
  105. package/dist/types.d.ts +2 -2
  106. package/dist/types.js +0 -5
  107. package/dist/verbs.d.ts +1 -1
  108. package/package.json +66 -36
  109. package/dist/shared/@outfitter/cli-n1k0d23k.d.ts +0 -33
  110. /package/dist/{shared/@outfitter/cli-zw75pdk8.js → internal/envelope-types.js} +0 -0
@@ -0,0 +1,267 @@
1
+ // @bun
2
+ import {
3
+ isSecureGlobPattern,
4
+ isSecurePath,
5
+ isWithinWorkspace
6
+ } from "./cli-sy99pjyj.js";
7
+ import {
8
+ isDirectory,
9
+ readStdin
10
+ } from "./cli-6shkwxdc.js";
11
+
12
+ // packages/cli/src/internal/input-parsers.ts
13
+ import path from "path";
14
+ import { ValidationError } from "@outfitter/contracts";
15
+ import { Err, Ok } from "better-result";
16
+ async function expandFileArg(input, options) {
17
+ const {
18
+ encoding: _encoding = "utf-8",
19
+ maxSize,
20
+ trim = false
21
+ } = options ?? {};
22
+ if (!input.startsWith("@")) {
23
+ return input;
24
+ }
25
+ const filePath = input.slice(1);
26
+ if (filePath === "-") {
27
+ let content2 = await readStdin();
28
+ if (trim) {
29
+ content2 = content2.trim();
30
+ }
31
+ return content2;
32
+ }
33
+ if (!isSecurePath(filePath, true)) {
34
+ throw new Error(`Security error: path traversal not allowed: ${filePath}`);
35
+ }
36
+ const file = Bun.file(filePath);
37
+ const exists = await file.exists();
38
+ if (!exists) {
39
+ throw new Error(`File not found: ${filePath}`);
40
+ }
41
+ if (maxSize !== undefined) {
42
+ const size = file.size;
43
+ if (size > maxSize) {
44
+ throw new Error(`File exceeds maximum size of ${maxSize} bytes`);
45
+ }
46
+ }
47
+ let content = await file.text();
48
+ if (trim) {
49
+ content = content.trim();
50
+ }
51
+ return content;
52
+ }
53
+ async function parseGlob(pattern, options) {
54
+ const {
55
+ cwd = process.cwd(),
56
+ ignore = [],
57
+ onlyFiles = false,
58
+ onlyDirectories = false,
59
+ followSymlinks = false
60
+ } = options ?? {};
61
+ if (!isSecureGlobPattern(pattern)) {
62
+ throw new Error(`Security error: glob pattern may escape workspace: ${pattern}`);
63
+ }
64
+ const resolvedCwd = path.resolve(cwd);
65
+ const glob = new Bun.Glob(pattern);
66
+ const matches = [];
67
+ const scanOptions = {
68
+ cwd,
69
+ followSymlinks,
70
+ onlyFiles: onlyFiles === true
71
+ };
72
+ for await (const match of glob.scan(scanOptions)) {
73
+ const fullPath = path.resolve(cwd, match);
74
+ if (!isWithinWorkspace(fullPath, resolvedCwd)) {
75
+ continue;
76
+ }
77
+ let shouldIgnore = false;
78
+ for (const ignorePattern of ignore) {
79
+ const ignoreGlob = new Bun.Glob(ignorePattern);
80
+ if (ignoreGlob.match(match)) {
81
+ shouldIgnore = true;
82
+ break;
83
+ }
84
+ }
85
+ if (shouldIgnore)
86
+ continue;
87
+ if (onlyDirectories) {
88
+ const isDir = await isDirectory(fullPath);
89
+ if (!isDir)
90
+ continue;
91
+ }
92
+ matches.push(match);
93
+ }
94
+ return matches;
95
+ }
96
+ function parseKeyValue(input) {
97
+ const pairs = [];
98
+ const inputs = Array.isArray(input) ? input : [input];
99
+ for (const item of inputs) {
100
+ if (!item)
101
+ continue;
102
+ const parts = item.split(",");
103
+ for (const part of parts) {
104
+ const trimmed = part.trim();
105
+ if (!trimmed)
106
+ continue;
107
+ const eqIndex = trimmed.indexOf("=");
108
+ if (eqIndex === -1) {
109
+ return new Err(new ValidationError({
110
+ message: `Missing '=' in key-value pair: ${trimmed}`
111
+ }));
112
+ }
113
+ const key = trimmed.slice(0, eqIndex).trim();
114
+ const value = trimmed.slice(eqIndex + 1);
115
+ if (!key) {
116
+ return new Err(new ValidationError({ message: "Empty key in key-value pair" }));
117
+ }
118
+ pairs.push({ key, value });
119
+ }
120
+ }
121
+ return new Ok(pairs);
122
+ }
123
+ function parseRange(input, type) {
124
+ const trimmed = input.trim();
125
+ if (type === "date") {
126
+ const parts = trimmed.split("..");
127
+ if (parts.length === 1) {
128
+ const dateStr = parts[0];
129
+ if (dateStr === undefined) {
130
+ return new Err(new ValidationError({ message: "Empty date input" }));
131
+ }
132
+ const date = new Date(dateStr.trim());
133
+ if (Number.isNaN(date.getTime())) {
134
+ return new Err(new ValidationError({ message: `Invalid date format: ${dateStr}` }));
135
+ }
136
+ return new Ok({ type: "date", start: date, end: date });
137
+ }
138
+ if (parts.length === 2) {
139
+ const startStr = parts[0];
140
+ const endStr = parts[1];
141
+ if (startStr === undefined || endStr === undefined) {
142
+ return new Err(new ValidationError({ message: "Invalid date range format" }));
143
+ }
144
+ const start = new Date(startStr.trim());
145
+ const end = new Date(endStr.trim());
146
+ if (Number.isNaN(start.getTime())) {
147
+ return new Err(new ValidationError({ message: `Invalid date format: ${startStr}` }));
148
+ }
149
+ if (Number.isNaN(end.getTime())) {
150
+ return new Err(new ValidationError({ message: `Invalid date format: ${endStr}` }));
151
+ }
152
+ if (start.getTime() > end.getTime()) {
153
+ return new Err(new ValidationError({
154
+ message: "Start date must be before or equal to end date"
155
+ }));
156
+ }
157
+ return new Ok({ type: "date", start, end });
158
+ }
159
+ return new Err(new ValidationError({ message: `Invalid date range format: ${input}` }));
160
+ }
161
+ const singleNum = Number(trimmed);
162
+ if (!(Number.isNaN(singleNum) || trimmed.includes("-", trimmed.startsWith("-") ? 1 : 0))) {
163
+ return new Ok({ type: "number", min: singleNum, max: singleNum });
164
+ }
165
+ let separatorIndex = -1;
166
+ for (let i = 1;i < trimmed.length; i++) {
167
+ const char = trimmed[i];
168
+ const prevChar = trimmed[i - 1];
169
+ if (char === "-" && prevChar !== undefined && /[\d\s]/.test(prevChar)) {
170
+ separatorIndex = i;
171
+ break;
172
+ }
173
+ }
174
+ if (separatorIndex === -1) {
175
+ return new Err(new ValidationError({ message: `Invalid numeric range format: ${input}` }));
176
+ }
177
+ const minStr = trimmed.slice(0, separatorIndex).trim();
178
+ const maxStr = trimmed.slice(separatorIndex + 1).trim();
179
+ const min = Number(minStr);
180
+ const max = Number(maxStr);
181
+ if (Number.isNaN(min)) {
182
+ return new Err(new ValidationError({ message: `Invalid number: ${minStr}` }));
183
+ }
184
+ if (Number.isNaN(max)) {
185
+ return new Err(new ValidationError({ message: `Invalid number: ${maxStr}` }));
186
+ }
187
+ if (min > max) {
188
+ return new Err(new ValidationError({ message: "Min must be less than or equal to max" }));
189
+ }
190
+ return new Ok({ type: "number", min, max });
191
+ }
192
+ function parseFilter(input) {
193
+ const trimmed = input.trim();
194
+ if (!trimmed) {
195
+ return new Ok([]);
196
+ }
197
+ const filters = [];
198
+ const parts = trimmed.split(",");
199
+ for (const part of parts) {
200
+ let partTrimmed = part.trim();
201
+ if (!partTrimmed)
202
+ continue;
203
+ let isNegated = false;
204
+ if (partTrimmed.startsWith("!")) {
205
+ isNegated = true;
206
+ partTrimmed = partTrimmed.slice(1).trim();
207
+ }
208
+ const colonIndex = partTrimmed.indexOf(":");
209
+ if (colonIndex === -1) {
210
+ return new Err(new ValidationError({
211
+ message: `Missing ':' in filter expression: ${part.trim()}`
212
+ }));
213
+ }
214
+ const field = partTrimmed.slice(0, colonIndex).trim();
215
+ let value = partTrimmed.slice(colonIndex + 1).trim();
216
+ let operator;
217
+ if (isNegated) {
218
+ operator = "ne";
219
+ } else if (value.startsWith(">=")) {
220
+ operator = "gte";
221
+ value = value.slice(2).trim();
222
+ } else if (value.startsWith("<=")) {
223
+ operator = "lte";
224
+ value = value.slice(2).trim();
225
+ } else if (value.startsWith(">")) {
226
+ operator = "gt";
227
+ value = value.slice(1).trim();
228
+ } else if (value.startsWith("<")) {
229
+ operator = "lt";
230
+ value = value.slice(1).trim();
231
+ } else if (value.startsWith("~")) {
232
+ operator = "contains";
233
+ value = value.slice(1).trim();
234
+ }
235
+ filters.push(operator ? { field, operator, value } : { field, value });
236
+ }
237
+ return new Ok(filters);
238
+ }
239
+ function parseSortSpec(input) {
240
+ const trimmed = input.trim();
241
+ if (!trimmed) {
242
+ return new Ok([]);
243
+ }
244
+ const criteria = [];
245
+ const parts = trimmed.split(",");
246
+ for (const part of parts) {
247
+ const partTrimmed = part.trim();
248
+ if (!partTrimmed)
249
+ continue;
250
+ const colonIndex = partTrimmed.indexOf(":");
251
+ if (colonIndex === -1) {
252
+ criteria.push({ field: partTrimmed, direction: "asc" });
253
+ } else {
254
+ const field = partTrimmed.slice(0, colonIndex).trim();
255
+ const direction = partTrimmed.slice(colonIndex + 1).trim().toLowerCase();
256
+ if (direction !== "asc" && direction !== "desc") {
257
+ return new Err(new ValidationError({
258
+ message: `Invalid sort direction: ${direction}. Must be 'asc' or 'desc'.`
259
+ }));
260
+ }
261
+ criteria.push({ field, direction });
262
+ }
263
+ }
264
+ return new Ok(criteria);
265
+ }
266
+
267
+ export { expandFileArg, parseGlob, parseKeyValue, parseRange, parseFilter, parseSortSpec };
@@ -0,0 +1,98 @@
1
+ // @bun
2
+ // packages/cli/src/internal/hint-error-recovery.ts
3
+ import {
4
+ retryableMap
5
+ } from "@outfitter/contracts";
6
+ var CATEGORY_RECOVERY_MAP = {
7
+ validation: (cliName) => [
8
+ {
9
+ description: "Check input parameters and try again",
10
+ command: cliName ? `${cliName} --help` : "--help",
11
+ params: { retryable: retryableMap.validation }
12
+ }
13
+ ],
14
+ not_found: (cliName) => [
15
+ {
16
+ description: "Verify the resource identifier exists",
17
+ command: cliName ? `${cliName} list` : "list",
18
+ params: { retryable: retryableMap.not_found }
19
+ }
20
+ ],
21
+ conflict: (cliName, commandName) => {
22
+ const cmd = commandName || "<previous-command>";
23
+ return [
24
+ {
25
+ description: "Resolve the conflict and retry",
26
+ command: cliName ? `${cliName} ${cmd} --force` : `${cmd} --force`,
27
+ params: { retryable: retryableMap.conflict }
28
+ }
29
+ ];
30
+ },
31
+ permission: (cliName) => [
32
+ {
33
+ description: "Check your permissions or request access",
34
+ command: cliName ? `${cliName} auth status` : "auth status",
35
+ params: { retryable: retryableMap.permission }
36
+ }
37
+ ],
38
+ timeout: (cliName, commandName) => {
39
+ const cmd = commandName || "<previous-command>";
40
+ return [
41
+ {
42
+ description: "Retry the operation \u2014 transient timeout may resolve",
43
+ command: cliName ? `${cliName} ${cmd}` : cmd,
44
+ params: { retryable: retryableMap.timeout }
45
+ }
46
+ ];
47
+ },
48
+ rate_limit: (cliName, commandName) => {
49
+ const cmd = commandName || "<previous-command>";
50
+ return [
51
+ {
52
+ description: "Wait and retry \u2014 rate limit will reset",
53
+ command: cliName ? `${cliName} ${cmd}` : cmd,
54
+ params: { retryable: retryableMap.rate_limit }
55
+ }
56
+ ];
57
+ },
58
+ network: (cliName, commandName) => {
59
+ const cmd = commandName || "<previous-command>";
60
+ return [
61
+ {
62
+ description: "Retry the operation \u2014 network issue may be transient",
63
+ command: cliName ? `${cliName} ${cmd}` : cmd,
64
+ params: { retryable: retryableMap.network }
65
+ }
66
+ ];
67
+ },
68
+ internal: (cliName) => [
69
+ {
70
+ description: "Report this error \u2014 unexpected internal failure",
71
+ command: cliName ? `${cliName} --help` : "--help",
72
+ params: { retryable: retryableMap.internal }
73
+ }
74
+ ],
75
+ auth: (cliName) => [
76
+ {
77
+ description: "Authenticate or refresh your credentials",
78
+ command: cliName ? `${cliName} auth login` : "auth login",
79
+ params: { retryable: retryableMap.auth }
80
+ }
81
+ ],
82
+ cancelled: (cliName, commandName) => {
83
+ const cmd = commandName || "<previous-command>";
84
+ return [
85
+ {
86
+ description: "Operation was cancelled \u2014 re-run to try again",
87
+ command: cliName ? `${cliName} ${cmd}` : cmd,
88
+ params: { retryable: retryableMap.cancelled }
89
+ }
90
+ ];
91
+ }
92
+ };
93
+ function errorRecoveryHints(category, cliName, commandName) {
94
+ const factory = CATEGORY_RECOVERY_MAP[category];
95
+ return factory(cliName, commandName);
96
+ }
97
+
98
+ export { errorRecoveryHints };
@@ -0,0 +1,112 @@
1
+ import { OutputMode } from "./cli-x6qr7bnd.js";
2
+ import { CLIHint, ErrorCategory, OutfitterError } from "@outfitter/contracts";
3
+ import { Result } from "better-result";
4
+ /**
5
+ * Structured success envelope wrapping a command result.
6
+ *
7
+ * The `hints` field is absent (not an empty array) when there are no hints.
8
+ * This avoids Clippy-style noise in terminal output.
9
+ */
10
+ interface SuccessEnvelope<T = unknown> {
11
+ readonly ok: true;
12
+ readonly command: string;
13
+ readonly result: T;
14
+ readonly hints?: CLIHint[];
15
+ }
16
+ /**
17
+ * Structured error envelope wrapping a command failure.
18
+ *
19
+ * The `hints` field is absent (not an empty array) when there are no hints.
20
+ * The `retryable` field indicates whether the error is transient and safe to retry.
21
+ * The `retry_after` field is only present for rate_limit errors with a known delay.
22
+ */
23
+ interface ErrorEnvelope {
24
+ readonly ok: false;
25
+ readonly command: string;
26
+ readonly error: {
27
+ readonly category: ErrorCategory;
28
+ readonly message: string;
29
+ readonly retryable: boolean;
30
+ readonly retry_after?: number;
31
+ };
32
+ readonly hints?: CLIHint[];
33
+ }
34
+ /**
35
+ * Discriminated union of success and error envelopes.
36
+ *
37
+ * Use `envelope.ok` to narrow:
38
+ * ```typescript
39
+ * if (envelope.ok) {
40
+ * // SuccessEnvelope — envelope.result is available
41
+ * } else {
42
+ * // ErrorEnvelope — envelope.error is available
43
+ * }
44
+ * ```
45
+ */
46
+ type CommandEnvelope<T = unknown> = SuccessEnvelope<T> | ErrorEnvelope;
47
+ /**
48
+ * Options for the runHandler lifecycle bridge.
49
+ *
50
+ * @typeParam TInput - Type of validated input
51
+ * @typeParam TOutput - Type of handler result
52
+ * @typeParam TContext - Type of context object
53
+ */
54
+ interface RunHandlerOptions<
55
+ TInput = unknown,
56
+ TOutput = unknown,
57
+ TContext = unknown
58
+ > {
59
+ /** Command name for the envelope */
60
+ readonly command: string;
61
+ /**
62
+ * Handler function returning a Result.
63
+ *
64
+ * When a context factory is provided, receives (input, context).
65
+ * When no context factory, receives (input, undefined).
66
+ */
67
+ readonly handler: (input: TInput, context: TContext) => Promise<Result<TOutput, OutfitterError>>;
68
+ /** Validated input to pass to context factory and handler */
69
+ readonly input?: TInput;
70
+ /** Output format (json, jsonl, human) */
71
+ readonly format?: OutputMode;
72
+ /**
73
+ * Async factory for constructing handler context.
74
+ * Called before the handler with the validated input.
75
+ */
76
+ readonly contextFactory?: (input: TInput) => Promise<TContext> | TContext;
77
+ /** Success hint function — called with (result, input) */
78
+ readonly hints?: (result: unknown, input: TInput) => CLIHint[];
79
+ /** Error hint function — called with (error, input) */
80
+ readonly onError?: (error: unknown, input: TInput) => CLIHint[];
81
+ /**
82
+ * Enable NDJSON streaming mode.
83
+ *
84
+ * When `true`, the handler receives a `progress` callback via context
85
+ * and the CLI writes progress events as NDJSON lines to stdout.
86
+ * The final line is the standard command envelope (success or error).
87
+ * The CLI owns the initial `start` event, so handlers should emit only
88
+ * `step` and `progress` events through `ctx.progress`.
89
+ *
90
+ * `--stream` is orthogonal to output mode — it controls delivery, not serialization.
91
+ */
92
+ readonly stream?: boolean;
93
+ /**
94
+ * Indicate that this is a dry-run invocation of a destructive command.
95
+ *
96
+ * When `true`, the success envelope includes a CLIHint with the command
97
+ * to execute without `--dry-run` (preview-then-commit pattern).
98
+ *
99
+ * The handler is responsible for checking the dry-run flag and performing
100
+ * preview-only logic. This option only controls hint generation in the envelope.
101
+ */
102
+ readonly dryRun?: boolean;
103
+ /**
104
+ * Parsed argv to use for dry-run hint generation.
105
+ *
106
+ * Defaults to `process.argv.slice(2)`. Pass explicit argv when using
107
+ * `cli.parse(customArgv)` to ensure the dry-run hint reconstructs the
108
+ * correct command.
109
+ */
110
+ readonly argv?: readonly string[];
111
+ }
112
+ export { SuccessEnvelope, ErrorEnvelope, CommandEnvelope, RunHandlerOptions };
@@ -0,0 +1,134 @@
1
+ // @bun
2
+ // packages/cli/src/schema-input.ts
3
+ import { ValidationError } from "@outfitter/contracts";
4
+ import { Option } from "commander";
5
+ function camelToKebab(str) {
6
+ return str.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
7
+ }
8
+ function unwrapZodField(field) {
9
+ let current = field;
10
+ let description = current.description;
11
+ let defaultValue = undefined;
12
+ let hasDefault = false;
13
+ let isOptional = false;
14
+ while (true) {
15
+ if (description === undefined) {
16
+ description = current.description;
17
+ }
18
+ const def = current._zod?.def;
19
+ if (!def?.type)
20
+ break;
21
+ if (def.type === "default") {
22
+ hasDefault = true;
23
+ defaultValue = def.defaultValue;
24
+ current = def.innerType;
25
+ continue;
26
+ }
27
+ if (def.type === "optional" || def.type === "nullable") {
28
+ isOptional = true;
29
+ current = def.innerType;
30
+ continue;
31
+ }
32
+ return {
33
+ baseType: def.type,
34
+ description,
35
+ hasDefault,
36
+ defaultValue,
37
+ isOptional,
38
+ enumValues: def.type === "enum" ? current.options : undefined
39
+ };
40
+ }
41
+ return {
42
+ baseType: "unknown",
43
+ description,
44
+ hasDefault,
45
+ defaultValue,
46
+ isOptional,
47
+ enumValues: undefined
48
+ };
49
+ }
50
+ function deriveFlags(schema, explicitLongFlags) {
51
+ const flags = [];
52
+ for (const [fieldName, field] of Object.entries(schema.shape)) {
53
+ const kebabName = camelToKebab(fieldName);
54
+ const longFlag = `--${kebabName}`;
55
+ if (explicitLongFlags.has(longFlag))
56
+ continue;
57
+ const info = unwrapZodField(field);
58
+ const desc = info.description ?? fieldName;
59
+ let flagString;
60
+ let isBoolean = false;
61
+ const isRequired = !info.hasDefault && !info.isOptional;
62
+ switch (info.baseType) {
63
+ case "boolean":
64
+ flagString = longFlag;
65
+ isBoolean = true;
66
+ break;
67
+ case "number":
68
+ flagString = `${longFlag} <n>`;
69
+ break;
70
+ case "enum":
71
+ flagString = `${longFlag} <value>`;
72
+ break;
73
+ default:
74
+ flagString = `${longFlag} <value>`;
75
+ break;
76
+ }
77
+ flags.push({
78
+ name: fieldName,
79
+ longFlag,
80
+ flagString,
81
+ description: desc,
82
+ defaultValue: info.hasDefault ? info.defaultValue : undefined,
83
+ isBoolean,
84
+ isRequired
85
+ });
86
+ }
87
+ return flags;
88
+ }
89
+ function createCommanderOption(flag, schema) {
90
+ const option = new Option(flag.flagString, flag.description);
91
+ if (flag.defaultValue !== undefined) {
92
+ option.default(flag.defaultValue);
93
+ }
94
+ const fieldInfo = unwrapZodField(schema.shape[flag.name]);
95
+ if (fieldInfo.baseType === "enum" && fieldInfo.enumValues) {
96
+ option.choices(fieldInfo.enumValues);
97
+ }
98
+ if (fieldInfo.baseType === "number") {
99
+ option.argParser(Number);
100
+ }
101
+ if (flag.isRequired) {
102
+ option.makeOptionMandatory(true);
103
+ }
104
+ return option;
105
+ }
106
+ function validateInput(flags, schema) {
107
+ const input = {};
108
+ for (const fieldName of Object.keys(schema.shape)) {
109
+ if (fieldName in flags && flags[fieldName] !== undefined) {
110
+ input[fieldName] = flags[fieldName];
111
+ }
112
+ }
113
+ const result = schema.safeParse(input);
114
+ if (result.success) {
115
+ return result.data;
116
+ }
117
+ const rawError = result.error;
118
+ const rawIssues = rawError?.issues ?? [];
119
+ const issues = rawIssues.map((issue) => ({
120
+ field: (issue.path ?? []).join("."),
121
+ expected: issue.expected,
122
+ message: issue.message ?? "Unknown validation error",
123
+ code: issue.code
124
+ }));
125
+ const fieldNames = issues.map((i) => i.field).filter(Boolean);
126
+ const summary = fieldNames.length > 0 ? `Invalid input: ${fieldNames.join(", ")}` : "Invalid input";
127
+ const detail = issues.map((i) => i.field ? ` ${i.field}: ${i.message}` : ` ${i.message}`).join(`
128
+ `);
129
+ const message = detail ? `${summary}
130
+ ${detail}` : summary;
131
+ throw ValidationError.fromMessage(message, { issues });
132
+ }
133
+
134
+ export { camelToKebab, unwrapZodField, deriveFlags, createCommanderOption, validateInput };
@@ -0,0 +1,76 @@
1
+ // @bun
2
+ import {
3
+ applyOutputTruncation,
4
+ cliStringify,
5
+ detectMode,
6
+ formatErrorHuman,
7
+ formatHuman,
8
+ getExitCode,
9
+ serializeErrorToJson,
10
+ writeWithBackpressure
11
+ } from "./cli-dg0cz7rw.js";
12
+
13
+ // packages/cli/src/output.ts
14
+ import { getEnvironment, getEnvironmentDefaults } from "@outfitter/config";
15
+ async function output(data, format, options) {
16
+ const mode = detectMode(format);
17
+ const stream = options?.stream ?? process.stdout;
18
+ const renderedData = applyOutputTruncation(data, options?.truncation);
19
+ let outputText;
20
+ switch (mode) {
21
+ case "json": {
22
+ const jsonData = renderedData === undefined ? null : renderedData;
23
+ outputText = cliStringify(jsonData, options?.pretty);
24
+ break;
25
+ }
26
+ case "jsonl": {
27
+ if (Array.isArray(renderedData)) {
28
+ if (renderedData.length === 0) {
29
+ outputText = "";
30
+ } else {
31
+ outputText = renderedData.map((item) => cliStringify(item)).join(`
32
+ `);
33
+ }
34
+ } else {
35
+ outputText = cliStringify(renderedData);
36
+ }
37
+ break;
38
+ }
39
+ default: {
40
+ outputText = formatHuman(renderedData);
41
+ break;
42
+ }
43
+ }
44
+ if (outputText) {
45
+ await writeWithBackpressure(stream, `${outputText}
46
+ `);
47
+ }
48
+ }
49
+ function exitWithError(error, format) {
50
+ const exitCode = getExitCode(error);
51
+ const mode = detectMode(format);
52
+ const isJsonMode = mode === "json" || mode === "jsonl";
53
+ if (isJsonMode) {
54
+ process.stderr.write(`${serializeErrorToJson(error)}
55
+ `);
56
+ } else {
57
+ process.stderr.write(`${formatErrorHuman(error)}
58
+ `);
59
+ }
60
+ process.exit(exitCode);
61
+ }
62
+ function resolveVerbose(verbose) {
63
+ const envVerbose = process.env["OUTFITTER_VERBOSE"];
64
+ if (envVerbose === "1")
65
+ return true;
66
+ if (envVerbose === "0")
67
+ return false;
68
+ if (verbose !== undefined) {
69
+ return verbose;
70
+ }
71
+ const env = getEnvironment();
72
+ const defaults = getEnvironmentDefaults(env);
73
+ return defaults.verbose;
74
+ }
75
+
76
+ export { output, exitWithError, resolveVerbose };