@outfitter/cli 0.1.0-rc.1

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.
package/README.md ADDED
@@ -0,0 +1,436 @@
1
+ # @outfitter/cli
2
+
3
+ Typed CLI runtime with output contracts, input parsing, and pagination for Bun.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun add @outfitter/cli
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import { output, collectIds, loadCursor, saveCursor } from "@outfitter/cli";
15
+
16
+ // Output data with automatic mode detection
17
+ output({ id: "123", name: "Example" });
18
+
19
+ // Collect IDs from various input formats
20
+ const ids = await collectIds("id1,id2,id3");
21
+
22
+ // Handle pagination state
23
+ const cursor = loadCursor({ command: "list", toolName: "myapp" });
24
+ if (cursor) {
25
+ // Continue from last position
26
+ }
27
+ ```
28
+
29
+ ## API Reference
30
+
31
+ ### Output Utilities
32
+
33
+ #### `output(data, options?)`
34
+
35
+ Output data to the console with automatic mode selection.
36
+
37
+ Defaults to human-friendly output for TTY, JSON for non-TTY. Override via `mode` option or `OUTFITTER_JSON`/`OUTFITTER_JSONL` environment variables.
38
+
39
+ ```typescript
40
+ import { output } from "@outfitter/cli";
41
+
42
+ // Basic usage - mode auto-detected
43
+ output(results);
44
+
45
+ // Force JSON mode
46
+ output(results, { mode: "json" });
47
+
48
+ // Pretty-print JSON
49
+ output(results, { mode: "json", pretty: true });
50
+
51
+ // Output to stderr
52
+ output(errors, { stream: process.stderr });
53
+ ```
54
+
55
+ **Options:**
56
+
57
+ | Option | Type | Default | Description |
58
+ |--------|------|---------|-------------|
59
+ | `mode` | `OutputMode` | auto | Force a specific output mode |
60
+ | `stream` | `WritableStream` | `stdout` | Stream to write to |
61
+ | `pretty` | `boolean` | `false` | Pretty-print JSON output |
62
+
63
+ **Output Modes:**
64
+
65
+ - `human` - Human-readable key: value format
66
+ - `json` - Single JSON object
67
+ - `jsonl` - JSON Lines (one object per line)
68
+ - `tree` - Tree structure (reserved)
69
+ - `table` - Table format (reserved)
70
+
71
+ #### `exitWithError(error)`
72
+
73
+ Exit the process with an error message and appropriate exit code.
74
+
75
+ ```typescript
76
+ import { exitWithError } from "@outfitter/cli";
77
+
78
+ try {
79
+ await riskyOperation();
80
+ } catch (error) {
81
+ exitWithError(error instanceof Error ? error : new Error(String(error)));
82
+ }
83
+ ```
84
+
85
+ ### Input Utilities
86
+
87
+ #### `collectIds(input, options?)`
88
+
89
+ Collect IDs from various input formats: space-separated, comma-separated, repeated flags, `@file`, and stdin.
90
+
91
+ ```typescript
92
+ import { collectIds } from "@outfitter/cli";
93
+
94
+ // All these produce the same result:
95
+ // myapp show id1 id2 id3
96
+ // myapp show id1,id2,id3
97
+ // myapp show --ids id1 --ids id2
98
+ // myapp show @ids.txt
99
+ // echo "id1\nid2" | myapp show @-
100
+
101
+ const ids = await collectIds(args.ids, {
102
+ allowFile: true,
103
+ allowStdin: true,
104
+ });
105
+ ```
106
+
107
+ **Options:**
108
+
109
+ | Option | Type | Default | Description |
110
+ |--------|------|---------|-------------|
111
+ | `allowFile` | `boolean` | `true` | Allow `@file` expansion |
112
+ | `allowStdin` | `boolean` | `true` | Allow `@-` for stdin |
113
+
114
+ #### `expandFileArg(input, options?)`
115
+
116
+ Expand `@file` references to file contents. Returns input unchanged if not a file reference.
117
+
118
+ ```typescript
119
+ import { expandFileArg } from "@outfitter/cli";
120
+
121
+ // myapp create @template.md
122
+ const content = await expandFileArg(args.content);
123
+
124
+ // With options
125
+ const content = await expandFileArg(args.content, {
126
+ maxSize: 1024 * 1024, // 1MB limit
127
+ trim: true,
128
+ });
129
+ ```
130
+
131
+ **Options:**
132
+
133
+ | Option | Type | Default | Description |
134
+ |--------|------|---------|-------------|
135
+ | `encoding` | `BufferEncoding` | `utf-8` | File encoding |
136
+ | `maxSize` | `number` | - | Maximum file size in bytes |
137
+ | `trim` | `boolean` | `false` | Trim whitespace |
138
+
139
+ #### `parseGlob(pattern, options?)`
140
+
141
+ Parse and expand glob patterns using `Bun.Glob`.
142
+
143
+ ```typescript
144
+ import { parseGlob } from "@outfitter/cli";
145
+
146
+ const files = await parseGlob("src/**/*.ts", {
147
+ cwd: workspaceRoot,
148
+ ignore: ["node_modules/**", "**/*.test.ts"],
149
+ onlyFiles: true,
150
+ });
151
+ ```
152
+
153
+ **Options:**
154
+
155
+ | Option | Type | Default | Description |
156
+ |--------|------|---------|-------------|
157
+ | `cwd` | `string` | `process.cwd()` | Working directory |
158
+ | `ignore` | `string[]` | `[]` | Patterns to exclude |
159
+ | `onlyFiles` | `boolean` | `false` | Only match files |
160
+ | `onlyDirectories` | `boolean` | `false` | Only match directories |
161
+ | `followSymlinks` | `boolean` | `false` | Follow symbolic links |
162
+
163
+ #### `parseKeyValue(input)`
164
+
165
+ Parse `key=value` pairs from CLI input.
166
+
167
+ ```typescript
168
+ import { parseKeyValue } from "@outfitter/cli";
169
+
170
+ // --set key=value --set key2=value2
171
+ // --set key=value,key2=value2
172
+ const result = parseKeyValue(args.set);
173
+
174
+ if (result.isOk()) {
175
+ // [{ key: "key", value: "value" }, { key: "key2", value: "value2" }]
176
+ console.log(result.value);
177
+ }
178
+ ```
179
+
180
+ #### `parseRange(input, type)`
181
+
182
+ Parse numeric or date range inputs.
183
+
184
+ ```typescript
185
+ import { parseRange } from "@outfitter/cli";
186
+
187
+ // Numeric range
188
+ const numResult = parseRange("1-10", "number");
189
+ // => { type: "number", min: 1, max: 10 }
190
+
191
+ // Date range
192
+ const dateResult = parseRange("2024-01-01..2024-12-31", "date");
193
+ // => { type: "date", start: Date, end: Date }
194
+
195
+ // Single value
196
+ const single = parseRange("5", "number");
197
+ // => { type: "number", min: 5, max: 5 }
198
+ ```
199
+
200
+ #### `parseFilter(input)`
201
+
202
+ Parse filter expressions from CLI input.
203
+
204
+ ```typescript
205
+ import { parseFilter } from "@outfitter/cli";
206
+
207
+ const result = parseFilter("status:active,priority:>=high,!archived:true");
208
+
209
+ if (result.isOk()) {
210
+ // [
211
+ // { field: "status", value: "active" },
212
+ // { field: "priority", value: "high", operator: "gte" },
213
+ // { field: "archived", value: "true", operator: "ne" }
214
+ // ]
215
+ }
216
+ ```
217
+
218
+ **Filter Operators:**
219
+
220
+ | Prefix | Operator | Description |
221
+ |--------|----------|-------------|
222
+ | (none) | `eq` | Equals (default) |
223
+ | `!` | `ne` | Not equals |
224
+ | `>` | `gt` | Greater than |
225
+ | `<` | `lt` | Less than |
226
+ | `>=` | `gte` | Greater than or equal |
227
+ | `<=` | `lte` | Less than or equal |
228
+ | `~` | `contains` | Contains substring |
229
+
230
+ #### `parseSortSpec(input)`
231
+
232
+ Parse sort specification from CLI input.
233
+
234
+ ```typescript
235
+ import { parseSortSpec } from "@outfitter/cli";
236
+
237
+ const result = parseSortSpec("modified:desc,title:asc");
238
+
239
+ if (result.isOk()) {
240
+ // [
241
+ // { field: "modified", direction: "desc" },
242
+ // { field: "title", direction: "asc" }
243
+ // ]
244
+ }
245
+ ```
246
+
247
+ #### `normalizeId(input, options?)`
248
+
249
+ Normalize an identifier with validation.
250
+
251
+ ```typescript
252
+ import { normalizeId } from "@outfitter/cli";
253
+
254
+ const result = normalizeId(" MY-ID ", {
255
+ trim: true,
256
+ lowercase: true,
257
+ minLength: 3,
258
+ maxLength: 50,
259
+ pattern: /^[a-z0-9-]+$/,
260
+ });
261
+
262
+ if (result.isOk()) {
263
+ // "my-id"
264
+ }
265
+ ```
266
+
267
+ **Options:**
268
+
269
+ | Option | Type | Default | Description |
270
+ |--------|------|---------|-------------|
271
+ | `trim` | `boolean` | `false` | Trim whitespace |
272
+ | `lowercase` | `boolean` | `false` | Convert to lowercase |
273
+ | `minLength` | `number` | - | Minimum length |
274
+ | `maxLength` | `number` | - | Maximum length |
275
+ | `pattern` | `RegExp` | - | Required pattern |
276
+
277
+ #### `confirmDestructive(options)`
278
+
279
+ Prompt for confirmation before destructive operations. Respects `--yes` flag for non-interactive mode.
280
+
281
+ ```typescript
282
+ import { confirmDestructive } from "@outfitter/cli";
283
+
284
+ const result = await confirmDestructive({
285
+ message: "Delete 5 notes?",
286
+ bypassFlag: flags.yes,
287
+ itemCount: 5,
288
+ });
289
+
290
+ if (result.isErr()) {
291
+ // User cancelled or non-TTY environment
292
+ console.error("Operation cancelled");
293
+ process.exit(0);
294
+ }
295
+
296
+ // Proceed with destructive operation
297
+ ```
298
+
299
+ ### Pagination Utilities
300
+
301
+ Pagination state persists per-command to support `--next` and `--reset` functionality.
302
+
303
+ **XDG State Directory Pattern:**
304
+
305
+ ```
306
+ $XDG_STATE_HOME/{toolName}/cursors/{command}[/{context}]/cursor.json
307
+ ```
308
+
309
+ #### `loadCursor(options)`
310
+
311
+ Load persisted pagination state for a command.
312
+
313
+ ```typescript
314
+ import { loadCursor } from "@outfitter/cli";
315
+
316
+ const state = loadCursor({
317
+ command: "list",
318
+ toolName: "waymark",
319
+ context: "workspace-123", // optional
320
+ maxAgeMs: 30 * 60 * 1000, // optional expiration window
321
+ });
322
+
323
+ if (state) {
324
+ // Continue from last position
325
+ const results = await listNotes({ cursor: state.cursor });
326
+ }
327
+ ```
328
+
329
+ #### `saveCursor(cursor, options)`
330
+
331
+ Save pagination state for a command.
332
+
333
+ ```typescript
334
+ import { saveCursor } from "@outfitter/cli";
335
+
336
+ const results = await listNotes({ limit: 20 });
337
+
338
+ if (results.hasMore) {
339
+ saveCursor(results.cursor, {
340
+ command: "list",
341
+ toolName: "waymark",
342
+ });
343
+ }
344
+ ```
345
+
346
+ #### `clearCursor(options)`
347
+
348
+ Clear persisted pagination state for a command.
349
+
350
+ ```typescript
351
+ import { clearCursor } from "@outfitter/cli";
352
+
353
+ // User passed --reset flag
354
+ if (flags.reset) {
355
+ clearCursor({
356
+ command: "list",
357
+ toolName: "waymark",
358
+ });
359
+ }
360
+ ```
361
+
362
+ ## Configuration
363
+
364
+ ### Environment Variables
365
+
366
+ | Variable | Description | Default |
367
+ |----------|-------------|---------|
368
+ | `OUTFITTER_JSON` | Set to `1` to force JSON output | - |
369
+ | `OUTFITTER_JSONL` | Set to `1` to force JSONL output (takes priority over JSON) | - |
370
+ | `XDG_STATE_HOME` | State directory for pagination | Platform-specific |
371
+
372
+ ### Output Mode Priority
373
+
374
+ 1. Explicit `mode` option in `output()` call
375
+ 2. `OUTFITTER_JSONL=1` environment variable (highest env priority)
376
+ 3. `OUTFITTER_JSON=1` environment variable
377
+ 4. `OUTFITTER_JSON=0` or `OUTFITTER_JSONL=0` forces human mode
378
+ 5. TTY detection: `json` for non-TTY, `human` for TTY
379
+
380
+ ## Error Handling
381
+
382
+ ### Exit Code Mapping
383
+
384
+ Exit codes are automatically determined from error categories:
385
+
386
+ | Category | Exit Code |
387
+ |----------|-----------|
388
+ | `validation` | 1 |
389
+ | `not_found` | 2 |
390
+ | `conflict` | 3 |
391
+ | `permission` | 4 |
392
+ | `timeout` | 5 |
393
+ | `rate_limit` | 6 |
394
+ | `network` | 7 |
395
+ | `internal` | 8 |
396
+ | `auth` | 9 |
397
+ | `cancelled` | 130 |
398
+
399
+ ### Tagged Errors
400
+
401
+ Errors with a `category` property are automatically mapped to exit codes:
402
+
403
+ ```typescript
404
+ const error = new Error("File not found") as Error & { category: string };
405
+ error.category = "not_found";
406
+
407
+ exitWithError(error); // Exits with code 2
408
+ ```
409
+
410
+ ## Types
411
+
412
+ All types are exported for TypeScript consumers:
413
+
414
+ ```typescript
415
+ import type {
416
+ // Core types
417
+ CLIConfig,
418
+ CommandConfig,
419
+ CommandAction,
420
+ CommandFlags,
421
+ // Output types
422
+ OutputMode,
423
+ OutputOptions,
424
+ // Input types
425
+ CollectIdsOptions,
426
+ ExpandFileOptions,
427
+ ParseGlobOptions,
428
+ // Pagination types
429
+ PaginationState,
430
+ CursorOptions,
431
+ } from "@outfitter/cli";
432
+ ```
433
+
434
+ ## License
435
+
436
+ MIT
@@ -0,0 +1,13 @@
1
+ import { ActionRegistry, ActionSurface, AnyActionSpec, HandlerContext } from "@outfitter/contracts";
2
+ import { Command } from "commander";
3
+ interface BuildCliCommandsOptions {
4
+ readonly createContext?: (input: {
5
+ action: AnyActionSpec;
6
+ args: readonly string[];
7
+ flags: Record<string, unknown>;
8
+ }) => HandlerContext;
9
+ readonly includeSurfaces?: readonly ActionSurface[];
10
+ }
11
+ type ActionSource = ActionRegistry | readonly AnyActionSpec[];
12
+ declare function buildCliCommands(source: ActionSource, options?: BuildCliCommandsOptions): Command[];
13
+ export { buildCliCommands, BuildCliCommandsOptions };
@@ -0,0 +1,175 @@
1
+ // @bun
2
+ import"./shared/@outfitter/cli-4yy82cmp.js";
3
+
4
+ // packages/cli/src/actions.ts
5
+ import {
6
+ createContext as createHandlerContext,
7
+ DEFAULT_REGISTRY_SURFACES,
8
+ validateInput
9
+ } from "@outfitter/contracts";
10
+ import { Command } from "commander";
11
+ var ARGUMENT_PREFIXES = ["<", "["];
12
+ function isArgumentToken(token) {
13
+ if (!token) {
14
+ return false;
15
+ }
16
+ return ARGUMENT_PREFIXES.some((prefix) => token.startsWith(prefix));
17
+ }
18
+ function splitCommandSpec(spec) {
19
+ const parts = spec.trim().split(/\s+/).filter(Boolean);
20
+ if (parts.length === 0) {
21
+ return { name: undefined, args: [] };
22
+ }
23
+ return { name: parts[0], args: parts.slice(1) };
24
+ }
25
+ function applyArguments(command, args) {
26
+ for (const arg of args) {
27
+ command.argument(arg);
28
+ }
29
+ }
30
+ function applyCliOptions(command, action) {
31
+ const options = action.cli?.options ?? [];
32
+ for (const option of options) {
33
+ if (option.required) {
34
+ command.requiredOption(option.flags, option.description, option.defaultValue);
35
+ } else {
36
+ command.option(option.flags, option.description, option.defaultValue);
37
+ }
38
+ }
39
+ }
40
+ function resolveDescription(action) {
41
+ return action.cli?.description ?? action.description ?? action.id;
42
+ }
43
+ function resolveAliases(action) {
44
+ return action.cli?.aliases ?? [];
45
+ }
46
+ function resolveCommandSpec(action) {
47
+ return action.cli?.command ?? action.id;
48
+ }
49
+ function resolveFlags(command) {
50
+ return command.optsWithGlobals?.() ?? command.opts();
51
+ }
52
+ function resolveInput(action, context) {
53
+ if (action.cli?.mapInput) {
54
+ return action.cli.mapInput(context);
55
+ }
56
+ const hasFlags = Object.keys(context.flags).length > 0;
57
+ if (!hasFlags && context.args.length === 0) {
58
+ return {};
59
+ }
60
+ return {
61
+ ...context.flags,
62
+ ...context.args.length > 0 ? { args: context.args } : {}
63
+ };
64
+ }
65
+ async function runAction(action, command, createContext) {
66
+ const flags = resolveFlags(command);
67
+ const args = command.args;
68
+ const inputContext = { args, flags };
69
+ const input = resolveInput(action, inputContext);
70
+ const validation = validateInput(action.input, input);
71
+ if (validation.isErr()) {
72
+ throw validation.error;
73
+ }
74
+ const ctx = createContext({ action, args, flags });
75
+ const result = await action.handler(validation.value, ctx);
76
+ if (result.isErr()) {
77
+ throw result.error;
78
+ }
79
+ }
80
+ function createCommand(action, createContext, spec) {
81
+ const commandSpec = spec ?? resolveCommandSpec(action);
82
+ const { name, args } = splitCommandSpec(commandSpec);
83
+ if (!name) {
84
+ throw new Error(`Missing CLI command name for action ${action.id}`);
85
+ }
86
+ const command = new Command(name);
87
+ command.description(resolveDescription(action));
88
+ applyCliOptions(command, action);
89
+ applyArguments(command, args);
90
+ for (const alias of resolveAliases(action)) {
91
+ command.alias(alias);
92
+ }
93
+ command.action(async (...argsList) => {
94
+ const commandInstance = argsList.at(-1);
95
+ await runAction(action, commandInstance, createContext);
96
+ });
97
+ return command;
98
+ }
99
+ function buildCliCommands(source, options = {}) {
100
+ const actions = isActionRegistry(source) ? source.list() : source;
101
+ const includeSurfaces = options.includeSurfaces ?? [
102
+ "cli"
103
+ ];
104
+ const commands = [];
105
+ const createContext = options.createContext ?? ((_input) => createHandlerContext({
106
+ cwd: process.cwd(),
107
+ env: process.env
108
+ }));
109
+ const grouped = new Map;
110
+ const ungrouped = [];
111
+ for (const action of actions) {
112
+ const surfaces = action.surfaces ?? DEFAULT_REGISTRY_SURFACES;
113
+ if (!surfaces.some((surface) => includeSurfaces.includes(surface))) {
114
+ continue;
115
+ }
116
+ const group = action.cli?.group;
117
+ if (group) {
118
+ const groupActions = grouped.get(group) ?? [];
119
+ groupActions.push(action);
120
+ grouped.set(group, groupActions);
121
+ } else {
122
+ ungrouped.push(action);
123
+ }
124
+ }
125
+ for (const action of ungrouped) {
126
+ commands.push(createCommand(action, createContext));
127
+ }
128
+ for (const [groupName, groupActions] of grouped.entries()) {
129
+ const groupCommand = new Command(groupName);
130
+ let baseAction;
131
+ const subcommands = [];
132
+ for (const action of groupActions) {
133
+ const spec = action.cli?.command?.trim() ?? "";
134
+ const { name, args } = splitCommandSpec(spec);
135
+ if (!name || isArgumentToken(name)) {
136
+ if (baseAction) {
137
+ throw new Error(`Group '${groupName}' defines multiple base actions: '${baseAction.id}' and '${action.id}'.`);
138
+ }
139
+ baseAction = action;
140
+ groupCommand.description(resolveDescription(action));
141
+ applyCliOptions(groupCommand, action);
142
+ applyArguments(groupCommand, name ? [name, ...args] : args);
143
+ for (const alias of resolveAliases(action)) {
144
+ groupCommand.alias(alias);
145
+ }
146
+ groupCommand.action(async (...argsList) => {
147
+ const commandInstance = argsList.at(-1);
148
+ await runAction(action, commandInstance, createContext);
149
+ });
150
+ } else {
151
+ subcommands.push(action);
152
+ }
153
+ }
154
+ for (const action of subcommands) {
155
+ const spec = resolveCommandSpec(action);
156
+ const { name, args } = splitCommandSpec(spec);
157
+ if (!name) {
158
+ continue;
159
+ }
160
+ const subcommand = createCommand(action, createContext, [name, ...args].join(" "));
161
+ groupCommand.addCommand(subcommand);
162
+ }
163
+ if (!baseAction) {
164
+ groupCommand.description(groupName);
165
+ }
166
+ commands.push(groupCommand);
167
+ }
168
+ return commands;
169
+ }
170
+ function isActionRegistry(source) {
171
+ return "list" in source;
172
+ }
173
+ export {
174
+ buildCliCommands
175
+ };