run_kit 0.1.2 → 0.1.4
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/.rubocop.yml +0 -1
- data/README.md +35 -5
- data/demo.rb +15 -3
- data/lib/run_kit/core_ext.rb +5 -10
- data/lib/run_kit/options/config.rb +42 -15
- data/lib/run_kit/options/help.rb +63 -40
- data/lib/run_kit/options/main.rb +86 -28
- data/lib/run_kit/options/parser.rb +61 -52
- data/lib/run_kit/shell.rb +4 -4
- data/lib/run_kit.rb +8 -1
- data/run_kit.gemspec +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 957080dc55365e15529d2077974586f3239ac02d04d8b2ef6f568ca126ed22a5
|
|
4
|
+
data.tar.gz: 92beadf93985edeac07cc957b66fea237a55f29e4c7db97e43eecfd214eda91e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5ff6d38e709d6412237e499a8df5f3388d133fa56dcd57c66bc89355da4c419273203ae3264acb54dcf569a232d0d8321753821c1b20e599e9480d497d9d0228
|
|
7
|
+
data.tar.gz: 9999afdfe9bda8cf5903cb695a59b26c387401e5d21732e107977100b0873a35c9c2b6bfdb38fa309fcfa3642922050f5ebd809229d57db22f7d5422b75601a5
|
data/.rubocop.yml
CHANGED
|
@@ -35,7 +35,6 @@ Naming/MemoizedInstanceVariableName: { Enabled: true } # clean memo ivars
|
|
|
35
35
|
Naming/MethodName: { Enabled: true } # keep method names conventional
|
|
36
36
|
Performance/MapCompact: { Enabled: true } # filter_map-ish style
|
|
37
37
|
Performance/RegexpMatch: { Enabled: false } # local style is fine
|
|
38
|
-
Performance/SelectMap: { Enabled: true } # filter_map-ish style
|
|
39
38
|
Style/BlockDelimiters: { Enabled: true } # do/end vs braces
|
|
40
39
|
Style/ClassAndModuleChildren: { Enabled: true, EnforcedStyle: nested } # explicit nesting
|
|
41
40
|
Style/ClassMethodsDefinitions: { Enabled: true } # avoid "class << self"
|
data/README.md
CHANGED
|
@@ -6,10 +6,10 @@ RunKit is a small toolkit for cli. It provides option parsing, shell and file he
|
|
|
6
6
|
|
|
7
7
|
```ruby
|
|
8
8
|
# install gem
|
|
9
|
-
$ gem install
|
|
9
|
+
$ gem install run_kit
|
|
10
10
|
|
|
11
11
|
# or add to your Gemfile
|
|
12
|
-
gem "
|
|
12
|
+
gem "run_kit"
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
## RunKit::Options
|
|
@@ -37,7 +37,28 @@ o.bool "--force", env: true # ENV["FORCE"]
|
|
|
37
37
|
o.str "--token", env: "API_TOKEN" # ENV["API_TOKEN"]
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
Custom validation:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
o.validate = lambda do |options|
|
|
44
|
+
raise "--count must be positive" if options.count <= 0
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Also supports subcommands, `git`-style:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
o.bool "-n", "--dry-run"
|
|
52
|
+
o.cmd "build", "Build the project" do |c|
|
|
53
|
+
c.str "--target <target>", default: "release"
|
|
54
|
+
end
|
|
55
|
+
o.cmd "test", "Run tests" do |c|
|
|
56
|
+
c.bool "--verbose"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# myapp build --target debug
|
|
60
|
+
# => #<data command="build", dry_run=false, target="debug", _args=[]>
|
|
61
|
+
```
|
|
41
62
|
|
|
42
63
|
## RunKit::Shell
|
|
43
64
|
|
|
@@ -48,7 +69,7 @@ Configured variables appear in `--help`. Command-line values override ENV, which
|
|
|
48
69
|
| `csv_read` / `csv_write` | Read/write CSV (add .gz for gzip) |
|
|
49
70
|
| `file_read` / `file_write` | Atomic read/write files (add .gz for gzip) |
|
|
50
71
|
| `json_read` / `json_write` | Atomic read/write json (add .gz for gzip) |
|
|
51
|
-
| `jsonl_read / `jsonl_write`
|
|
72
|
+
| `jsonl_read` / `jsonl_write` | Atomic read/write jsonl (add .gz for gzip) |
|
|
52
73
|
| |
|
|
53
74
|
| `csv_write_stdout` | Write CSV to stdout |
|
|
54
75
|
| `gunzip` / `gzip` | (De)compress a string |
|
|
@@ -66,7 +87,7 @@ Configured variables appear in `--help`. Command-line values override ENV, which
|
|
|
66
87
|
| |
|
|
67
88
|
| `banner` / `warning` / `fatal` | Pretty banner in green, orange or red (fatal exits) |
|
|
68
89
|
| `program_name` | Return executable name |
|
|
69
|
-
| `prompt?` | Ask
|
|
90
|
+
| `prompt?` | Ask user for confirmation |
|
|
70
91
|
| `md5` / `sha256` | Hash strings |
|
|
71
92
|
|
|
72
93
|
### RunKit CoreExt
|
|
@@ -111,6 +132,15 @@ Note: There has been some effort to get the Pathname helpers into Ruby itself, w
|
|
|
111
132
|
|
|
112
133
|
### Changelog
|
|
113
134
|
|
|
135
|
+
#### 0.1.4 (Sep 2026)
|
|
136
|
+
|
|
137
|
+
- add subcommand support (`o.cmd`)
|
|
138
|
+
- add custom validation (`o.validate`)
|
|
139
|
+
|
|
140
|
+
#### 0.1.3 (Sep 2026)
|
|
141
|
+
|
|
142
|
+
- move PROGBAR constant into RunKit::
|
|
143
|
+
|
|
114
144
|
#### 0.1.2 (Sep 2026)
|
|
115
145
|
|
|
116
146
|
- allow options to read from ENV
|
data/demo.rb
CHANGED
|
@@ -3,9 +3,21 @@
|
|
|
3
3
|
require_relative "lib/run_kit"
|
|
4
4
|
|
|
5
5
|
options = RunKit.parse do |o|
|
|
6
|
-
o.
|
|
7
|
-
o.
|
|
8
|
-
o.
|
|
6
|
+
o.version = "1.0"
|
|
7
|
+
o.bool "--dry-run", "Preview without making changes"
|
|
8
|
+
o.desc = "this is made up"
|
|
9
|
+
|
|
10
|
+
o.cmd "fetch", "Fetch a URL" do |c|
|
|
11
|
+
c.int "-n", "--count <n>", "How many times to run", default: 1
|
|
12
|
+
c.str "--mode <mode>", "Run quickly, or not", choices: %w[fast slow]
|
|
13
|
+
c.positional "<url>", "URL to fetch"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
o.cmd "build", "Build the project" do |c|
|
|
17
|
+
c.naked = false
|
|
18
|
+
c.str "--target <target>", "Build target", choices: %w[debug release], default: "release"
|
|
19
|
+
c.bool "--force", "Force a rebuild", env: "DEMO_FORCE"
|
|
20
|
+
end
|
|
9
21
|
end
|
|
10
22
|
|
|
11
23
|
p options
|
data/lib/run_kit/core_ext.rb
CHANGED
|
@@ -34,20 +34,15 @@ module RunKit
|
|
|
34
34
|
# each.with_progresbar
|
|
35
35
|
module Enumerator
|
|
36
36
|
def with_progressbar(options = {}, &block)
|
|
37
|
-
|
|
38
|
-
format: "%t: %j%% %B #{RunKit::Term.paint_ansi("%c/%u %e", RunKit::Term.ansi256_fg(242))}",
|
|
39
|
-
progress_mark: RunKit::Term.paint_ansi("━", RunKit::Term.ansi256_fg(46)),
|
|
40
|
-
remainder_mark: RunKit::Term.paint_ansi("━", RunKit::Term.ansi256_fg(237)),
|
|
37
|
+
options = RunKit::PROGRESSBAR.merge(
|
|
41
38
|
output: $stdout.isatty ? $stdout : $stderr,
|
|
42
|
-
total: size
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
options = defaults.merge(options)
|
|
39
|
+
total: size
|
|
40
|
+
).merge(options)
|
|
46
41
|
|
|
47
42
|
return enum_for(__method__) if !block
|
|
48
43
|
|
|
49
|
-
if !options[:hide]
|
|
50
|
-
|
|
44
|
+
bar = if !options[:hide]
|
|
45
|
+
ProgressBar.create(options)
|
|
51
46
|
end
|
|
52
47
|
RunKit::Term.with_hidden_cursor(options[:output]) do
|
|
53
48
|
each do
|
|
@@ -6,15 +6,17 @@
|
|
|
6
6
|
module RunKit
|
|
7
7
|
module Options
|
|
8
8
|
class Config
|
|
9
|
-
attr_accessor :
|
|
10
|
-
attr_reader :
|
|
9
|
+
attr_accessor :banner, :color, :desc, :exit, :help, :naked, :root, :validate, :version
|
|
10
|
+
attr_reader :help_flag, :name, :version_flag
|
|
11
11
|
alias_method :naked?, :naked
|
|
12
12
|
|
|
13
|
-
def initialize
|
|
14
|
-
@app_name = File.basename($PROGRAM_NAME)
|
|
13
|
+
def initialize(name: nil)
|
|
15
14
|
@naked = true
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
self.name = name || Shell.program_name
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def name=(name)
|
|
19
|
+
@name = name.to_s
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
# Add a positional param declared as `<url>`.
|
|
@@ -26,6 +28,18 @@ module RunKit
|
|
|
26
28
|
end
|
|
27
29
|
end
|
|
28
30
|
|
|
31
|
+
# Add a subcommand with its own nested Config, eg `myapp build`.
|
|
32
|
+
def cmd(name, desc = nil)
|
|
33
|
+
name = name.to_s
|
|
34
|
+
raise ArgumentError, "duplicate command #{name}" if commands.key?(name)
|
|
35
|
+
Config.new(name:).tap do
|
|
36
|
+
_1.desc, _1.root = desc, self
|
|
37
|
+
yield _1 if block_given?
|
|
38
|
+
raise ArgumentError, "nested commands are not supported" if _1.commands.any?
|
|
39
|
+
commands[name] = _1
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
29
43
|
# Add separator text at the current point in generated help.
|
|
30
44
|
def sep(text = "")
|
|
31
45
|
[flags.length, text].tap do
|
|
@@ -62,7 +76,9 @@ module RunKit
|
|
|
62
76
|
end
|
|
63
77
|
|
|
64
78
|
# long-form aliases
|
|
79
|
+
alias_method :app_name=, :name=
|
|
65
80
|
alias_method :boolean, :bool
|
|
81
|
+
alias_method :command, :cmd
|
|
66
82
|
alias_method :integer, :int
|
|
67
83
|
alias_method :pathname, :path
|
|
68
84
|
alias_method :positional, :pos
|
|
@@ -81,27 +97,38 @@ module RunKit
|
|
|
81
97
|
# one-liners
|
|
82
98
|
def flag(switch) = lookup[switch]
|
|
83
99
|
def flag?(switch) = lookup.key?(switch)
|
|
100
|
+
def full_name = root ? "#{root.full_name} #{name}" : name
|
|
84
101
|
def key?(key) = lookup.key?(key)
|
|
85
102
|
def required = flags.select(&:required?)
|
|
86
103
|
|
|
104
|
+
# memoized accessors
|
|
105
|
+
def commands = @commands ||= {}
|
|
106
|
+
def flags = @flags ||= []
|
|
107
|
+
def lookup = @lookup ||= {}
|
|
108
|
+
def positionals = @positionals ||= []
|
|
109
|
+
def separators = @separators ||= []
|
|
110
|
+
|
|
87
111
|
# Complete one-time setup after the caller has declared overrides.
|
|
88
112
|
def prepare!
|
|
89
113
|
return if @prepared
|
|
90
114
|
@prepared = true
|
|
115
|
+
|
|
116
|
+
# Children inherit shared settings before adding builtins.
|
|
117
|
+
if root
|
|
118
|
+
self.color, self.exit, self.version = root.color, root.exit, root.version
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# now defaults
|
|
91
122
|
@exit ||= lambda { |status| Kernel.exit(status) }
|
|
92
|
-
@help_flag =
|
|
93
|
-
@version_flag =
|
|
123
|
+
@help_flag = bool("-h", "--help", "Show this message")
|
|
124
|
+
@version_flag = bool("-v", "--version", "Show version") if version
|
|
125
|
+
|
|
126
|
+
# setup subcommands
|
|
127
|
+
commands.each_value(&:prepare!)
|
|
94
128
|
end
|
|
95
129
|
|
|
96
130
|
protected
|
|
97
131
|
|
|
98
|
-
# Add help/version flags, but only for switches the user did not override.
|
|
99
|
-
def add_builtin(switches, help_text)
|
|
100
|
-
unused = switches.select { !flag?(_1) }
|
|
101
|
-
return if unused.empty?
|
|
102
|
-
add_flag(Flag.new(:bool, unused + [help_text]))
|
|
103
|
-
end
|
|
104
|
-
|
|
105
132
|
def add_flag(flag)
|
|
106
133
|
# dup check
|
|
107
134
|
raise ArgumentError, "reserved flag key: _args" if flag.key == :_args
|
data/lib/run_kit/options/help.rb
CHANGED
|
@@ -18,65 +18,88 @@ module RunKit
|
|
|
18
18
|
# Render generated help, unless the caller supplied complete help text.
|
|
19
19
|
def to_s
|
|
20
20
|
return config.help if config.help
|
|
21
|
+
help = [].tap do
|
|
22
|
+
_1 << desc if config.desc
|
|
23
|
+
_1 << banner
|
|
24
|
+
_1 << commands_text if config.commands.any?
|
|
25
|
+
_1 << flags_text(config, "Options:", builtins: !config.root)
|
|
26
|
+
_1 << flags_text(config.root, "Other options:") if config.root
|
|
27
|
+
end.compact.join("\n\n")
|
|
28
|
+
"#{help}\n"
|
|
29
|
+
end
|
|
21
30
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
31
|
+
# Render a heading and aligned flags, preserving explicit separator lines.
|
|
32
|
+
def flags_text(source, title, builtins: true)
|
|
33
|
+
# gather flags
|
|
34
|
+
flags = source.flags
|
|
35
|
+
flags -= [source.help_flag, source.version_flag] unless builtins
|
|
36
|
+
return if flags.empty? && source.separators.empty?
|
|
27
37
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
38
|
+
[].tap do |lines|
|
|
39
|
+
lines << color.blue(title)
|
|
40
|
+
label_width = flags.map { Term.width(flag_label(_1)) }.max
|
|
41
|
+
flags.each.with_index do |flag, idx|
|
|
42
|
+
lines.concat(separator_lines(source, idx))
|
|
43
|
+
buf = StringIO.new
|
|
32
44
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
45
|
+
# left
|
|
46
|
+
label = flag_label(flag)
|
|
47
|
+
buf << " " * INDENT
|
|
48
|
+
buf << label
|
|
37
49
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
# right
|
|
51
|
+
help = flag.help
|
|
52
|
+
if flag.env
|
|
53
|
+
env_help = "[env: #{flag.env}]"
|
|
54
|
+
help = help ? "#{help} #{env_help}" : env_help
|
|
55
|
+
end
|
|
56
|
+
if help
|
|
57
|
+
buf << " " * (label_width - Term.width(label) + 2)
|
|
58
|
+
indent = INDENT + label_width + 2
|
|
59
|
+
buf << Term.wrap(help, width - indent).gsub("\n", "\n#{" " * indent}")
|
|
60
|
+
end
|
|
61
|
+
lines << buf.string
|
|
48
62
|
end
|
|
49
|
-
|
|
50
|
-
end
|
|
51
|
-
buf << separator_text(config.flags.length)
|
|
52
|
-
|
|
53
|
-
buf.string
|
|
63
|
+
lines.concat(separator_lines(source, flags.length))
|
|
64
|
+
end.join("\n")
|
|
54
65
|
end
|
|
55
66
|
|
|
56
|
-
# Build the usage line from the
|
|
67
|
+
# Build the usage line from the command's full name and positionals.
|
|
57
68
|
def banner
|
|
58
69
|
text = config.banner
|
|
59
|
-
text ||= [color.blue("Usage:"), color.green(config.
|
|
70
|
+
text ||= [color.blue("Usage:"), color.green(config.full_name), "[options]"].tap do
|
|
60
71
|
_1.push(*config.positionals.map(&:meta))
|
|
72
|
+
_1.push(color.yellow("<command>")) if config.commands.any?
|
|
61
73
|
end.join(" ")
|
|
62
74
|
Term.wrap(text, width)
|
|
63
75
|
end
|
|
64
76
|
|
|
65
|
-
# Render
|
|
66
|
-
def
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
77
|
+
# Render the list of subcommands, aligned like the flag list above.
|
|
78
|
+
def commands_text
|
|
79
|
+
[].tap do |lines|
|
|
80
|
+
lines << color.blue("Commands:")
|
|
81
|
+
label_width = config.commands.keys.map { Term.width(_1) }.max
|
|
82
|
+
config.commands.each do |name, child|
|
|
83
|
+
lines << StringIO.new.tap do |buf|
|
|
84
|
+
buf << " " * INDENT << color.green(name)
|
|
85
|
+
if child.desc
|
|
86
|
+
buf << " " * (label_width - Term.width(name) + 2)
|
|
87
|
+
indent = INDENT + label_width + 2
|
|
88
|
+
buf << Term.wrap(child.desc, width - indent).gsub("\n", "\n#{" " * indent}")
|
|
89
|
+
end
|
|
90
|
+
end.string
|
|
73
91
|
end
|
|
74
|
-
end.
|
|
92
|
+
end.join("\n")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Keep blank and multiline separators exactly as supplied.
|
|
96
|
+
def separator_lines(source, position)
|
|
97
|
+
source.separators.filter_map { |pos, str| color.blue(str) if pos == position }
|
|
75
98
|
end
|
|
76
99
|
|
|
77
100
|
# one-liners
|
|
78
101
|
def color = @color ||= Color.new(config.color)
|
|
79
|
-
def
|
|
102
|
+
def desc = Term.wrap(config.desc, width)
|
|
80
103
|
|
|
81
104
|
protected
|
|
82
105
|
|
data/lib/run_kit/options/main.rb
CHANGED
|
@@ -16,67 +16,125 @@
|
|
|
16
16
|
# | meta | placeholder shown for a param | <xxx> from `--port <xxx>` |
|
|
17
17
|
# | positional | configured required param slot | o.positional "<url>" |
|
|
18
18
|
# | separator | help section heading | "Network:" |
|
|
19
|
+
# | command | subcommand with its own Config | o.cmd "build" { ... } |
|
|
19
20
|
#
|
|
20
21
|
|
|
21
22
|
module RunKit
|
|
22
23
|
module Options
|
|
23
24
|
class Main
|
|
24
|
-
|
|
25
|
+
# root is the top-level config; ctx is the active parser or validator's config.
|
|
26
|
+
attr_reader :ctx, :root
|
|
25
27
|
|
|
26
|
-
def initialize = @
|
|
27
|
-
def app_name = config.app_name
|
|
28
|
+
def initialize = @root = Config.new
|
|
28
29
|
|
|
29
30
|
# Parse argv and turn internal parser outcomes into CLI behavior.
|
|
30
31
|
def parse(argv)
|
|
31
|
-
|
|
32
|
+
@ctx = root
|
|
33
|
+
root.prepare!
|
|
34
|
+
|
|
35
|
+
# handle --help and --version
|
|
36
|
+
return exit_fn(0) if early_exit?(argv)
|
|
32
37
|
|
|
33
38
|
begin
|
|
34
|
-
options =
|
|
39
|
+
options = root.commands.empty? ? parse_with_ctx(root, argv) : subcommand(argv)
|
|
35
40
|
klass = Data.define(*options.keys)
|
|
36
|
-
klass.new(**options)
|
|
37
|
-
rescue Error => ex
|
|
38
|
-
|
|
39
|
-
warn "#{app_name}: try '#{app_name} --help' for more information"
|
|
40
|
-
exit_fn(1, error: ex.message)
|
|
41
|
-
rescue HelpRequested, NakedRequested, VersionRequested => ex
|
|
42
|
-
early_exit(ex)
|
|
43
|
-
exit_fn(0)
|
|
41
|
+
klass.new(**options).tap { validate(_1) }
|
|
42
|
+
rescue Error, NakedRequested => ex
|
|
43
|
+
handle_error(ex)
|
|
44
44
|
end
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
protected
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
49
|
+
# Handle --help or --version
|
|
50
|
+
def early_exit?(argv)
|
|
51
|
+
if argv.include?("--help") || argv.include?("-h")
|
|
52
|
+
selected = root.commands[argv.first] || root
|
|
53
|
+
puts Help.new(selected)
|
|
54
|
+
return true
|
|
55
|
+
end
|
|
56
|
+
if root.version && (argv.include?("--version") || argv.include?("-v"))
|
|
57
|
+
puts "#{root.name} #{root.version}"
|
|
58
|
+
return true
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Peek at the first bare argument to pick a subcommand, then parse the
|
|
63
|
+
# rest with its own Config and merge the two option hashes together.
|
|
64
|
+
def subcommand(argv)
|
|
65
|
+
# Parse root options before the subcommand.
|
|
66
|
+
root_options = parse_with_ctx(root, argv, passthru: true)
|
|
67
|
+
name, *rest = root_options[:_args]
|
|
68
|
+
raise NakedRequested if !name
|
|
69
|
+
|
|
70
|
+
# Find and parse the child.
|
|
71
|
+
child = root.commands[name]
|
|
72
|
+
raise Error, "unknown command '#{name}'" if !child
|
|
73
|
+
child_options = parse_with_ctx(child, rest)
|
|
74
|
+
|
|
75
|
+
# merge
|
|
76
|
+
root_options.merge(child_options).merge(command: name)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Validate the final options, root first.
|
|
80
|
+
def validate(options)
|
|
81
|
+
[ctx.root, ctx].compact.each do
|
|
82
|
+
validate_with_ctx(_1, options)
|
|
83
|
+
rescue RuntimeError => ex
|
|
84
|
+
raise Error, ex.message
|
|
57
85
|
end
|
|
58
86
|
end
|
|
59
87
|
|
|
88
|
+
# Render the outcome using the active context.
|
|
89
|
+
def handle_error(ex)
|
|
90
|
+
if ex.is_a?(Error)
|
|
91
|
+
warn "#{ctx.full_name}: #{ex.message}"
|
|
92
|
+
warn "#{ctx.full_name}: try '#{ctx.full_name} --help' for more information"
|
|
93
|
+
return exit_fn(1, error: ex.message)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
puts Help.new(ctx)
|
|
97
|
+
exit_fn(0)
|
|
98
|
+
end
|
|
99
|
+
|
|
60
100
|
def exit_fn(status, error: nil)
|
|
61
101
|
args = [].tap do
|
|
62
102
|
_1 << status
|
|
63
|
-
_1 << error if
|
|
103
|
+
_1 << error if root.exit.arity == 2
|
|
64
104
|
end
|
|
65
|
-
|
|
105
|
+
root.exit.call(*args)
|
|
66
106
|
nil
|
|
67
107
|
end
|
|
108
|
+
|
|
109
|
+
#
|
|
110
|
+
# Keep the active config after a failure so the outer rescue can use it.
|
|
111
|
+
# This is error context, not a push/pop command stack.
|
|
112
|
+
#
|
|
113
|
+
|
|
114
|
+
def with_ctx(ctx)
|
|
115
|
+
@ctx = ctx
|
|
116
|
+
yield
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def parse_with_ctx(ctx, argv, passthru: false)
|
|
120
|
+
with_ctx(ctx) do
|
|
121
|
+
Parser.new(ctx).parse(argv, passthru:)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def validate_with_ctx(ctx, options)
|
|
126
|
+
with_ctx(ctx) do
|
|
127
|
+
ctx.validate&.call(options)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
68
130
|
end
|
|
69
131
|
|
|
70
132
|
class Error < StandardError; end
|
|
71
|
-
|
|
72
|
-
# early exits
|
|
73
|
-
class HelpRequested < Exception; end # rubocop:disable Lint/InheritException
|
|
74
133
|
class NakedRequested < Exception; end # rubocop:disable Lint/InheritException
|
|
75
|
-
class VersionRequested < Exception; end # rubocop:disable Lint/InheritException
|
|
76
134
|
|
|
77
135
|
# main entry point
|
|
78
136
|
def self.parse(argv = ARGV)
|
|
79
|
-
Main.new.tap { yield _1.
|
|
137
|
+
Main.new.tap { yield _1.root if block_given? }.parse(argv)
|
|
80
138
|
end
|
|
81
139
|
end
|
|
82
140
|
end
|
|
@@ -6,20 +6,32 @@
|
|
|
6
6
|
module RunKit
|
|
7
7
|
module Options
|
|
8
8
|
class Parser
|
|
9
|
-
attr_reader :config
|
|
9
|
+
attr_reader :config
|
|
10
10
|
|
|
11
11
|
def initialize(config)
|
|
12
12
|
@config = config
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
# Reset transient state, parse argv, and assemble the result.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
# Reset transient state, parse argv, and assemble the result. When
|
|
16
|
+
# passthru is set, scanning halts at the first bare arg (for subcommands).
|
|
17
|
+
def parse(argv, passthru: false)
|
|
18
|
+
# 1. naked?
|
|
19
|
+
raise NakedRequested if config.naked? && argv.empty?
|
|
20
|
+
|
|
21
|
+
# 2. Parse argv.
|
|
22
|
+
argv_options = parse_argv(argv, passthru:)
|
|
23
|
+
|
|
24
|
+
# 3. defaults => ENV => ARGV
|
|
25
|
+
options = {}.merge(config.defaults, parse_env, argv_options)
|
|
26
|
+
|
|
27
|
+
# 4. validate final options
|
|
28
|
+
validate!(options)
|
|
29
|
+
|
|
30
|
+
# success! add predicate? keys
|
|
31
|
+
config.flags.select(&:bool?).map(&:key).each do
|
|
32
|
+
options[:"#{_1}?"] = options[_1] if options.key?(_1)
|
|
33
|
+
end
|
|
19
34
|
|
|
20
|
-
@options, @queue = config.defaults.merge(env), argv.dup
|
|
21
|
-
parse_queue
|
|
22
|
-
validate!
|
|
23
35
|
options
|
|
24
36
|
end
|
|
25
37
|
|
|
@@ -29,57 +41,55 @@ module RunKit
|
|
|
29
41
|
# main parser
|
|
30
42
|
#
|
|
31
43
|
|
|
32
|
-
def
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
def parse_argv(argv, passthru: false)
|
|
45
|
+
{}.tap do |result|
|
|
46
|
+
# any non-flags we find below
|
|
47
|
+
operands = []
|
|
48
|
+
|
|
49
|
+
# process argv as queue
|
|
50
|
+
queue = argv.dup
|
|
51
|
+
while (item = queue.shift)
|
|
52
|
+
case item
|
|
53
|
+
when Flag::SWITCH_RE, Flag::INLINE_RE then result.merge!(parse_switch(item, Regexp.last_match, queue))
|
|
54
|
+
when /\A-[^-]/ then result.merge!(parse_smashed(item, queue))
|
|
55
|
+
when "", /\A[^-]/
|
|
56
|
+
operands << item
|
|
57
|
+
if passthru
|
|
58
|
+
operands.concat(queue)
|
|
59
|
+
break
|
|
60
|
+
end
|
|
61
|
+
when "--" then break operands.concat(queue)
|
|
62
|
+
else; raise Error, "unexpected argument '#{item}' found"
|
|
63
|
+
end
|
|
44
64
|
end
|
|
45
|
-
end
|
|
46
65
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
options[:"#{_1}?"] = options[_1] if options.key?(_1)
|
|
50
|
-
end
|
|
66
|
+
# positionals
|
|
67
|
+
config.positionals.each { result[_1.key] = operands.shift }
|
|
51
68
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
options[_1.key] = operands.shift
|
|
69
|
+
# _args
|
|
70
|
+
result[:_args] = operands
|
|
55
71
|
end
|
|
56
|
-
|
|
57
|
-
# _args
|
|
58
|
-
options[:_args] = operands
|
|
59
72
|
end
|
|
60
73
|
|
|
61
74
|
#
|
|
62
75
|
# -x or -x=123 or --xyz or --xyz=123 or --no-xyz
|
|
63
76
|
#
|
|
64
77
|
|
|
65
|
-
def parse_switch(item, match)
|
|
78
|
+
def parse_switch(item, match, queue)
|
|
66
79
|
switch = "-#{match[1]}"
|
|
67
80
|
param = match[2]
|
|
68
81
|
separator = param ? "=" : ""
|
|
69
82
|
|
|
70
83
|
# -x or --xyz?
|
|
71
84
|
if (flag = config.flag(switch))
|
|
72
|
-
builtin!(flag)
|
|
73
85
|
param = queue.shift if flag.takes_param? && separator.empty?
|
|
74
|
-
|
|
75
|
-
return
|
|
86
|
+
return {flag.key => flag.parse(switch, param)}
|
|
76
87
|
end
|
|
77
88
|
|
|
78
89
|
# --no-xyz?
|
|
79
90
|
if (neg = find_negated_flag(switch))
|
|
80
91
|
raise Error, "option '#{item}' does not take a value" if separator == "="
|
|
81
|
-
|
|
82
|
-
return
|
|
92
|
+
return {neg.key => false}
|
|
83
93
|
end
|
|
84
94
|
|
|
85
95
|
raise Error, "unexpected argument '#{item}' found"
|
|
@@ -91,12 +101,12 @@ module RunKit
|
|
|
91
101
|
|
|
92
102
|
# Expand short-switch groups such as `-qv`. A parameter-taking switch ends
|
|
93
103
|
# the group and consumes either its attached suffix or the next queue item.
|
|
94
|
-
def parse_smashed(group)
|
|
104
|
+
def parse_smashed(group, queue)
|
|
105
|
+
result = {}
|
|
95
106
|
(1...group.length).each do |idx|
|
|
96
107
|
switch = "-#{group[idx]}"
|
|
97
108
|
flag = config.flag(switch)
|
|
98
109
|
raise Error, "unexpected argument '#{group}' found" unless flag
|
|
99
|
-
builtin!(flag)
|
|
100
110
|
|
|
101
111
|
# For `-qnLee`, `Lee` belongs to `-n`; for `-qn Lee`, shift the queue.
|
|
102
112
|
if flag.takes_param?
|
|
@@ -105,30 +115,29 @@ module RunKit
|
|
|
105
115
|
else
|
|
106
116
|
queue.shift
|
|
107
117
|
end
|
|
108
|
-
|
|
109
|
-
return
|
|
118
|
+
result[flag.key] = flag.parse(switch, param)
|
|
119
|
+
return result
|
|
110
120
|
end
|
|
111
121
|
|
|
112
122
|
# bool
|
|
113
|
-
|
|
123
|
+
result[flag.key] = true
|
|
114
124
|
end
|
|
125
|
+
result
|
|
115
126
|
end
|
|
116
127
|
|
|
117
128
|
#
|
|
118
|
-
#
|
|
129
|
+
# env
|
|
119
130
|
#
|
|
120
131
|
|
|
121
|
-
def
|
|
122
|
-
config.flags.
|
|
123
|
-
|
|
124
|
-
[_1.key, _1.parse_env(ENV.fetch(_1.env))]
|
|
132
|
+
def parse_env
|
|
133
|
+
config.flags.select { _1.env && ENV.key?(_1.env) }.map do |flag|
|
|
134
|
+
[flag.key, flag.parse_env(ENV[flag.env])]
|
|
125
135
|
end.to_h
|
|
126
136
|
end
|
|
127
137
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
end
|
|
138
|
+
#
|
|
139
|
+
# helpers
|
|
140
|
+
#
|
|
132
141
|
|
|
133
142
|
def find_negated_flag(switch)
|
|
134
143
|
if (m = Flag::NEGATE_RE.match(switch))
|
|
@@ -137,7 +146,7 @@ module RunKit
|
|
|
137
146
|
end
|
|
138
147
|
end
|
|
139
148
|
|
|
140
|
-
def validate!
|
|
149
|
+
def validate!(options)
|
|
141
150
|
config.required.each do
|
|
142
151
|
raise Error, "required option '#{_1.switch}' is missing" if !options.key?(_1.key)
|
|
143
152
|
end
|
data/lib/run_kit/shell.rb
CHANGED
|
@@ -29,9 +29,9 @@ module RunKit
|
|
|
29
29
|
# json file read/write, including gz
|
|
30
30
|
#
|
|
31
31
|
|
|
32
|
-
def json_read(path, symbolize_names: true) = JSON.parse(file_read(path), symbolize_names:)
|
|
32
|
+
def json_read(path, symbolize_names: true) = JSON.parse(file_read(path), allow_comments: true, symbolize_names:)
|
|
33
33
|
def json_write(path, json) = file_write(path, JSON.pretty_generate(json))
|
|
34
|
-
def jsonl_read(path, symbolize_names: true) = file_read(path).split("\n").map { JSON.parse(_1, symbolize_names:) }
|
|
34
|
+
def jsonl_read(path, symbolize_names: true) = file_read(path).split("\n").map { JSON.parse(_1, allow_comments: true, symbolize_names:) }
|
|
35
35
|
def jsonl_write(path, json) = file_write(path, json.map { JSON.generate(_1) }.join("\n"))
|
|
36
36
|
|
|
37
37
|
#
|
|
@@ -262,8 +262,8 @@ module RunKit
|
|
|
262
262
|
data = gunzip(data) if compress
|
|
263
263
|
case format
|
|
264
264
|
when :bin then data.force_encoding("ascii-8bit")
|
|
265
|
-
when :json then JSON.parse(data)
|
|
266
|
-
when :jsonl then data.split("\n").map { JSON.parse(_1) }
|
|
265
|
+
when :json then JSON.parse(data, allow_comments: true)
|
|
266
|
+
when :jsonl then data.split("\n").map { JSON.parse(_1, allow_comments: true) }
|
|
267
267
|
when :marshal then Marshal.load(data)
|
|
268
268
|
when :str, :string then data.force_encoding("utf-8")
|
|
269
269
|
else; raise "unknown format #{format.inspect}"
|
data/lib/run_kit.rb
CHANGED
|
@@ -16,7 +16,14 @@ require_relative "run_kit/term"
|
|
|
16
16
|
require_relative "run_kit/options"
|
|
17
17
|
require_relative "run_kit/shell"
|
|
18
18
|
|
|
19
|
-
# handy entry point for RunKit::Options
|
|
20
19
|
module RunKit
|
|
20
|
+
PROGRESSBAR = {
|
|
21
|
+
format: "%t: %j%% %B #{Term.paint_ansi("%c/%u %e", Term.ansi256_fg(242))}",
|
|
22
|
+
progress_mark: Term.paint_ansi("━", Term.ansi256_fg(46)),
|
|
23
|
+
remainder_mark: Term.paint_ansi("━", Term.ansi256_fg(237)),
|
|
24
|
+
length: 72,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# handy entry point for RunKit::Options
|
|
21
28
|
def self.parse(...) = Options.parse(...)
|
|
22
29
|
end
|
data/run_kit.gemspec
CHANGED