rubycli 0.1.4 → 0.1.5
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/README.ja.md +65 -12
- data/README.md +65 -12
- data/lib/rubycli/argument_parser.rb +255 -1
- data/lib/rubycli/arguments/value_converter.rb +11 -0
- data/lib/rubycli/cli.rb +5 -1
- data/lib/rubycli/command_line.rb +42 -6
- data/lib/rubycli/documentation/metadata_parser.rb +160 -25
- data/lib/rubycli/documentation_registry.rb +1 -1
- data/lib/rubycli/environment.rb +42 -7
- data/lib/rubycli/eval_coercer.rb +1 -1
- data/lib/rubycli/help_renderer.rb +64 -9
- data/lib/rubycli/types.rb +2 -2
- data/lib/rubycli/version.rb +1 -1
- data/lib/rubycli.rb +92 -26
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9287ea2ca51772eb53c50fe717e648c7d23a9fb1e5cb0e25990eef7f07cde225
|
|
4
|
+
data.tar.gz: aab6e71f2beafb924bb4bc29689c6ce64391d55bc43936a789278ad764e91592
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 98baa8a62366c9babedda68174dbc1fadaafbdf1ea4354393bf1a525b9b1d1f2ce0459f20ef8aff81cc4a3448f0686da9bf7940a8fde2526a3b42caf3d2feb63
|
|
7
|
+
data.tar.gz: ac5044eec17148e3bb26ef1aa2fd67b78e6578acd6693747bb9a95e8d043e1842f9ebc853e6e50297130d0351fdfb9cfad04c1d217738c6462eda94c1c5b039f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.5] - 2025-11-10
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `rubycli --check` gained a short `-c` alias and refuses to run target commands while linting, making documentation checks easier to script.
|
|
7
|
+
- Bundled `examples/strict_choices_demo.rb` and `examples/typed_arguments_demo.rb` now illustrate literal choice validation and stdlib type coercions.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- Runtime validation now reads the documented literal choices and inferred types for both positional and keyword arguments; invalid values emit `[WARN] …` guidance by default and raise `Rubycli::ArgumentError` under `--strict` with friendly suggestions.
|
|
11
|
+
- Help output renders positional arguments and options as structured tables showing requirement level, types, defaults, and descriptions for quicker scanning.
|
|
12
|
+
- Documentation comments are aligned with the actual method signature, and mismatches surface with file/line context during `rubycli --check`.
|
|
13
|
+
- CLI warnings/errors are prefixed with `[WARN]` / `[ERROR]`, and the deprecated `--debug` flag was removed in favor of `RUBYCLI_DEBUG=true`.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- `--check` now rejects forwarded CLI arguments as well as JSON/Eval modes, ensuring documentation linting never executes user code and always starts from a clean issue list.
|
|
17
|
+
- Placeholder parsing keeps option descriptions such as `--prefix` in the documentation showcase so README snippets and live behavior stay in sync.
|
|
18
|
+
|
|
3
19
|
## [0.1.4] - 2025-11-08
|
|
4
20
|
|
|
5
21
|
### Changed
|
|
@@ -7,6 +23,9 @@
|
|
|
7
23
|
|
|
8
24
|
> _Note:_ 0.1.3 was yanked before general availability; consumers should upgrade directly to 0.1.4.
|
|
9
25
|
|
|
26
|
+
### Documentation
|
|
27
|
+
- Clarified repeated-value guidance (type enforcement, eval mode workflows) and updated both READMEs to reflect the retirement of `(type: …)` annotations while preserving the historical changelog entry.
|
|
28
|
+
|
|
10
29
|
## [0.1.3] - 2025-11-08
|
|
11
30
|
|
|
12
31
|
### Added
|
data/README.ja.md
CHANGED
|
@@ -33,7 +33,6 @@ Available commands:
|
|
|
33
33
|
greet <NAME>
|
|
34
34
|
|
|
35
35
|
Detailed command help: hello_app.rb COMMAND help
|
|
36
|
-
Enable debug logging: --debug or RUBYCLI_DEBUG=true
|
|
37
36
|
```
|
|
38
37
|
|
|
39
38
|
```bash
|
|
@@ -107,7 +106,6 @@ Available commands:
|
|
|
107
106
|
greet <NAME> [--shout]
|
|
108
107
|
|
|
109
108
|
Detailed command help: hello_app_with_docs.rb COMMAND help
|
|
110
|
-
Enable debug logging: --debug or RUBYCLI_DEBUG=true
|
|
111
109
|
```
|
|
112
110
|
|
|
113
111
|
```bash
|
|
@@ -158,13 +156,13 @@ Rubycli は「ファイル名を CamelCase にした定数」を公開対象だ
|
|
|
158
156
|
|
|
159
157
|
大規模なコードベースでも安全側を保ちながら、どうしても自動選択したいときだけ 1 フラグで切り替えられます。
|
|
160
158
|
|
|
161
|
-
> **インスタンスメソッド専用のクラスについて** – 公開メソッドがインスタンス側(`
|
|
159
|
+
> **インスタンスメソッド専用のクラスについて** – 公開メソッドがインスタンス側(`def greet` など)にしか無い場合は、`--new` を付けて事前にインスタンス化しないと CLI から呼び出せません。クラスメソッドを 1 つ用意するか、`--new` を明示して実行してください。
|
|
162
160
|
|
|
163
161
|
## 開発方針
|
|
164
162
|
|
|
165
163
|
- **便利さが最優先** – 既存の Ruby スクリプトを最小の手間で CLI 化できることを目的にしており、Python Fire の完全移植は目指していません。
|
|
166
164
|
- **インスパイアであってポートではない** – アイデアの出自は Fire ですが、同等機能を揃える予定は基本的にありません。Fire 由来の未実装機能は仕様です。
|
|
167
|
-
- **メソッド定義が土台、コメントが挙動を補強** – 公開メソッドのシグネチャが CLI に露出する範囲と必須/任意を決めますが、コメントに `TAG...` や `[Integer]`
|
|
165
|
+
- **メソッド定義が土台、コメントが挙動を補強** – 公開メソッドのシグネチャが CLI に露出する範囲と必須/任意を決めますが、コメントに `TAG...` や `[Integer]` を書くと同じ引数でも配列化や型変換が行われます。さらに Rubycli は `--names='["Alice","Bob"]'` のような JSON/YAML らしい入力を自動的に安全なリテラルとして評価します。`rubycli --check パス/対象.rb` でコメントと実装のズレを検査しつつ、通常実行時に `--strict` を付ければドキュメント通りでない入力をその場でエラーにできます。(現状の `--check` は「コメントが揃っているか」を中心にチェックしており、`Booalean` のように型トークンを誤記しても検出できません。)
|
|
168
166
|
- **軽量メンテナンス** – 実装の多くは AI 支援で作られており、深い Ruby メタプログラミングを伴う大規模拡張は想定外です。Fire 互換を求める PR は事前相談をお願いします。
|
|
169
167
|
|
|
170
168
|
## 特徴
|
|
@@ -173,13 +171,13 @@ Rubycli は「ファイル名を CamelCase にした定数」を公開対象だ
|
|
|
173
171
|
- YARD 形式と `NAME [Type] 説明…` の簡潔記法を同時サポート
|
|
174
172
|
- 引数はデフォルトで安全なリテラルとして解釈し、必要に応じて厳格 JSON モードや Ruby eval モードを切り替え可能
|
|
175
173
|
- `--pre-script`(エイリアス: `--init`)で任意の Ruby コードを評価し、その結果オブジェクトを公開
|
|
176
|
-
- `
|
|
174
|
+
- `--check` でコメント整合性を lint、`--strict` で入力値をドキュメント通りに強制する二段構えのガード
|
|
177
175
|
|
|
178
176
|
## Python Fire との違い
|
|
179
177
|
|
|
180
178
|
- **コメント対応のヘルプ生成**: コメントがあればヘルプに反映しつつ、最終的な判断は常にライブなメソッド定義に基づきます。
|
|
181
179
|
- **型に基づく解析**: `NAME [String]` や YARD タグから型を推論し、真偽値・配列・数値などを自動変換します。
|
|
182
|
-
- **厳密な整合性チェック**:
|
|
180
|
+
- **厳密な整合性チェック**: `rubycli --check` でコメントと実装のズレを実行前に検査し、通常実行時に `--strict` を付ければドキュメントで宣言した型・許可値以外の入力を拒否できます。
|
|
183
181
|
- **Ruby 向け拡張**: キーワード引数やブロック (`@yield*`) といった Ruby 固有の構文に合わせたパーサや `RUBYCLI_*` 環境変数を用意しています。
|
|
184
182
|
|
|
185
183
|
| 機能 | Python Fire | Rubycli |
|
|
@@ -284,15 +282,62 @@ README のサンプルは既定スタイルとして大文字プレースホル
|
|
|
284
282
|
|
|
285
283
|
> 補足: コメント内で任意引数を角括弧で表す必要はありません。Ruby 側のメソッドシグネチャから必須/任意は自動判定され、ヘルプ出力では Rubycli が適切に角括弧を追加します。
|
|
286
284
|
|
|
287
|
-
型ヒントは `[String]
|
|
285
|
+
型ヒントは `[String]` や `(String)` のように角括弧/丸括弧で指定できます。複数型は `(String, nil)` のように列挙してください。
|
|
288
286
|
|
|
289
|
-
`VALUE...` のような繰り返し指定(`TAG...` など)や、`[String[]]` / `Array<String>` といった配列型の注釈が付いたオプションは配列として扱われます。JSON/YAML 形式のリスト(例: `--tags '["build","test"]'`)を渡すか、カンマ区切り文字列(`--tags "build,test"`)を渡すことで配列に変換されます。スペース区切りの複数値入力(`--tags build test
|
|
287
|
+
`VALUE...` のような繰り返し指定(`TAG...` など)や、`[String[]]` / `Array<String>` といった配列型の注釈が付いたオプションは配列として扱われます。JSON/YAML 形式のリスト(例: `--tags '["build","test"]'`)を渡すか、カンマ区切り文字列(`--tags "build,test"`)を渡すことで配列に変換されます。スペース区切りの複数値入力(`--tags build test`)にはまだ対応しておらず、繰り返し注記のないオプションは従来どおりスカラーとして扱われます。`--strict` 実行時は各要素の型も検証されるため、`[String[]]` と書かれているのに `--tags [1,2]` のような数値配列を渡すと即エラーになります。
|
|
288
|
+
|
|
289
|
+
JSON やカンマ区切りで表現しづらいシンボル配列・ハッシュなどを渡したい場合は eval モード(`--eval-args`/`-e` または `--eval-lax`/`-E`)を有効にし、ドキュメントで宣言した型に合わせた Ruby リテラルを渡してください。スペース区切りが未対応でも、安全に複数選択を指定できます(後述の eval 例を参照)。
|
|
290
290
|
|
|
291
291
|
代表的な推論例:
|
|
292
292
|
|
|
293
293
|
- `ARG1` のように型ラベルを省略したプレースホルダは既定で `String` として扱われます。
|
|
294
294
|
- `--name ARG1` のようにオプションへプレースホルダだけを指定しても同じく `String` が推論されます。
|
|
295
295
|
- `--verbose` のように値プレースホルダを省略したオプションは Boolean フラグとして扱われます。
|
|
296
|
+
- 位置引数を Boolean にしたい場合は必ず `[Boolean]` を明示してください。`NAME 説明` や `@param name 説明` のように型を省略すると、Ruby 側のデフォルト値に関わらず `String` とみなされます。
|
|
297
|
+
|
|
298
|
+
### リテラル列挙による制約
|
|
299
|
+
|
|
300
|
+
`--format MODE [:json, :yaml, :auto]` や `LEVEL [:info, :warn]` のように型注釈内へ許容リテラルを列挙すると、ヘルプに選択肢を表示しつつ Rubycli が入力制約として解釈します。シンボル・文字列(裸の単語も可)・真偽値・数値・`nil` に対応し、型ヒントと混在させて `--channel TARGET [:stdout, :stderr, Boolean]` のような宣言も書けます。`%i[info warn]` / `%w[debug info]` などの短縮記法も展開されるため、`LEVEL %i[info warn]` でも同じ効果になります。通常実行では許可外の入力に警告を表示して続行し、`--strict` を付けた場合は `Rubycli::ArgumentError` を送出して即座に停止します。
|
|
301
|
+
|
|
302
|
+
> シンボルと文字列は厳密に区別されます。`[:info, :warn]` と書いた場合は `:info` のようにコロン付きで入力してください。`["info", "warn"]` を選んだ場合はプレーンな文字列のみ受け付けます。
|
|
303
|
+
|
|
304
|
+
> 列挙は各スカラー値に適用されます。`[Symbol[]]` のような配列注釈に対して「許可される組み合わせ」をリテラルで書く構文(例: `[%i[foo bar][]]`)は未サポートなので、必要に応じて文章で説明するか、eval モードで Ruby の配列を渡してください。
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
# literal choice デモ (examples/strict_choices_demo.rb)
|
|
308
|
+
ruby examples/strict_choices_demo.rb report warn --format json
|
|
309
|
+
#=> [WARN] format=json
|
|
310
|
+
|
|
311
|
+
# --strict を付けると仕様外の値で即エラー
|
|
312
|
+
ruby -Ilib exe/rubycli --strict examples/strict_choices_demo.rb report debug
|
|
313
|
+
#=> Rubycli::ArgumentError: Value "debug" for LEVEL is not allowed: allowed values are :info, :warn, :error
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
# シンボル入力はコロンを付ける
|
|
318
|
+
ruby -Ilib exe/rubycli --strict examples/strict_choices_demo.rb report :warn
|
|
319
|
+
#=> [WARN] format=text
|
|
320
|
+
|
|
321
|
+
ruby -Ilib exe/rubycli --strict examples/strict_choices_demo.rb report warn
|
|
322
|
+
#=> Rubycli::ArgumentError: Value "warn" for LEVEL is not allowed: allowed values are :info, :warn, :error
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### 標準ライブラリ型ヒント
|
|
326
|
+
|
|
327
|
+
コメントに `Date` や `Time`, `BigDecimal`, `Pathname` など標準ライブラリの型名を書けば、Rubycli が必要な `require` を行った上で CLI 引数をその型へ変換します。
|
|
328
|
+
|
|
329
|
+
```bash
|
|
330
|
+
# examples/typed_arguments_demo.rb より
|
|
331
|
+
ruby examples/typed_arguments_demo.rb ingest \
|
|
332
|
+
--date 2024-12-25 \
|
|
333
|
+
--moment 2024-12-25T10:00:00Z \
|
|
334
|
+
--budget 123.45 \
|
|
335
|
+
--input ./data/input.csv
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
ハンドラ側には `Date` / `Time` / `BigDecimal` / `Pathname` のインスタンスがそのまま渡るため、追加のパース処理は不要です。
|
|
339
|
+
|
|
340
|
+
各オプションには既定値があるため、`ruby examples/typed_arguments_demo.rb ingest --budget 999.99` のように個別の型だけ試すこともできます。
|
|
296
341
|
|
|
297
342
|
`@example` や `@raise`, `@see`, `@deprecated` などその他の YARD タグは、現状ヘルプ出力には反映されません。
|
|
298
343
|
|
|
@@ -331,7 +376,6 @@ Available commands:
|
|
|
331
376
|
scale AMOUNT [<FACTOR>] [--clamp=<value>] [--notify]
|
|
332
377
|
|
|
333
378
|
Detailed command help: fallback_example.rb COMMAND help
|
|
334
|
-
Enable debug logging: --debug or RUBYCLI_DEBUG=true
|
|
335
379
|
```
|
|
336
380
|
|
|
337
381
|
```bash
|
|
@@ -350,7 +394,7 @@ Options:
|
|
|
350
394
|
--notify [Boolean] optional (default: false)
|
|
351
395
|
```
|
|
352
396
|
|
|
353
|
-
`AMOUNT` だけがドキュメント化されていますが、`factor` や `clamp`, `notify`
|
|
397
|
+
`AMOUNT` だけがドキュメント化されていますが、`factor` や `clamp`, `notify` も自動的に補完され、既定値や型が推論されていることがわかります。開発時は `rubycli --check 対象.rb` でコメントとシグネチャの矛盾を検出し、本番実行で `--strict` を付ければ仕様外の入力をその場で弾けます。
|
|
354
398
|
|
|
355
399
|
#### 存在しない引数やオプションをコメントに書いた場合
|
|
356
400
|
|
|
@@ -387,6 +431,14 @@ YAML 固有の書き方は拒否され、無効な JSON であれば `JSON::Pars
|
|
|
387
431
|
|
|
388
432
|
`--eval-args`(短縮形 `-e`)を使うと、後続の引数を Ruby コードとして評価した結果を CLI に渡せます。JSON や YAML では表現しづらいオブジェクトを扱いたいときに便利ですが、評価は `Object.new.instance_eval { binding }` 上で行われるため、信頼できる入力に限定してください。コード内では `Rubycli.with_eval_mode(true) { … }` で切り替えられます。
|
|
389
433
|
|
|
434
|
+
Ruby 評価はシンボルや配列/ハッシュもそのまま扱えるため、列挙値の組み合わせをオプションへ渡すときにも役立ちます。
|
|
435
|
+
|
|
436
|
+
```bash
|
|
437
|
+
rubycli -E scripts/report_runner.rb publish \
|
|
438
|
+
--targets '[:marketing, :sales]' \
|
|
439
|
+
--channels '[:email, :slack]'
|
|
440
|
+
```
|
|
441
|
+
|
|
390
442
|
Ruby 評価を使いつつ、構文エラーが出たときは元の文字列にフォールバックさせたい場合は `--eval-lax`(短縮形 `-E`)を指定します。`--eval-args` と同じく eval モードを有効にしますが、Ruby として解釈できなかったトークン(例: 素の `https://example.com`)は警告を出した上でそのまま渡すため、`60*60*24*14` のような式と文字列を気軽に混在させられます。
|
|
391
443
|
|
|
392
444
|
`--json-args`/`-j` は `--eval-args`/`-e` および `--eval-lax`/`-E` と同時指定できません。どのモードも既定のリテラル解析を拡張する位置づけなので、用途に応じて厳格な JSON か Ruby eval(通常/lax)のいずれかを選択してください。
|
|
@@ -427,8 +479,9 @@ rubycli --pre-script scripts/bootstrap_runner.rb \
|
|
|
427
479
|
|
|
428
480
|
| 変数 / フラグ | 説明 | 既定値 |
|
|
429
481
|
| ------------- | ---- | ------ |
|
|
430
|
-
|
|
|
431
|
-
| `
|
|
482
|
+
| `RUBYCLI_DEBUG=true` | デバッグログ表示 | `false` |
|
|
483
|
+
| `--check` | コメント/実装のズレを検査し、コマンドは実行しない | `off` |
|
|
484
|
+
| `--strict` | ドキュメントで許可した型・値以外をエラーとして拒否 | `off` |
|
|
432
485
|
| `RUBYCLI_ALLOW_PARAM_COMMENT=OFF` | レガシーな `@param` 記法を無効化(互換性のため既定では ON) | `ON` |
|
|
433
486
|
|
|
434
487
|
## Rubycli API
|
data/README.md
CHANGED
|
@@ -35,7 +35,6 @@ Available commands:
|
|
|
35
35
|
greet <NAME>
|
|
36
36
|
|
|
37
37
|
Detailed command help: hello_app.rb COMMAND help
|
|
38
|
-
Enable debug logging: --debug or RUBYCLI_DEBUG=true
|
|
39
38
|
```
|
|
40
39
|
|
|
41
40
|
```bash
|
|
@@ -109,7 +108,6 @@ Available commands:
|
|
|
109
108
|
greet <NAME> [--shout]
|
|
110
109
|
|
|
111
110
|
Detailed command help: hello_app_with_docs.rb COMMAND help
|
|
112
|
-
Enable debug logging: --debug or RUBYCLI_DEBUG=true
|
|
113
111
|
```
|
|
114
112
|
|
|
115
113
|
```bash
|
|
@@ -165,13 +163,13 @@ Rubycli assumes that the file name (CamelCased) matches the class or module you
|
|
|
165
163
|
|
|
166
164
|
This keeps large projects safe by default but still provides a one-flag escape hatch when you prefer the fully automatic behaviour.
|
|
167
165
|
|
|
168
|
-
> **Instance-only classes** – If a class only defines public *instance* methods (for example, it exposes functionality via `
|
|
166
|
+
> **Instance-only classes** – If a class only defines public *instance* methods (for example, it exposes functionality via `def greet` on the instance), you must run Rubycli with `--new` so the class is instantiated before commands are resolved. Otherwise Rubycli cannot see any CLI-callable methods. Add at least one public class method when you do not want to rely on `--new`.
|
|
169
167
|
|
|
170
168
|
## Project Philosophy
|
|
171
169
|
|
|
172
170
|
- **Convenience first** – The goal is to wrap existing Ruby scripts in a CLI with almost no manual plumbing. Fidelity with Python Fire is not a requirement.
|
|
173
171
|
- **Inspired, not a port** – We borrow ideas from Python Fire, but we do not aim for feature parity. Missing Fire features are generally “by design.”
|
|
174
|
-
- **Method definitions first, comments augment behavior** – Public method signatures determine what gets exposed (and which arguments are required), while doc comments like `TAG...` or `[Integer]` can turn the very same CLI value into arrays, integers, booleans, etc.
|
|
172
|
+
- **Method definitions first, comments augment behavior** – Public method signatures determine what gets exposed (and which arguments are required), while doc comments like `TAG...` or `[Integer]` can turn the very same CLI value into arrays, integers, booleans, etc. Rubycli also auto-parses inputs that look like JSON/YAML literals (for example `--names='["Alice","Bob"]'`) before enforcing the documented type. Run `rubycli --check path/to/script.rb` to lint documentation mismatches, and pass `--strict` during normal runs when you want invalid input to abort instead of merely warning. (Current limitation: `--check` verifies that comments cover every argument, but it does not yet validate the spelling of type tokens—`Booalean` will be treated as a plain string.)
|
|
175
173
|
- **Lightweight maintenance** – Much of the implementation was generated with AI assistance; contributions that diverge into deep Ruby metaprogramming are out of scope. Please discuss expectations before opening parity PRs.
|
|
176
174
|
|
|
177
175
|
## Features
|
|
@@ -180,13 +178,13 @@ This keeps large projects safe by default but still provides a one-flag escape h
|
|
|
180
178
|
- Automatic option signature inference (`NAME [Type] Description…`) without extra DSLs
|
|
181
179
|
- Safe literal parsing out of the box (arrays / hashes / booleans) with opt-in strict JSON and Ruby eval modes
|
|
182
180
|
- Optional pre-script hook (`--pre-script` / `--init`) to evaluate Ruby and expose the resulting object
|
|
183
|
-
-
|
|
181
|
+
- Dedicated CLI flags for quality gates: `--check` lints documentation/comments without running commands, and `--strict` treats documented types/choices as hard requirements
|
|
184
182
|
|
|
185
183
|
## How it differs from Python Fire
|
|
186
184
|
|
|
187
185
|
- **Comment-aware help** – Rubycli leans on doc comments when present but still reflects the live method signature, keeping code as the ultimate authority.
|
|
188
186
|
- **Type-aware parsing** – Placeholder syntax (`NAME [String]`) and YARD tags let Rubycli coerce arguments to booleans, arrays, numerics, etc. without additional code.
|
|
189
|
-
- **Strict validation** –
|
|
187
|
+
- **Strict validation** – `rubycli --check` lint runs catch documentation drift before execution, while runtime `--strict` runs turn documented types/choices into enforceable contracts.
|
|
190
188
|
- **Ruby-centric tooling** – Supports Ruby-specific conventions such as optional keyword arguments, block documentation (`@yield*` tags), and `RUBYCLI_*` environment toggles.
|
|
191
189
|
|
|
192
190
|
| Capability | Python Fire | Rubycli |
|
|
@@ -293,15 +291,62 @@ The CLI treats `--flag VALUE`, `--flag <value>`, and `--flag=<value>` identicall
|
|
|
293
291
|
|
|
294
292
|
> Tip: You do not need to wrap optional arguments in brackets inside the comment. Rubycli already knows which parameters are optional from the Ruby signature and will introduce the brackets in generated help.
|
|
295
293
|
|
|
296
|
-
You can annotate types using `[String]
|
|
294
|
+
You can annotate types using `[String]` or `(String)`—they both convey the same hint, and you can list multiple types such as `(String, nil)`.
|
|
297
295
|
|
|
298
|
-
Repeated values (`VALUE...`) now materialize as arrays automatically whenever the option is documented with an ellipsis (for example `TAG...`) or an explicit array type hint (`[String[]]`, `Array<String>`). Supply either JSON/YAML list syntax (`--tags "[\"build\",\"test\"]"`) or a comma-delimited string (`--tags "build,test"`); Rubycli will coerce both forms to arrays. Space-separated multi-value flags (`--tags build test`) are still not supported, and options without a repeated/array hint continue to be parsed as scalars.
|
|
296
|
+
Repeated values (`VALUE...`) now materialize as arrays automatically whenever the option is documented with an ellipsis (for example `TAG...`) or an explicit array type hint (`[String[]]`, `Array<String>`). Supply either JSON/YAML list syntax (`--tags "[\"build\",\"test\"]"`) or a comma-delimited string (`--tags "build,test"`); Rubycli will coerce both forms to arrays. Space-separated multi-value flags (`--tags build test`) are still not supported, and options without a repeated/array hint continue to be parsed as scalars. Strict mode still verifies each element against the documented type, so `--tags [1,2]` will fail when the docs say `[String[]]`.
|
|
297
|
+
|
|
298
|
+
Need to pass structures that are awkward to express as JSON (for example symbol arrays or hashes)? Enable eval mode (`--eval-args`/`-e` or `--eval-lax`/`-E`) and supply a Ruby literal that matches the documented type; the example in the eval section below shows how to pass multiple enum selections safely even though space-separated syntax remains unsupported.
|
|
299
299
|
|
|
300
300
|
Common inference rules:
|
|
301
301
|
|
|
302
302
|
- Writing a placeholder such as `ARG1` (without `[String]`) makes Rubycli treat it as a `String`.
|
|
303
303
|
- Using that placeholder in an option line (`--name ARG1`) also infers a `String`.
|
|
304
304
|
- Omitting the placeholder entirely (`--verbose`) produces a Boolean flag.
|
|
305
|
+
- Positional arguments only become booleans when you annotate `[Boolean]`; a bare `NAME Description` (or `@param name Description`) falls back to `String`, regardless of the Ruby default value.
|
|
306
|
+
|
|
307
|
+
### Literal choices and enums
|
|
308
|
+
|
|
309
|
+
You can express a finite set of accepted values directly inside the type annotation, for example `--format MODE [:json, :yaml, :auto]` or `LEVEL [:info, :warn]`. Symbols, strings (including barewords), booleans, numbers, and `nil` are supported, and you can mix literal entries with broader types such as `--channel TARGET [:stdout, :stderr, Boolean]`. `%i[info warn]` / `%w[debug info]` short-hands expand as expected, so `LEVEL %i[info warn]` works the same as the explicit array form. Rubycli always records these choices in the generated help; when you run with `--strict`, any value outside the documented set results in `Rubycli::ArgumentError`, otherwise a warning is printed and execution proceeds.
|
|
310
|
+
|
|
311
|
+
> Symbols and strings are compared strictly. `[:info, :warn]` requires symbol inputs such as `:info`, while `["info", "warn"]` only accepts plain strings. Prefix a value with `:` at the CLI to pass a symbol.
|
|
312
|
+
|
|
313
|
+
> Literal enums currently apply to each scalar argument. If an option is documented as an array (for example `[Symbol[]]`), spell out the allowed members in prose for now—combined literal arrays such as `[%i[foo bar][]]` are not supported.
|
|
314
|
+
|
|
315
|
+
```bash
|
|
316
|
+
# literal choices + booleans (see examples/strict_choices_demo.rb)
|
|
317
|
+
ruby examples/strict_choices_demo.rb report warn --format json
|
|
318
|
+
#=> [WARN] format=json
|
|
319
|
+
|
|
320
|
+
# the same command with --strict will abort when values drift
|
|
321
|
+
ruby -Ilib exe/rubycli --strict examples/strict_choices_demo.rb report debug
|
|
322
|
+
#=> Rubycli::ArgumentError: Value "debug" for LEVEL is not allowed: allowed values are :info, :warn, :error
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
# symbol values stay distinct
|
|
327
|
+
ruby -Ilib exe/rubycli --strict examples/strict_choices_demo.rb report :warn
|
|
328
|
+
#=> [WARN] format=text
|
|
329
|
+
|
|
330
|
+
ruby -Ilib exe/rubycli --strict examples/strict_choices_demo.rb report warn
|
|
331
|
+
#=> Rubycli::ArgumentError: Value "warn" for LEVEL is not allowed: allowed values are :info, :warn, :error
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### Standard library type hints
|
|
335
|
+
|
|
336
|
+
Doc comments can reference standard classes such as `Date`, `Time`, `BigDecimal`, or `Pathname`. Rubycli loads the necessary stdlib files on demand and coerces CLI inputs using the documented types.
|
|
337
|
+
|
|
338
|
+
```bash
|
|
339
|
+
# see examples/typed_arguments_demo.rb
|
|
340
|
+
ruby examples/typed_arguments_demo.rb ingest \
|
|
341
|
+
--date 2024-12-25 \
|
|
342
|
+
--moment 2024-12-25T10:00:00Z \
|
|
343
|
+
--budget 123.45 \
|
|
344
|
+
--input ./data/input.csv
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
This command prints a normalized summary and the handler receives real `Date`, `Time`, `BigDecimal`, and `Pathname` objects without manual parsing.
|
|
348
|
+
|
|
349
|
+
Each option has a sensible default, so you can also experiment one at a time (for example `ruby examples/typed_arguments_demo.rb ingest --budget 999.99`).
|
|
305
350
|
|
|
306
351
|
Other YARD tags such as `@example`, `@raise`, `@see`, and `@deprecated` are currently ignored by the CLI renderer.
|
|
307
352
|
|
|
@@ -340,7 +385,6 @@ Available commands:
|
|
|
340
385
|
scale AMOUNT [<FACTOR>] [--clamp=<value>] [--notify]
|
|
341
386
|
|
|
342
387
|
Detailed command help: fallback_example.rb COMMAND help
|
|
343
|
-
Enable debug logging: --debug or RUBYCLI_DEBUG=true
|
|
344
388
|
```
|
|
345
389
|
|
|
346
390
|
```bash
|
|
@@ -359,7 +403,7 @@ Options:
|
|
|
359
403
|
--notify [Boolean] optional (default: false)
|
|
360
404
|
```
|
|
361
405
|
|
|
362
|
-
Here only `AMOUNT` is documented, yet `factor`, `clamp`, and `notify` are still presented with sensible defaults and inferred types.
|
|
406
|
+
Here only `AMOUNT` is documented, yet `factor`, `clamp`, and `notify` are still presented with sensible defaults and inferred types. Run `rubycli --check path/to/script.rb` during development to surface mismatches between comments and signatures, and pass `--strict` when executing commands to enforce the documented types/choices.
|
|
363
407
|
|
|
364
408
|
#### What if the docs mention arguments that do not exist?
|
|
365
409
|
|
|
@@ -395,6 +439,14 @@ rubycli -e scripts/data_cli.rb DataCLI run '(1..10).to_a'
|
|
|
395
439
|
|
|
396
440
|
Under the hood Rubycli evaluates each argument inside an isolated binding (`Object.new.instance_eval { binding }`). Treat this as unsafe input: do not enable it for untrusted callers. The mode can also be toggled programmatically via `Rubycli.with_eval_mode(true) { … }`.
|
|
397
441
|
|
|
442
|
+
Because Ruby evaluation understands symbols, arrays, and hashes, it’s a convenient way to pass literal enum combinations to options that expect arrays:
|
|
443
|
+
|
|
444
|
+
```bash
|
|
445
|
+
rubycli -E scripts/report_runner.rb publish \
|
|
446
|
+
--targets '[:marketing, :sales]' \
|
|
447
|
+
--channels '[:email, :slack]'
|
|
448
|
+
```
|
|
449
|
+
|
|
398
450
|
Need Ruby evaluation plus a safety net? Pass `--eval-lax` (or `-E`). It flips on eval mode just like `--eval-args`, but if Ruby fails to parse a token (for example, a bare `https://example.com`), Rubycli emits a warning and forwards the original string unchanged. This lets you mix inline math (`60*60*24*14`) with literal values without constantly juggling quotes.
|
|
399
451
|
|
|
400
452
|
`--json-args`/`-j` cannot be combined with either `--eval-args`/`-e` or `--eval-lax`/`-E`; Rubycli will raise an error if both are present. Both modes augment the default literal parsing, so you can pick either strict JSON or one of the Ruby eval variants when the defaults are not enough.
|
|
@@ -435,8 +487,9 @@ This keeps `--new` available for quick zero-argument instantiation while allowin
|
|
|
435
487
|
|
|
436
488
|
| Flag / Env | Description | Default |
|
|
437
489
|
| ---------- | ----------- | ------- |
|
|
438
|
-
|
|
|
439
|
-
| `
|
|
490
|
+
| `RUBYCLI_DEBUG=true` | Print debug logs | `false` |
|
|
491
|
+
| `--check` | Validate documentation/comments without executing commands | `off` |
|
|
492
|
+
| `--strict` | Enforce documented choices/types; invalid input aborts | `off` |
|
|
440
493
|
| `RUBYCLI_ALLOW_PARAM_COMMENT=OFF` | Disable legacy `@param` lines (defaults to on today for compatibility) | `ON` |
|
|
441
494
|
|
|
442
495
|
## Library helpers
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require 'did_you_mean'
|
|
2
|
+
|
|
1
3
|
require_relative 'type_utils'
|
|
2
4
|
require_relative 'arguments/token_stream'
|
|
3
5
|
require_relative 'arguments/value_converter'
|
|
@@ -61,6 +63,14 @@ module Rubycli
|
|
|
61
63
|
[pos_args, kw_args]
|
|
62
64
|
end
|
|
63
65
|
|
|
66
|
+
def validate_inputs(method_obj, positional_args, keyword_args)
|
|
67
|
+
return unless method_obj
|
|
68
|
+
|
|
69
|
+
metadata = @documentation_registry.metadata_for(method_obj)
|
|
70
|
+
validate_positional_arguments(method_obj, metadata, positional_args)
|
|
71
|
+
validate_keyword_arguments(metadata, keyword_args)
|
|
72
|
+
end
|
|
73
|
+
|
|
64
74
|
private
|
|
65
75
|
|
|
66
76
|
def debug_log(message)
|
|
@@ -196,6 +206,235 @@ module Rubycli
|
|
|
196
206
|
@value_converter.convert(arg)
|
|
197
207
|
end
|
|
198
208
|
|
|
209
|
+
def validate_positional_arguments(method_obj, metadata, positional_args)
|
|
210
|
+
return if positional_args.nil? || positional_args.empty?
|
|
211
|
+
|
|
212
|
+
positional_map = metadata[:positionals_map] || {}
|
|
213
|
+
ordered_params = method_obj.parameters.select { |type, _| %i[req opt].include?(type) }
|
|
214
|
+
|
|
215
|
+
ordered_params.each_with_index do |(_, name), index|
|
|
216
|
+
definition = positional_map[name]
|
|
217
|
+
next unless definition
|
|
218
|
+
next if index >= positional_args.size
|
|
219
|
+
|
|
220
|
+
label = definition.label || definition.placeholder || name.to_s.upcase
|
|
221
|
+
enforce_value_against_definition(definition, positional_args[index], label)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def validate_keyword_arguments(metadata, keyword_args)
|
|
226
|
+
return if keyword_args.nil? || keyword_args.empty?
|
|
227
|
+
|
|
228
|
+
option_lookup = build_option_lookup(metadata[:options] || [])
|
|
229
|
+
keyword_args.each do |key, value|
|
|
230
|
+
definition = option_lookup[key.to_sym]
|
|
231
|
+
next unless definition
|
|
232
|
+
|
|
233
|
+
label = definition.long || "--#{key.to_s.tr('_', '-')}"
|
|
234
|
+
enforce_value_against_definition(definition, value, label)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def enforce_value_against_definition(definition, value, label)
|
|
239
|
+
return unless definition
|
|
240
|
+
|
|
241
|
+
return if type_allowed?(definition.types, value)
|
|
242
|
+
|
|
243
|
+
Array(value.is_a?(Array) ? value : [value]).each do |entry|
|
|
244
|
+
next if literal_allowed?(definition.allowed_values, entry)
|
|
245
|
+
next if type_allowed?(definition.types, entry)
|
|
246
|
+
|
|
247
|
+
message = build_invalid_value_message(definition, entry, label)
|
|
248
|
+
@environment.handle_input_violation(message)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def build_invalid_value_message(definition, entry, label)
|
|
253
|
+
description = allowed_value_description(definition)
|
|
254
|
+
formatted_value = format_literal_value(entry)
|
|
255
|
+
|
|
256
|
+
message = if description
|
|
257
|
+
"#{label} must be #{description} (received #{formatted_value})"
|
|
258
|
+
else
|
|
259
|
+
"Value #{formatted_value} for #{label} is not allowed"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
suggestions = literal_suggestions(definition, entry)
|
|
263
|
+
return message if suggestions.empty?
|
|
264
|
+
|
|
265
|
+
suggestion_text = suggestions.size == 1 ? suggestions.first : suggestions.join(', ')
|
|
266
|
+
"#{message}. Did you mean #{suggestion_text}?"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def literal_allowed?(allowed_entries, value)
|
|
270
|
+
entries = Array(allowed_entries).compact
|
|
271
|
+
return false if entries.empty?
|
|
272
|
+
|
|
273
|
+
entries.any? { |entry| literal_match?(entry[:value], value) }
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def literal_match?(candidate, value)
|
|
277
|
+
case candidate
|
|
278
|
+
when Symbol
|
|
279
|
+
value.is_a?(Symbol) && value == candidate
|
|
280
|
+
when String
|
|
281
|
+
value.is_a?(String) && value == candidate
|
|
282
|
+
when Integer
|
|
283
|
+
value.is_a?(Integer) && value == candidate
|
|
284
|
+
when Float
|
|
285
|
+
value.is_a?(Float) && value == candidate
|
|
286
|
+
when TrueClass, FalseClass
|
|
287
|
+
value == candidate
|
|
288
|
+
when NilClass
|
|
289
|
+
value.nil?
|
|
290
|
+
else
|
|
291
|
+
value == candidate
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def type_allowed?(types, value)
|
|
296
|
+
tokens = Array(types).compact
|
|
297
|
+
return false if tokens.empty?
|
|
298
|
+
|
|
299
|
+
tokens.any? { |token| matches_type_token?(token, value) }
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def allowed_value_description(definition)
|
|
303
|
+
literal_descriptions = Array(definition.allowed_values).map { |entry| format_literal_value(entry[:value]) }.reject(&:empty?)
|
|
304
|
+
type_descriptions = Array(definition.types)
|
|
305
|
+
.map { |token| token.to_s.strip }
|
|
306
|
+
.reject { |token| token.empty? || literal_hint_token?(token) }
|
|
307
|
+
combined = (literal_descriptions + type_descriptions).uniq.reject(&:empty?)
|
|
308
|
+
return nil if combined.empty?
|
|
309
|
+
|
|
310
|
+
return combined.first if combined.size == 1
|
|
311
|
+
|
|
312
|
+
"one of #{combined.join(', ')}"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def literal_suggestions(definition, entry)
|
|
316
|
+
literals = Array(definition.allowed_values).map { |allowed| allowed[:value] }.compact
|
|
317
|
+
return [] if literals.empty?
|
|
318
|
+
return [] unless entry.is_a?(String) || entry.is_a?(Symbol)
|
|
319
|
+
|
|
320
|
+
candidates = literals.select { |value| value.is_a?(String) || value.is_a?(Symbol) }
|
|
321
|
+
return [] if candidates.empty?
|
|
322
|
+
|
|
323
|
+
lookup = {}
|
|
324
|
+
dictionary = candidates.each_with_object([]) do |candidate, memo|
|
|
325
|
+
key = candidate.to_s
|
|
326
|
+
lookup[key] ||= candidate
|
|
327
|
+
memo << key unless memo.include?(key)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
spell_checker = DidYouMean::SpellChecker.new(dictionary: dictionary)
|
|
331
|
+
matches = spell_checker.correct(entry.to_s)
|
|
332
|
+
return [] if matches.empty?
|
|
333
|
+
|
|
334
|
+
matches.take(3).map { |match| format_literal_value(lookup[match]) }
|
|
335
|
+
rescue LoadError, NameError
|
|
336
|
+
[]
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def matches_type_token?(token, value)
|
|
340
|
+
normalized = token.to_s.strip
|
|
341
|
+
return true if normalized.empty?
|
|
342
|
+
|
|
343
|
+
if (inner = array_inner_type(normalized))
|
|
344
|
+
return false unless value.is_a?(Array)
|
|
345
|
+
return value.all? { |element| matches_type_token?(inner, element) }
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
case normalized
|
|
349
|
+
when 'Boolean'
|
|
350
|
+
value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
|
351
|
+
when 'JSON'
|
|
352
|
+
value.is_a?(Hash) || value.is_a?(Array)
|
|
353
|
+
when 'nil', 'NilClass'
|
|
354
|
+
value.nil?
|
|
355
|
+
else
|
|
356
|
+
klass = constant_for_token(normalized)
|
|
357
|
+
return value.is_a?(klass) if klass
|
|
358
|
+
|
|
359
|
+
false
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def array_inner_type(token)
|
|
364
|
+
if token.end_with?('[]')
|
|
365
|
+
token[0..-3]
|
|
366
|
+
elsif token.start_with?('Array<') && token.end_with?('>')
|
|
367
|
+
token[6..-2].strip
|
|
368
|
+
else
|
|
369
|
+
nil
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def nil_type_token?(token)
|
|
374
|
+
token.to_s.strip.casecmp('nil').zero? || token.to_s.strip.casecmp('NilClass').zero?
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def safe_constant_lookup(name)
|
|
378
|
+
parts = name.to_s.split('::').reject(&:empty?)
|
|
379
|
+
return nil if parts.empty?
|
|
380
|
+
|
|
381
|
+
context = Object
|
|
382
|
+
parts.each do |const_name|
|
|
383
|
+
return nil unless context.const_defined?(const_name, false)
|
|
384
|
+
|
|
385
|
+
context = context.const_get(const_name)
|
|
386
|
+
end
|
|
387
|
+
context
|
|
388
|
+
rescue NameError
|
|
389
|
+
nil
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def constant_for_token(token)
|
|
393
|
+
normalized = token.to_s
|
|
394
|
+
case normalized
|
|
395
|
+
when 'Fixnum'
|
|
396
|
+
return Integer
|
|
397
|
+
when 'Date', 'DateTime'
|
|
398
|
+
require 'date'
|
|
399
|
+
when 'Time'
|
|
400
|
+
require 'time'
|
|
401
|
+
when 'BigDecimal', 'Decimal'
|
|
402
|
+
require 'bigdecimal'
|
|
403
|
+
when 'Pathname'
|
|
404
|
+
require 'pathname'
|
|
405
|
+
when 'Struct'
|
|
406
|
+
return Struct
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
safe_constant_lookup(normalized)
|
|
410
|
+
rescue LoadError
|
|
411
|
+
safe_constant_lookup(normalized)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def format_literal_value(value)
|
|
415
|
+
case value
|
|
416
|
+
when Symbol
|
|
417
|
+
":#{value}"
|
|
418
|
+
when String
|
|
419
|
+
value.inspect
|
|
420
|
+
when Integer, Float
|
|
421
|
+
value.to_s
|
|
422
|
+
when TrueClass, FalseClass
|
|
423
|
+
value.to_s
|
|
424
|
+
when NilClass
|
|
425
|
+
'nil'
|
|
426
|
+
else
|
|
427
|
+
value.inspect
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def literal_hint_token?(token)
|
|
432
|
+
token = token.to_s.strip
|
|
433
|
+
return false if token.empty?
|
|
434
|
+
|
|
435
|
+
token.start_with?('%i[', '%I[', '%w[', '%W[')
|
|
436
|
+
end
|
|
437
|
+
|
|
199
438
|
|
|
200
439
|
def build_cli_alias_map(option_defs)
|
|
201
440
|
option_defs.each_with_object({}) do |opt, memo|
|
|
@@ -273,7 +512,15 @@ module Rubycli
|
|
|
273
512
|
->(value) { value.to_sym }
|
|
274
513
|
when 'BigDecimal', 'Decimal'
|
|
275
514
|
require 'bigdecimal'
|
|
276
|
-
->(value) {
|
|
515
|
+
->(value) {
|
|
516
|
+
return value if value.is_a?(BigDecimal)
|
|
517
|
+
|
|
518
|
+
if value.is_a?(String)
|
|
519
|
+
BigDecimal(value)
|
|
520
|
+
else
|
|
521
|
+
BigDecimal(value.to_s)
|
|
522
|
+
end
|
|
523
|
+
}
|
|
277
524
|
when 'Date'
|
|
278
525
|
require 'date'
|
|
279
526
|
->(value) { Date.parse(value) }
|
|
@@ -282,6 +529,13 @@ module Rubycli
|
|
|
282
529
|
->(value) { Time.parse(value) }
|
|
283
530
|
when 'JSON', 'Hash'
|
|
284
531
|
->(value) { JSON.parse(value) }
|
|
532
|
+
when 'Pathname'
|
|
533
|
+
require 'pathname'
|
|
534
|
+
->(value) {
|
|
535
|
+
return value if value.is_a?(Pathname)
|
|
536
|
+
|
|
537
|
+
Pathname.new(value.to_s)
|
|
538
|
+
}
|
|
285
539
|
else
|
|
286
540
|
if normalized.start_with?('Array<') && normalized.end_with?('>')
|
|
287
541
|
inner = normalized[6..-2].strip
|