run_kit 0.1.0
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 +7 -0
- data/.github/workflows/ci.yml +27 -0
- data/.gitignore +3 -0
- data/.justfile +60 -0
- data/.mise.toml +11 -0
- data/.rubocop.yml +61 -0
- data/AGENTS.md +29 -0
- data/Gemfile +13 -0
- data/README.md +107 -0
- data/Rakefile +14 -0
- data/demo.rb +11 -0
- data/lib/run_kit/core_ext.rb +116 -0
- data/lib/run_kit/options/color.rb +30 -0
- data/lib/run_kit/options/config.rb +123 -0
- data/lib/run_kit/options/flag.rb +135 -0
- data/lib/run_kit/options/help.rb +85 -0
- data/lib/run_kit/options/main.rb +82 -0
- data/lib/run_kit/options/parser.rb +142 -0
- data/lib/run_kit/options/positional.rb +23 -0
- data/lib/run_kit/options.rb +7 -0
- data/lib/run_kit/shell.rb +307 -0
- data/lib/run_kit/term.rb +201 -0
- data/lib/run_kit.rb +22 -0
- data/prek.toml +46 -0
- data/run_kit.gemspec +23 -0
- metadata +93 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#
|
|
2
|
+
# One configured flag, including all switches that invoke it.
|
|
3
|
+
#
|
|
4
|
+
|
|
5
|
+
module RunKit
|
|
6
|
+
module Options
|
|
7
|
+
class Flag
|
|
8
|
+
# --foo or -f
|
|
9
|
+
SWITCH_RE = /\A-(\w|-\w[\w-]*)\z/
|
|
10
|
+
# --foo=bar
|
|
11
|
+
INLINE_RE = /\A-(\w|-\w[\w-]*)=(.*)\z/m
|
|
12
|
+
# --no-foo
|
|
13
|
+
NEGATE_RE = /\A--no-(\w[\w-]*)\z/
|
|
14
|
+
|
|
15
|
+
KINDS = %i[bool float int path str sym]
|
|
16
|
+
|
|
17
|
+
attr_reader :choices, :default, :help, :kind, :meta, :required, :switches
|
|
18
|
+
|
|
19
|
+
# ctor
|
|
20
|
+
def initialize(kind, opts, default: nil, required: false, choices: nil)
|
|
21
|
+
@choices, @kind, @required = choices, kind, required
|
|
22
|
+
|
|
23
|
+
# extract @help from last string
|
|
24
|
+
@switches = opts.dup
|
|
25
|
+
@help = if switch && !switch.start_with?("-")
|
|
26
|
+
switches.pop
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Extract meta from the final switch, if written as `--port <int>`.
|
|
30
|
+
@meta = build_meta
|
|
31
|
+
|
|
32
|
+
# default, with some special handling for bool
|
|
33
|
+
@default = default
|
|
34
|
+
if bool? && default.nil? && !required?
|
|
35
|
+
@default = false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
validate
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
#
|
|
42
|
+
# parsing
|
|
43
|
+
#
|
|
44
|
+
|
|
45
|
+
# Parse a single cli param
|
|
46
|
+
def parse(switch, param)
|
|
47
|
+
if bool?
|
|
48
|
+
raise Error, "option '#{switch}=#{param}' does not take a value" if param
|
|
49
|
+
return true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
raise Error, "option '#{switch}' requires a value" if !param
|
|
53
|
+
parsed = begin
|
|
54
|
+
case kind
|
|
55
|
+
when :float then Float(param)
|
|
56
|
+
when :int then Integer(param, 10)
|
|
57
|
+
when :path then Pathname.new(param)
|
|
58
|
+
when :str then param
|
|
59
|
+
when :sym then param.to_sym
|
|
60
|
+
end
|
|
61
|
+
rescue ArgumentError
|
|
62
|
+
raise Error, "invalid value '#{param}' for option '#{switch}'"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if choices && !choices.include?(parsed)
|
|
66
|
+
raise Error, "invalid value '#{parsed}' for option '#{switch}', must be one of #{choices.join(", ")}"
|
|
67
|
+
end
|
|
68
|
+
parsed
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# one-liners
|
|
72
|
+
def bool? = kind == :bool
|
|
73
|
+
def key = @key ||= switch.sub(/^-+/, "").tr("-", "_").to_sym
|
|
74
|
+
def switch = switches.last
|
|
75
|
+
def takes_param? = !bool?
|
|
76
|
+
alias_method :required?, :required
|
|
77
|
+
|
|
78
|
+
protected
|
|
79
|
+
|
|
80
|
+
#
|
|
81
|
+
# validation
|
|
82
|
+
#
|
|
83
|
+
|
|
84
|
+
def validate
|
|
85
|
+
# switches
|
|
86
|
+
raise ArgumentError, "at least one switch is required" if switches.empty?
|
|
87
|
+
switches.each do
|
|
88
|
+
raise ArgumentError, "invalid switch: #{_1}" unless _1.is_a?(String)
|
|
89
|
+
raise ArgumentError, "invalid switch: #{_1}" unless _1.match?(SWITCH_RE)
|
|
90
|
+
end
|
|
91
|
+
raise ArgumentError, "duplicate switch" unless switches.uniq.length == switches.length
|
|
92
|
+
|
|
93
|
+
# params
|
|
94
|
+
raise ArgumentError, "invalid flag kind: #{kind}" unless KINDS.include?(kind)
|
|
95
|
+
raise ArgumentError, "boolean flags do not accept meta" if bool? && meta
|
|
96
|
+
raise ArgumentError, "required must be true or false" unless required == true || required == false
|
|
97
|
+
raise ArgumentError, "required flags cannot have defaults" if required && default != nil
|
|
98
|
+
raise ArgumentError, "invalid default #{default.inspect} for #{kind}" if default != nil && !allowed?(default)
|
|
99
|
+
|
|
100
|
+
# choices
|
|
101
|
+
if choices
|
|
102
|
+
raise ArgumentError, "choices must be an array" unless choices.is_a?(Array)
|
|
103
|
+
raise ArgumentError, "choices cannot be empty" if choices.empty?
|
|
104
|
+
choices.each do
|
|
105
|
+
raise ArgumentError, "invalid choice #{_1.inspect} for #{kind}" unless allowed?(_1)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
#
|
|
111
|
+
# helpers
|
|
112
|
+
#
|
|
113
|
+
|
|
114
|
+
def build_meta
|
|
115
|
+
# Only the final spelling may carry an inferred `<meta>`.
|
|
116
|
+
if (m = /\A(\S+) <([^>]+)>\z/.match(switch))
|
|
117
|
+
switches[switches.length - 1] = m[1]
|
|
118
|
+
return m[2]
|
|
119
|
+
end
|
|
120
|
+
kind.to_s unless bool?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def allowed?(candidate)
|
|
124
|
+
case kind
|
|
125
|
+
when :bool then candidate == true || candidate == false
|
|
126
|
+
when :float then candidate.is_a?(Float)
|
|
127
|
+
when :int then candidate.is_a?(Integer)
|
|
128
|
+
when :path then candidate.is_a?(Pathname)
|
|
129
|
+
when :str then candidate.is_a?(String)
|
|
130
|
+
when :sym then candidate.is_a?(Symbol)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Renders the generated help text. Most of the fiddly work here is keeping
|
|
3
|
+
# columns aligned while ANSI color is present.
|
|
4
|
+
#
|
|
5
|
+
|
|
6
|
+
module RunKit
|
|
7
|
+
module Options
|
|
8
|
+
class Help
|
|
9
|
+
INDENT = 2
|
|
10
|
+
|
|
11
|
+
attr_reader :config, :width
|
|
12
|
+
|
|
13
|
+
def initialize(config, width = nil)
|
|
14
|
+
@config = config
|
|
15
|
+
@width = (width || Term.winsize[1]).clamp(60, 100)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Render generated help, unless the caller supplied complete help text.
|
|
19
|
+
def to_s
|
|
20
|
+
return config.help if config.help
|
|
21
|
+
|
|
22
|
+
# usage: xyz (banner)
|
|
23
|
+
buf = StringIO.new
|
|
24
|
+
buf << banner
|
|
25
|
+
buf << "\n"
|
|
26
|
+
buf << "\n" if config.separators.any? { _1.first.zero? }
|
|
27
|
+
|
|
28
|
+
# Render each flag with aligned switch labels and wrapped help text.
|
|
29
|
+
label_width = widest_label
|
|
30
|
+
config.flags.each.with_index do |flag, idx|
|
|
31
|
+
buf << separator_text(idx) # sep
|
|
32
|
+
|
|
33
|
+
# left
|
|
34
|
+
label = flag_label(flag)
|
|
35
|
+
buf << " " * INDENT
|
|
36
|
+
buf << label
|
|
37
|
+
|
|
38
|
+
# right
|
|
39
|
+
if flag.help
|
|
40
|
+
buf << " " * (label_width - Term.width(label) + 2)
|
|
41
|
+
indent = INDENT + label_width + 2
|
|
42
|
+
buf << Term.wrap(flag.help, width - indent).gsub("\n", "\n#{" " * indent}")
|
|
43
|
+
end
|
|
44
|
+
buf << "\n"
|
|
45
|
+
end
|
|
46
|
+
buf << separator_text(config.flags.length)
|
|
47
|
+
|
|
48
|
+
buf.string
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Build the usage line from the configured app name and positionals.
|
|
52
|
+
def banner
|
|
53
|
+
text = config.banner
|
|
54
|
+
text ||= [color.blue("Usage:"), color.green(config.app_name), "[options]"].tap do
|
|
55
|
+
_1.push(*config.positionals.map(&:meta))
|
|
56
|
+
end.join(" ")
|
|
57
|
+
Term.wrap(text, width)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Render separator text at its recorded position between flags.
|
|
61
|
+
def separator_text(position)
|
|
62
|
+
StringIO.new.tap do |buf|
|
|
63
|
+
config.separators.each do |(pos, str)|
|
|
64
|
+
if pos == position
|
|
65
|
+
buf << color.blue(str)
|
|
66
|
+
buf << "\n"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end.string
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# one-liners
|
|
73
|
+
def color = @color ||= Color.new(config.color)
|
|
74
|
+
def widest_label = config.flags.map { Term.width(flag_label(_1)) }.max
|
|
75
|
+
|
|
76
|
+
protected
|
|
77
|
+
|
|
78
|
+
def flag_label(flag)
|
|
79
|
+
label = flag.switches.map { color.green(_1) }.join(", ")
|
|
80
|
+
return label unless flag.takes_param?
|
|
81
|
+
"#{label} #{color.yellow("<#{flag.meta}>")}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Main entry point and parsing
|
|
3
|
+
#
|
|
4
|
+
#
|
|
5
|
+
# Glossary
|
|
6
|
+
#
|
|
7
|
+
# | term | meaning | example |
|
|
8
|
+
# |------------|------------------------------------|---------------------------|
|
|
9
|
+
# | flag | configured cli flag | o.int "-p", "--port" |
|
|
10
|
+
# | switch | dashed string invoking a flag | -p or --port |
|
|
11
|
+
# | key | result field derived from a switch | --http-port => :http_port |
|
|
12
|
+
# | param | raw input consumed by a flag | 8080 from --port=8080 |
|
|
13
|
+
# | - | - | - |
|
|
14
|
+
# | app_name | program name used in output | "curl" |
|
|
15
|
+
# | banner | usage text at top of help | Usage: curl [options] |
|
|
16
|
+
# | meta | placeholder shown for a param | <xxx> from `--port <xxx>` |
|
|
17
|
+
# | positional | configured required param slot | o.positional "<url>" |
|
|
18
|
+
# | separator | help section heading | "Network:" |
|
|
19
|
+
#
|
|
20
|
+
|
|
21
|
+
module RunKit
|
|
22
|
+
module Options
|
|
23
|
+
class Main
|
|
24
|
+
attr_reader :config
|
|
25
|
+
|
|
26
|
+
def initialize = @config = Config.new
|
|
27
|
+
def app_name = config.app_name
|
|
28
|
+
|
|
29
|
+
# Parse argv and turn internal parser outcomes into CLI behavior.
|
|
30
|
+
def parse(argv)
|
|
31
|
+
config.prepare!
|
|
32
|
+
|
|
33
|
+
begin
|
|
34
|
+
options = Parser.new(config).parse(argv)
|
|
35
|
+
klass = Data.define(*options.keys)
|
|
36
|
+
klass.new(**options)
|
|
37
|
+
rescue Error => ex
|
|
38
|
+
warn "#{app_name}: #{ex.message}"
|
|
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)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
protected
|
|
48
|
+
|
|
49
|
+
def early_exit(ex)
|
|
50
|
+
case ex
|
|
51
|
+
when HelpRequested
|
|
52
|
+
puts Help.new(config)
|
|
53
|
+
when NakedRequested
|
|
54
|
+
puts "#{app_name}: try '#{app_name} --help' for more information"
|
|
55
|
+
when VersionRequested
|
|
56
|
+
puts "#{app_name} #{config.version}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def exit_fn(status, error: nil)
|
|
61
|
+
args = [].tap do
|
|
62
|
+
_1 << status
|
|
63
|
+
_1 << error if config.exit.arity == 2
|
|
64
|
+
end
|
|
65
|
+
config.exit.call(*args)
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class Error < StandardError; end
|
|
71
|
+
|
|
72
|
+
# early exits
|
|
73
|
+
class HelpRequested < Exception; end # rubocop:disable Lint/InheritException
|
|
74
|
+
class NakedRequested < Exception; end # rubocop:disable Lint/InheritException
|
|
75
|
+
class VersionRequested < Exception; end # rubocop:disable Lint/InheritException
|
|
76
|
+
|
|
77
|
+
# main entry point
|
|
78
|
+
def self.parse(argv = ARGV)
|
|
79
|
+
Main.new.tap { yield _1.config if block_given? }.parse(argv)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Turns argv into a typed result. It knows the CLI grammar but stays away from
|
|
3
|
+
# printing and exiting.
|
|
4
|
+
#
|
|
5
|
+
|
|
6
|
+
module RunKit
|
|
7
|
+
module Options
|
|
8
|
+
class Parser
|
|
9
|
+
attr_reader :config, :options, :queue
|
|
10
|
+
|
|
11
|
+
def initialize(config)
|
|
12
|
+
@config = config
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Reset transient state, parse argv, and assemble the result.
|
|
16
|
+
def parse(argv)
|
|
17
|
+
@options, @queue = config.defaults, argv.dup
|
|
18
|
+
parse_queue
|
|
19
|
+
validate!
|
|
20
|
+
options
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
protected
|
|
24
|
+
|
|
25
|
+
#
|
|
26
|
+
# main parser
|
|
27
|
+
#
|
|
28
|
+
|
|
29
|
+
def parse_queue
|
|
30
|
+
raise NakedRequested if config.naked? && queue.empty?
|
|
31
|
+
|
|
32
|
+
# any non-flags we find below
|
|
33
|
+
operands = []
|
|
34
|
+
|
|
35
|
+
# process argv as queue
|
|
36
|
+
while (item = queue.shift)
|
|
37
|
+
case item
|
|
38
|
+
when Flag::SWITCH_RE, Flag::INLINE_RE then parse_switch(item, Regexp.last_match)
|
|
39
|
+
when /\A-[^-]/ then parse_smashed(item)
|
|
40
|
+
when "", /\A[^-]/ then operands << item
|
|
41
|
+
when "--" then break operands.concat(queue)
|
|
42
|
+
else; raise Error, "unexpected argument '#{item}' found"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# add pred? for bools
|
|
47
|
+
config.flags.filter_map { _1.key if _1.bool? }.each do
|
|
48
|
+
options[:"#{_1}?"] = options[_1] if options.key?(_1)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# positionals
|
|
52
|
+
config.positionals.each do
|
|
53
|
+
options[_1.key] = operands.shift
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# _args
|
|
57
|
+
options[:_args] = operands
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
#
|
|
61
|
+
# -x or -x=123 or --xyz or --xyz=123 or --no-xyz
|
|
62
|
+
#
|
|
63
|
+
|
|
64
|
+
def parse_switch(item, match)
|
|
65
|
+
switch = "-#{match[1]}"
|
|
66
|
+
param = match[2]
|
|
67
|
+
separator = param ? "=" : ""
|
|
68
|
+
|
|
69
|
+
# -x or --xyz?
|
|
70
|
+
if (flag = config.flag(switch))
|
|
71
|
+
builtin!(flag)
|
|
72
|
+
param = queue.shift if flag.takes_param? && separator.empty?
|
|
73
|
+
options[flag.key] = flag.parse(switch, param)
|
|
74
|
+
return
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# --no-xyz?
|
|
78
|
+
if (neg = find_negated_flag(switch))
|
|
79
|
+
raise Error, "option '#{item}' does not take a value" if separator == "="
|
|
80
|
+
options[neg.key] = false
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
raise Error, "unexpected argument '#{item}' found"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
#
|
|
88
|
+
# smashed flags
|
|
89
|
+
#
|
|
90
|
+
|
|
91
|
+
# Expand short-switch groups such as `-qv`. A parameter-taking switch ends
|
|
92
|
+
# the group and consumes either its attached suffix or the next queue item.
|
|
93
|
+
def parse_smashed(group)
|
|
94
|
+
(1...group.length).each do |idx|
|
|
95
|
+
switch = "-#{group[idx]}"
|
|
96
|
+
flag = config.flag(switch)
|
|
97
|
+
raise Error, "unexpected argument '#{group}' found" unless flag
|
|
98
|
+
builtin!(flag)
|
|
99
|
+
|
|
100
|
+
# For `-qnLee`, `Lee` belongs to `-n`; for `-qn Lee`, shift the queue.
|
|
101
|
+
if flag.takes_param?
|
|
102
|
+
param = if idx + 1 < group.length
|
|
103
|
+
group[idx + 1...group.length]
|
|
104
|
+
else
|
|
105
|
+
queue.shift
|
|
106
|
+
end
|
|
107
|
+
options[flag.key] = flag.parse(switch, param)
|
|
108
|
+
return
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# bool
|
|
112
|
+
options[flag.key] = true
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
#
|
|
117
|
+
# helpers
|
|
118
|
+
#
|
|
119
|
+
|
|
120
|
+
def builtin!(flag)
|
|
121
|
+
raise HelpRequested if flag == config.help_flag
|
|
122
|
+
raise VersionRequested if flag == config.version_flag
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def find_negated_flag(switch)
|
|
126
|
+
if (m = Flag::NEGATE_RE.match(switch))
|
|
127
|
+
flag = config.flag("--#{m[1]}")
|
|
128
|
+
flag if flag&.bool?
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def validate!
|
|
133
|
+
config.required.each do
|
|
134
|
+
raise Error, "required option '#{_1.switch}' is missing" if !options.key?(_1.key)
|
|
135
|
+
end
|
|
136
|
+
config.positionals.each do
|
|
137
|
+
raise Error, "required argument '#{_1.meta}' is missing" if !options[_1.key]
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#
|
|
2
|
+
# A required positional param like `<url>`.
|
|
3
|
+
#
|
|
4
|
+
|
|
5
|
+
module RunKit
|
|
6
|
+
module Options
|
|
7
|
+
class Positional
|
|
8
|
+
POSITIONAL_RE = /\A<([A-Z]\w*)>\z/i
|
|
9
|
+
|
|
10
|
+
attr_reader :help, :meta
|
|
11
|
+
|
|
12
|
+
def initialize(meta:, help:)
|
|
13
|
+
@help, @meta = help, meta
|
|
14
|
+
raise ArgumentError, "positional help must be a string" unless help.is_a?(String)
|
|
15
|
+
raise ArgumentError, "positional meta must be a string" unless meta.is_a?(String)
|
|
16
|
+
raise ArgumentError, "positional must use <meta>" unless POSITIONAL_RE.match?(meta)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# one-liners
|
|
20
|
+
def key = @key ||= POSITIONAL_RE.match(meta)[1].to_sym
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|