Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions packages/@d-zero/roar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ if (result.command === 'crawl') {
}
```

### 位置引数とフラグの併用

フラグと位置引数(positional args)は任意の順序で混在できます。boolean フラグの直後に位置引数を置いても正しくパースされます。

```bash
# フラグと位置引数の順序は自由
my-tool crawl https://example.com --verbose
my-tool crawl --verbose https://example.com
# => result.args: ['https://example.com'], result.flags.verbose: true

# 複数のフラグ・位置引数も混在可能
my-tool crawl --verbose --depth 5 https://example.com https://test.com
# => result.args: ['https://example.com', 'https://test.com']

# `--` 以降はすべて位置引数として扱われる
my-tool crawl --verbose -- --not-a-flag
# => result.args: ['--not-a-flag']
```

### エラーハンドリング

`onError` コールバックでコマンド未指定や不明なコマンドのエラーを処理できます。`true` を返すとヘルプテキストを stderr に表示してから `process.exit(1)` します。
Expand Down Expand Up @@ -75,11 +94,11 @@ const result = parseCli({

`settings` オブジェクト:

| プロパティ | 型 | 必須 | 説明 |
| ---------- | ---------------------------- | ---- | ------------------------------------------------------- |
| `name` | `string` | ✓ | CLI プログラム名(ヘルプテキストに表示) |
| `commands` | `Record<string, CommandDef>` | ✓ | サブコマンド定義のマップ |
| `onError` | `(error: Error) => boolean` | - | コマンド未指定時のエラーハンドラ(`true` でヘルプ表示) |
| プロパティ | 型 | 必須 | 説明 |
| ---------- | ---------------------------- | ---- | ------------------------------------------------------------- |
| `name` | `string` | ✓ | CLI プログラム名(ヘルプテキストに表示) |
| `commands` | `Record<string, CommandDef>` | ✓ | サブコマンド定義のマップ |
| `onError` | `(error: Error) => boolean` | - | コマンド未指定・不明時のエラーハンドラ(`true` でヘルプ表示) |

#### 戻り値

Expand Down
55 changes: 55 additions & 0 deletions packages/@d-zero/roar/src/parse-cli.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,59 @@ describe('parseCli', () => {
expect(() => parseCli(testSettings)).toThrow('process.exit called');
expect(exitSpy).toHaveBeenCalledWith(0);
});

it('preserves positional args when boolean flag precedes them', () => {
setArgv(['crawl', '--verbose', 'https://example.com']);
const result = parseCli(testSettings);
expect(result.command).toBe('crawl');
expect(result.args).toContain('https://example.com');
if (result.command === 'crawl') {
expect(result.flags.verbose).toBe(true);
}
});

it('preserves positional args when short boolean flag precedes them', () => {
setArgv(['crawl', '-v', 'https://example.com']);
const result = parseCli(testSettings);
expect(result.command).toBe('crawl');
expect(result.args).toContain('https://example.com');
if (result.command === 'crawl') {
expect(result.flags.verbose).toBe(true);
}
});

it('preserves positional args with multiple flags mixed', () => {
setArgv([
'crawl',
'--verbose',
'--depth',
'5',
'https://example.com',
'https://test.com',
]);
const result = parseCli(testSettings);
expect(result.command).toBe('crawl');
expect(result.args).toEqual(['https://example.com', 'https://test.com']);
if (result.command === 'crawl') {
expect(result.flags.verbose).toBe(true);
expect(result.flags.depth).toBe(5);
}
});

it('returns positional args for command without flags', () => {
setArgv(['analyze', 'file1.html', 'file2.html']);
const result = parseCli(testSettings);
expect(result.command).toBe('analyze');
expect(result.args).toEqual(['file1.html', 'file2.html']);
});

it('treats args after -- as positional', () => {
setArgv(['crawl', '--verbose', '--', '--not-a-flag']);
const result = parseCli(testSettings);
expect(result.command).toBe('crawl');
expect(result.args).toContain('--not-a-flag');
if (result.command === 'crawl') {
expect(result.flags.verbose).toBe(true);
}
});
});
19 changes: 10 additions & 9 deletions packages/@d-zero/roar/src/parse-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ type InferFlagValue<F extends FlagDef> = F extends { type: 'string'; isMultiple:

/**
* Maps a flags definition record to its runtime value types.
* Used as the return type of {@link parseFlags} and in {@link RoarResult}.
* Used in {@link RoarResult} to type the `flags` property of each command.
*/
export type InferFlags<F extends AnyFlags> = {
-readonly [K in keyof F]: InferFlagValue<F[K]>;
Expand Down Expand Up @@ -232,9 +232,12 @@ function generateCommandHelp<F extends AnyFlags>(
* these common CLI parsing concerns.
* @param argv - Raw argument strings (after removing the command name)
* @param flags - Flag definitions that drive parsing configuration
* @returns Typed flag values matching the definitions
* @returns Object containing typed flag values and positional arguments
*/
function parseFlags<F extends AnyFlags>(argv: string[], flags: F): InferFlags<F> {
function parseFlags<F extends AnyFlags>(
argv: string[],
flags: F,
): { flags: InferFlags<F>; args: string[] } {
const alias: Record<string, string> = {};
const boolean: string[] = [];
const string: string[] = [];
Expand Down Expand Up @@ -292,7 +295,7 @@ function parseFlags<F extends AnyFlags>(argv: string[], flags: F): InferFlags<F>
result[key] = parsed[key] ?? defaults[key];
}

return result as InferFlags<F>;
return { flags: result as InferFlags<F>, args: parsed._.map(String) };
}

// ---- Main export ----
Expand Down Expand Up @@ -359,11 +362,9 @@ export function parseCli<const Commands extends Record<string, CommandDef>>(
process.exit(0);
}

const flags = commandDef.flags ? parseFlags(commandArgv, commandDef.flags) : {};

// Extract positional args (yargs-parser puts them in _)
const parsed = yargsParser(commandArgv);
const args = parsed._.map(String);
const { flags, args } = commandDef.flags
? parseFlags(commandArgv, commandDef.flags)
: { flags: {}, args: yargsParser(commandArgv)._.map(String) };

return {
command,
Expand Down
Loading