Skip to content

Commit 87565b2

Browse files
authored
add onMissing config option for interactive/async default values (#245)
* add onMissing to types and options * add onMissing callback support to flags and multioptions * update docs * changeset
1 parent 77aa47d commit 87565b2

File tree

17 files changed

+588
-62
lines changed

17 files changed

+588
-62
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'cmd-ts': patch
3+
---
4+
5+
Added onMissing callback support to flags, options, and custom types
6+
7+
That allows providing dynamic fallback values when command-line arguments are not provided, This enables:
8+
9+
- Hiding default values from help output
10+
- Interactive prompts: Ask users for input when flags/options are missing
11+
- Environment-based defaults: Check environment variables or config files dynamically
12+
- Auto-discovery: Automatically find files or resources when not specified
13+
- Async support: Handle both synchronous and asynchronous fallback logic
14+
15+
The onMissing callback is used as a fallback when defaultValue is not provided, following the precedence order: environment variables β†’ defaultValue β†’ onMissing β†’ type defaults.
16+
17+
New APIs:
18+
19+
- flag({ onMissing: () => boolean | Promise<boolean> })
20+
- option({ onMissing: () => T | Promise<T> })
21+
- multioption({ onMissing: () => T[] | Promise<T[]> })
22+
- Custom Type interface now supports onMissing property

β€Ždocs/custom_types.mdβ€Ž

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ const ReadStream: Type<string, Stream> = {
6565
- `description` to provide a default description for this type
6666
- `displayName` is a short way to describe the type in the help
6767
- `defaultValue(): B` to allow the type to be optional and have a default value
68+
- `onMissing(): B | Promise<B>` to provide a dynamic fallback when the argument is not provided (used as fallback if `defaultValue` is not provided)
6869

6970
Using the type we've just created is no different that using `string`:
7071

@@ -93,4 +94,40 @@ Now, we can add more features to our `ReadStream` type and stop touching our cod
9394
- We can try to parse the string as a URI and check if the protocol is HTTP, if so - make an HTTP request and return the body stream
9495
- We can see if the string is `-`, and when it happens, return `process.stdin` like many Unix applications
9596

97+
### Custom Types with `onMissing`
98+
99+
Custom types can also provide dynamic defaults using `onMissing`. This is useful when you want the type itself to determine what happens when no argument is provided:
100+
101+
```ts
102+
const ConfigFile: Type<string, Config> = {
103+
async from(str) {
104+
if (!fs.existsSync(str)) {
105+
throw new Error(`Config file not found: ${str}`);
106+
}
107+
return JSON.parse(fs.readFileSync(str, 'utf8'));
108+
},
109+
110+
displayName: 'config-file',
111+
112+
async onMissing() {
113+
// Look for config in standard locations when not provided
114+
const candidates = [
115+
'./config.json',
116+
path.join(os.homedir(), '.myapp', 'config.json'),
117+
'/etc/myapp/config.json'
118+
];
119+
120+
for (const candidate of candidates) {
121+
if (fs.existsSync(candidate)) {
122+
console.log(`Using config from: ${candidate}`);
123+
return JSON.parse(fs.readFileSync(candidate, 'utf8'));
124+
}
125+
}
126+
127+
// Return default config if none found
128+
return { debug: false, verbose: false };
129+
},
130+
};
131+
```
132+
96133
And the best thing about it β€” everything is encapsulated to an easily tested type definition, which can be easily shared and reused. Take a look at [io-ts-types](https://github.com/gcanti/io-ts-types), for instance, which has types like DateFromISOString, NumberFromString and more, which is something we can totally do.

β€Ždocs/parsers/flags.mdβ€Ž

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,46 @@ const cmd = command({
4646
});
4747
```
4848

49+
### Dynamic Defaults with `onMissing`
50+
51+
The `onMissing` callback provides a way to dynamically generate values when a flag is not provided. This is useful for environment-based defaults, configuration file lookups, or user prompts.
52+
53+
```ts
54+
import { command, flag } from 'cmd-ts';
55+
56+
const verboseFlag = flag({
57+
long: 'verbose',
58+
short: 'v',
59+
description: 'Enable verbose output',
60+
onMissing: () => {
61+
// Check environment variable as fallback
62+
return process.env.NODE_ENV === 'development';
63+
},
64+
});
65+
66+
const debugFlag = flag({
67+
long: 'debug',
68+
short: 'd',
69+
description: 'Enable debug mode',
70+
onMissing: async () => {
71+
// Async example: check config file or make API call
72+
const config = await loadConfig();
73+
return config.debug || false;
74+
},
75+
});
76+
77+
const cmd = command({
78+
name: 'my app',
79+
args: {
80+
verbose: verboseFlag,
81+
debug: debugFlag,
82+
},
83+
handler: ({ verbose, debug }) => {
84+
console.log(`Verbose: ${verbose}, Debug: ${debug}`);
85+
},
86+
});
87+
```
88+
4989
### Config
5090

5191
- `type` (required): A type from `boolean` to any value
@@ -55,6 +95,7 @@ const cmd = command({
5595
- `displayName`: A short description regarding the option
5696
- `defaultValue`: A function that returns a default value for the option
5797
- `defaultValueIsSerializable`: Whether to print the defaultValue as a string in the help docs.
98+
- `onMissing`: A function (sync or async) that returns a value when the flag is not provided. Used as fallback if `defaultValue` is not provided.
5899

59100
## `multiflag`
60101

β€Ždocs/parsers/options.mdβ€Ž

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,43 @@ const cmd = command({
4444
});
4545
```
4646

47+
### Dynamic Defaults with `onMissing`
48+
49+
The `onMissing` callback provides a way to dynamically generate values when an option is not provided. This is perfect for interactive prompts:
50+
51+
```ts
52+
import { command, option, string } from './src';
53+
import { createInterface } from 'readline/promises';
54+
55+
const name = option({
56+
type: string,
57+
long: 'name',
58+
short: 'n',
59+
description: 'Your name for the greeting',
60+
onMissing: async () => {
61+
const rl = createInterface({
62+
input: process.stdin,
63+
output: process.stdout,
64+
});
65+
66+
try {
67+
const answer = await rl.question("What's your name? ");
68+
return answer.trim() || 'Anonymous';
69+
} finally {
70+
rl.close();
71+
}
72+
},
73+
});
74+
75+
const cmd = command({
76+
name: 'greeting',
77+
args: { name },
78+
handler: ({ name }) => {
79+
console.log(`Hello, ${name}!`);
80+
},
81+
});
82+
```
83+
4784
### Config
4885

4986
- `type` (required): A type from `string` to any value
@@ -53,6 +90,7 @@ const cmd = command({
5390
- `displayName`: A short description regarding the option
5491
- `defaultValue`: A function that returns a default value for the option
5592
- `defaultValueIsSerializable`: Whether to print the defaultValue as a string in the help docs.
93+
- `onMissing`: A function (sync or async) that returns a value when the option is not provided. Used as fallback if `defaultValue` is not provided.
5694

5795
## `multioption`
5896

@@ -65,6 +103,42 @@ This parser will fail to parse if:
65103
- No value was provided (if it was treated like [a flag](./flags.md))
66104
- Decoding the user input fails
67105

106+
### Dynamic Defaults for `multioption`
107+
108+
Like single options, `multioption` supports `onMissing` callbacks for dynamic default arrays:
109+
110+
```ts
111+
import { command, multioption } from 'cmd-ts';
112+
import type { Type } from 'cmd-ts';
113+
114+
const stringArray: Type<string[], string[]> = {
115+
async from(strings) {
116+
return strings;
117+
},
118+
displayName: 'string',
119+
};
120+
121+
const includes = multioption({
122+
type: stringArray,
123+
long: 'include',
124+
short: 'i',
125+
description: 'Files to include',
126+
onMissing: async () => {
127+
// Auto-discover files when none specified
128+
const files = await glob('src/**/*.ts');
129+
return files;
130+
},
131+
});
132+
133+
const cmd = command({
134+
name: 'build',
135+
args: { includes },
136+
handler: ({ includes }) => {
137+
console.log(`Processing files: ${includes.join(', ')}`);
138+
},
139+
});
140+
```
141+
68142
### Config
69143

70144
- `type` (required): A type from `string[]` to any value
@@ -74,3 +148,4 @@ This parser will fail to parse if:
74148
- `displayName`: A short description regarding the option
75149
- `defaultValue`: A function that returns a default value for the option array in case no options were provided. If not provided, the default value will be an empty array.
76150
- `defaultValueIsSerializable`: Whether to print the defaultValue as a string in the help docs.
151+
- `onMissing`: A function (sync or async) that returns a value when the option is not provided. Used as fallback if `defaultValue` is not provided.

β€Žexample/app4.tsβ€Ž

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/env YARN_SILENT=1 yarn ts-node
2+
3+
import { command, extendType, option, run, string } from "../src";
4+
5+
const AsyncType = extendType(string, {
6+
async from(str) {
7+
return str;
8+
},
9+
onMissing: () => Promise.resolve("default value"),
10+
description: "A type with onMissing callback",
11+
});
12+
13+
const app = command({
14+
name: "async-test",
15+
args: {
16+
asyncArg: option({
17+
type: AsyncType,
18+
long: "async-arg",
19+
short: "a",
20+
}),
21+
asyncArg2: option({
22+
long: "async-arg-2",
23+
type: AsyncType,
24+
defaultValue: () => "Hi",
25+
defaultValueIsSerializable: true,
26+
}),
27+
arg3: option({
28+
long: "async-arg-3",
29+
type: string,
30+
onMissing: () => "Hello from opt",
31+
}),
32+
},
33+
handler: ({ asyncArg, asyncArg2, arg3 }) => {
34+
console.log(`Result: ${asyncArg}, ${asyncArg2}, ${arg3}`);
35+
},
36+
});
37+
38+
run(app, process.argv.slice(2));

β€Žexample/app5.tsβ€Ž

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/usr/bin/env YARN_SILENT=1 yarn ts-node
2+
3+
import { command, extendType, option, run, string } from "../src";
4+
5+
const AsyncFailureType = extendType(string, {
6+
async from(str) {
7+
return str;
8+
},
9+
onMissing: () => Promise.reject(new Error("Async onMissing failed")),
10+
description: "A type with onMissing callback that fails",
11+
});
12+
13+
const app = command({
14+
name: "async-test-failure",
15+
args: {
16+
failArg: option({
17+
type: AsyncFailureType,
18+
long: "fail-arg",
19+
short: "f",
20+
}),
21+
},
22+
handler: ({ failArg }) => {
23+
console.log(`Result: ${failArg}`);
24+
},
25+
});
26+
27+
run(app, process.argv.slice(2));

β€Žexample/app6.tsβ€Ž

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#!/usr/bin/env YARN_SILENT=1 yarn ts-node
2+
3+
import { command, flag, run } from "../src";
4+
5+
const app = command({
6+
name: "flag-onmissing-demo",
7+
args: {
8+
verbose: flag({
9+
long: "verbose",
10+
short: "v",
11+
description: "Enable verbose output",
12+
onMissing: () => {
13+
console.log("πŸ€” Verbose flag not provided, checking environment...");
14+
return process.env.NODE_ENV === "development";
15+
},
16+
}),
17+
debug: flag({
18+
long: "debug",
19+
short: "d",
20+
description: "Enable debug mode",
21+
onMissing: async () => {
22+
console.log("πŸ” Debug flag missing, simulating config check...");
23+
await new Promise((resolve) => setTimeout(resolve, 100));
24+
return Math.random() > 0.5; // Simulate config-based decision
25+
},
26+
}),
27+
force: flag({
28+
long: "force",
29+
short: "f",
30+
description: "Force operation without confirmation",
31+
onMissing: () => {
32+
console.log("⚠️ Force flag not set, would normally prompt user...");
33+
// In real scenario: return prompt("Force operation? (y/n)") === "y"
34+
return false; // Safe default for demo
35+
},
36+
}),
37+
},
38+
handler: ({ verbose, debug, force }) => {
39+
console.log("\nπŸ“‹ Results:");
40+
console.log(` Verbose: ${verbose ? "βœ…" : "❌"}`);
41+
console.log(` Debug: ${debug ? "βœ…" : "❌"}`);
42+
console.log(` Force: ${force ? "βœ…" : "❌"}`);
43+
},
44+
});
45+
46+
run(app, process.argv.slice(2));

0 commit comments

Comments
Β (0)