run_kit 0.1.5 → 0.1.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d199e66b4f3d3fc9d3316baed91dc47ee182dde5d7cc229b4522069cc11de05a
4
- data.tar.gz: f80d68b84e9d02d450d95a78282e1b7785defe8ebf50a951d2e95e606349bd56
3
+ metadata.gz: 88bb49c39cde1b38476bad542e725026bf1709aecd58ed4ffd676a1367a87faf
4
+ data.tar.gz: 016eed022444cb2aa2fbfeaf213eceee7259fabdf920bde34a7500d60f79ff46
5
5
  SHA512:
6
- metadata.gz: 66a535125d24e6a1b5c609c096e525d14a5c43c35a35a5011439a4edc8ed68b45860ed955ad242bbf4cb2642e060dc3d41d042cc7d9cb5528cf445c9ecb2ce71
7
- data.tar.gz: a4d76419621604a84efbaf0d99cf84591b181a9a04398773ecc29905b8856b87e74032b7d6c03dc68ba47d657cdd5aa5e6c11e3dfaa4c3f8e6910874e0ac48d2
6
+ metadata.gz: 82159e72865bd71a9d6d696e2c7276e3b5051b82217093e68381ebbd051ef8faeaff9f53debefd09c4b71f4c77c20f335e20ab489aef6e3785a756d7bbfe9b1e
7
+ data.tar.gz: 5a38d4bb77f1e2a7440c69ebdf435a55e58a772c401f0756c1fea0cdbc9a8aaf8d429e739a846213927b4d300825c8e0ee13347e7ed325883ea8355e955449ca
data/README.md CHANGED
@@ -58,10 +58,15 @@ o.cmd "test", "Run tests" do |c|
58
58
  c.bool "--verbose"
59
59
  end
60
60
 
61
- # myapp build --target debug
62
- # => #<data command="build", dry_run=false, target="debug", _args=[]>
61
+ # myapp build --dry-run --target debug
62
+ # => #<data command="build", dry_run=true, target="debug", _args=[]>
63
63
  ```
64
64
 
65
+ Put the command first, then global or command flags in any order. If the first
66
+ argument isn't a command, a `default: true` command receives all arguments.
67
+ Root positionals aren't supported with subcommands; global and command flags
68
+ must have distinct switches and keys.
69
+
65
70
  ## RunKit::Shell
66
71
 
67
72
  `RunKit::Shell` is a mixin with many helpers for bin scripts:
@@ -74,7 +79,9 @@ end
74
79
  | `jsonl_read` / `jsonl_write` | Atomic read/write jsonl (add .gz for gzip) |
75
80
  | |
76
81
  | `csv_write_stdout` | Write CSV to stdout |
82
+ | `csv_write_to_s` | Generate CSV string |
77
83
  | `gunzip` / `gzip` | (De)compress a string |
84
+ | |
78
85
  | `atomic_write` | Atomically replace a file |
79
86
  | `cache_fetch` | Populate/fetch from file cache w/ block |
80
87
  | `cp_metadata` | Copy file metadata from src to dst |
@@ -132,8 +139,19 @@ RunKit also installs a small set of core extensions to assist with bin scripts.
132
139
 
133
140
  Note: There has been some effort to get the Pathname helpers into Ruby itself, without much success.
134
141
 
142
+ ### Future Work
143
+
144
+ - argv? ask nak for ideas
145
+
135
146
  ### Changelog
136
147
 
148
+ #### 0.1.6 (Sep 2026)
149
+
150
+ - default subcommands with `default: true`
151
+ - `<url...>` positionals for 1+ values
152
+ - remove `naked` and just do the right thing
153
+ - add `csv_write_to_s` for CSV string output
154
+
137
155
  #### 0.1.5 (Sep 2026)
138
156
 
139
157
  - after some investigation, decided that naked should default to false
data/demo.rb CHANGED
@@ -6,18 +6,23 @@ options = RunKit.parse do |o|
6
6
  o.version = "1.0"
7
7
  o.bool "--dry-run", "Preview without making changes"
8
8
  o.desc = "this is made up"
9
+ o.str "--gub"
9
10
 
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"
11
+ o.cmd("sub", default: true) do
12
+ _1.pos "<pos...>"
14
13
  end
15
14
 
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
15
+ # o.cmd "fetch", "Fetch a URL" do |c|
16
+ # c.int "-n", "--count <n>", "How many times to run", default: 1
17
+ # c.str "--mode <mode>", "Run quickly, or not", choices: %w[fast slow]
18
+ # c.positional "<url>", "URL to fetch"
19
+ # end
20
+
21
+ # o.cmd "build", "Build the project" do |c|
22
+ # c.naked = false
23
+ # c.str "--target <target>", "Build target", choices: %w[debug release], default: "release"
24
+ # c.bool "--force", "Force a rebuild", env: "DEMO_FORCE"
25
+ # end
21
26
  end
22
27
 
23
28
  p options
@@ -6,12 +6,11 @@
6
6
  module RunKit
7
7
  module Options
8
8
  class Config
9
- attr_accessor :banner, :color, :desc, :exit, :help, :naked, :root, :validate, :version
10
- attr_reader :help_flag, :name, :version_flag
11
- alias_method :naked?, :naked
9
+ attr_accessor :banner, :color, :default, :desc, :exit, :help, :root, :validate, :version
10
+ attr_reader(*%i[help_flag name version_flag])
11
+ alias_method :default?, :default
12
12
 
13
13
  def initialize(name: nil)
14
- @naked = false
15
14
  self.name = name || Shell.program_name
16
15
  end
17
16
 
@@ -21,6 +20,10 @@ module RunKit
21
20
 
22
21
  # Add a positional param declared as `<url>`.
23
22
  def pos(meta, help = "")
23
+ check_inside!
24
+ if positionals.last&.variadic?
25
+ raise ArgumentError, "no positional arguments are allowed after #{positionals.last.meta}"
26
+ end
24
27
  Positional.new(meta:, help:).tap do
25
28
  raise ArgumentError, "duplicate positional #{_1.key}" if key?(_1.key)
26
29
  positionals << _1
@@ -29,19 +32,22 @@ module RunKit
29
32
  end
30
33
 
31
34
  # Add a subcommand with its own nested Config, eg `myapp build`.
32
- def cmd(name, desc = nil)
35
+ def cmd(name, desc = nil, default: false)
36
+ check_inside!
33
37
  name = name.to_s
34
38
  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
39
+ raise ArgumentError, "default command already set" if default && default_command
40
+ Config.new(name:).tap do |child|
41
+ child.default, child.desc, child.root = default, desc, self
42
+ with_inside(name) { yield child } if block_given?
43
+ raise ArgumentError, "nested commands are not supported" if child.commands.any?
44
+ commands[name] = child
40
45
  end
41
46
  end
42
47
 
43
48
  # Add separator text at the current point in generated help.
44
49
  def sep(text = "")
50
+ check_inside!
45
51
  [flags.length, text].tap do
46
52
  separators << _1
47
53
  end
@@ -95,11 +101,9 @@ module RunKit
95
101
  end
96
102
 
97
103
  # one-liners
98
- def flag(switch) = lookup[switch]
99
- def flag?(switch) = lookup.key?(switch)
104
+ def default_command = commands.values.find(&:default?)
100
105
  def full_name = root ? "#{root.full_name} #{name}" : name
101
106
  def key?(key) = lookup.key?(key)
102
- def required = flags.select(&:required?)
103
107
 
104
108
  # memoized accessors
105
109
  def commands = @commands ||= {}
@@ -111,6 +115,9 @@ module RunKit
111
115
  # Complete one-time setup after the caller has declared overrides.
112
116
  def prepare!
113
117
  return if @prepared
118
+ if commands.any? && positionals.any?
119
+ raise ArgumentError, "a command with subcommands cannot also take positional arguments; add them to a subcommand instead"
120
+ end
114
121
  @prepared = true
115
122
 
116
123
  # Children inherit shared settings before adding builtins.
@@ -123,18 +130,38 @@ module RunKit
123
130
  @help_flag = bool("-h", "--help", "Show this message")
124
131
  @version_flag = bool("-v", "--version", "Show version") if version
125
132
 
133
+ # Global and command flags share one parser; only builtins may overlap.
134
+ if root
135
+ keys = lookup.keys - [help_flag, version_flag].compact.flat_map { [_1.key, *_1.switches] }
136
+ collisions = keys & root.lookup.keys
137
+ raise ArgumentError, "command #{name} conflicts with global options: #{collisions.join(", ")}" if collisions.any?
138
+ end
139
+
126
140
  # setup subcommands
127
141
  commands.each_value(&:prepare!)
128
142
  end
129
143
 
130
144
  protected
131
145
 
146
+ # Catch accidental use of the outer config while defining a command.
147
+ def with_inside(name)
148
+ @inside = name
149
+ yield
150
+ ensure
151
+ @inside = nil
152
+ end
153
+
154
+ def check_inside!
155
+ raise ArgumentError, "you're adding a root option inside #{@inside.inspect} command" if @inside
156
+ end
157
+
132
158
  def add_flag(flag)
159
+ check_inside!
133
160
  # dup check
134
161
  raise ArgumentError, "reserved flag key: _args" if flag.key == :_args
135
162
  raise ArgumentError, "dup flag key: #{flag.key}" if key?(flag.key)
136
163
  flag.switches.each do
137
- raise ArgumentError, "dup flag switch: #{_1}" if flag?(_1)
164
+ raise ArgumentError, "dup flag switch: #{_1}" if key?(_1)
138
165
  end
139
166
 
140
167
  # append
@@ -9,8 +9,6 @@ module RunKit
9
9
  SWITCH_RE = /\A-(\w|-\w[\w-]*)\z/
10
10
  # --foo=bar
11
11
  INLINE_RE = /\A-(\w|-\w[\w-]*)=(.*)\z/m
12
- # --no-foo
13
- NEGATE_RE = /\A--no-(\w[\w-]*)\z/
14
12
 
15
13
  TRUE_ENV = %w[1 true yes on]
16
14
  FALSE_ENV = ["", "0", "false", "no", "off"]
@@ -30,6 +28,7 @@ module RunKit
30
28
 
31
29
  # Extract meta from the final switch, if written as `--port <int>`.
32
30
  @meta = build_meta
31
+ validate_switches!
33
32
  @env = (env == true) ? key.to_s.upcase : env
34
33
 
35
34
  # default, with some special handling for bool
@@ -39,6 +38,9 @@ module RunKit
39
38
  end
40
39
 
41
40
  validate
41
+ rescue ArgumentError => ex
42
+ ctx = reconstruct(kind, opts, default:, required:, choices:, env:)
43
+ raise ArgumentError, "#{ctx} - #{ex.message}", cause: nil
42
44
  end
43
45
 
44
46
  #
@@ -83,15 +85,16 @@ module RunKit
83
85
  # validation
84
86
  #
85
87
 
86
- def validate
87
- # switches
88
- raise ArgumentError, "at least one switch is required" if switches.empty?
88
+ def validate_switches!
89
89
  switches.each do
90
90
  raise ArgumentError, "invalid switch: #{_1}" unless _1.is_a?(String)
91
91
  raise ArgumentError, "invalid switch: #{_1}" unless _1.match?(SWITCH_RE)
92
92
  end
93
+ raise ArgumentError, 'flag must start with "-" or "--"' if switches.empty?
93
94
  raise ArgumentError, "duplicate switch" unless switches.uniq.length == switches.length
95
+ end
94
96
 
97
+ def validate
95
98
  # params
96
99
  raise ArgumentError, "invalid flag kind: #{kind}" unless KINDS.include?(kind)
97
100
  raise ArgumentError, "boolean flags do not accept meta" if bool? && meta
@@ -114,6 +117,12 @@ module RunKit
114
117
  # helpers
115
118
  #
116
119
 
120
+ # Use the original declaration, before metadata and ENV normalization.
121
+ def reconstruct(kind, opts, **settings)
122
+ args = opts.map(&:inspect) + settings.filter_map { "#{_1}: #{_2.inspect}" if _2 }
123
+ ["opt.#{kind}", args.join(", ")].reject(&:empty?).join(" ")
124
+ end
125
+
117
126
  def build_meta
118
127
  # Only the final spelling may carry an inferred `<meta>`.
119
128
  if (m = /\A(\S+) <([^>]+)>\z/.match(switch))
@@ -18,12 +18,13 @@ 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
+ options = flags_text(config, "Options:", builtins: !config.root)
21
22
  help = [].tap do
22
23
  _1 << desc if config.desc
23
24
  _1 << banner
24
25
  _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
26
+ _1 << options
27
+ _1 << flags_text(config.root, options ? "Other options:" : "Options:") if config.root
27
28
  end.compact.join("\n\n")
28
29
  "#{help}\n"
29
30
  end
@@ -67,19 +68,22 @@ module RunKit
67
68
  # Build the usage line from the command's full name and positionals.
68
69
  def banner
69
70
  text = config.banner
70
- text ||= [color.blue("Usage:"), color.green(config.full_name), "[options]"].tap do
71
- _1.push(*config.positionals.map(&:meta))
71
+ text ||= [color.blue("Usage:"), color.green(config.full_name)].tap do
72
72
  _1.push(color.yellow("<command>")) if config.commands.any?
73
+ _1.push("[options]", *config.positionals.map { |pos| color.yellow(pos.meta) })
73
74
  end.join(" ")
74
75
  Term.wrap(text, width)
75
76
  end
76
77
 
77
78
  # Render the list of subcommands, aligned like the flag list above.
78
79
  def commands_text
80
+ commands = config.commands.map do |name, child|
81
+ [child.default? ? "#{name} (default)" : name, child]
82
+ end
79
83
  [].tap do |lines|
80
84
  lines << color.blue("Commands:")
81
- label_width = config.commands.keys.map { Term.width(_1) }.max
82
- config.commands.each do |name, child|
85
+ label_width = commands.map { Term.width(_1.first) }.max
86
+ commands.each do |name, child|
83
87
  lines << StringIO.new.tap do |buf|
84
88
  buf << " " * INDENT << color.green(name)
85
89
  if child.desc
@@ -39,7 +39,7 @@ module RunKit
39
39
  options = root.commands.empty? ? parse_with_ctx(root, argv) : subcommand(argv)
40
40
  klass = Data.define(*options.keys)
41
41
  klass.new(**options).tap { validate(_1) }
42
- rescue Error, NakedRequested => ex
42
+ rescue Error, HelpRequested => ex
43
43
  handle_error(ex)
44
44
  end
45
45
  end
@@ -59,21 +59,23 @@ module RunKit
59
59
  end
60
60
  end
61
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.
62
+ # Select from the first argument, then parse global and command flags together.
64
63
  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)
64
+ rest = argv
65
+ if (child = root.commands[rest.first])
66
+ rest = rest.drop(1)
67
+ infer_help = false
68
+ else
69
+ child = root.default_command
70
+ infer_help = true
71
+ end
72
+ raise HelpRequested if !child && rest.empty?
73
+ if !child
74
+ switch = rest.first.start_with?("--") ? rest.first.split("=", 2).first : rest.first[0, 2]
75
+ raise Error, "global options must follow the command" if root.key?(switch)
76
+ raise Error, "unknown command '#{rest.first}'"
77
+ end
78
+ parse_with_ctx(child, rest, infer_help:).merge(command: child.name)
77
79
  end
78
80
 
79
81
  # Validate the final options, root first.
@@ -93,7 +95,8 @@ module RunKit
93
95
  return exit_fn(1, error: ex.message)
94
96
  end
95
97
 
96
- puts Help.new(ctx)
98
+ # Inferred help on a bare invocation introduces the whole app.
99
+ puts Help.new(root)
97
100
  exit_fn(0)
98
101
  end
99
102
 
@@ -116,9 +119,9 @@ module RunKit
116
119
  yield
117
120
  end
118
121
 
119
- def parse_with_ctx(ctx, argv, passthru: false)
122
+ def parse_with_ctx(ctx, argv, infer_help: true)
120
123
  with_ctx(ctx) do
121
- Parser.new(ctx).parse(argv, passthru:)
124
+ Parser.new(ctx).parse(argv, infer_help:)
122
125
  end
123
126
  end
124
127
 
@@ -130,7 +133,7 @@ module RunKit
130
133
  end
131
134
 
132
135
  class Error < StandardError; end
133
- class NakedRequested < Exception; end # rubocop:disable Lint/InheritException
136
+ class HelpRequested < Exception; end # rubocop:disable Lint/InheritException
134
137
 
135
138
  # main entry point
136
139
  def self.parse(argv = ARGV)
@@ -6,29 +6,27 @@
6
6
  module RunKit
7
7
  module Options
8
8
  class Parser
9
- attr_reader :config
9
+ attr_reader(*%i[config flags lookup])
10
10
 
11
11
  def initialize(config)
12
12
  @config = config
13
+ @flags = config.root ? (config.root.flags - [config.root.help_flag, config.root.version_flag]) + config.flags : config.flags
14
+ @lookup = (config.root&.lookup || {}).merge(config.lookup)
13
15
  end
14
16
 
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?
17
+ # Parse global and command flags together, then assemble the result.
18
+ def parse(argv, infer_help: true)
19
+ # 1. Parse argv.
20
+ argv_options = parse_argv(argv)
20
21
 
21
- # 2. Parse argv.
22
- argv_options = parse_argv(argv, passthru:)
22
+ # 2. defaults => ENV => ARGV
23
+ options = {}.merge(config.root&.defaults || {}, config.defaults, parse_env, argv_options)
23
24
 
24
- # 3. defaults => ENV => ARGV
25
- options = {}.merge(config.defaults, parse_env, argv_options)
26
-
27
- # 4. validate final options
28
- validate!(options)
25
+ # 3. Validate requirements after ENV resolution; bare missing inputs show help.
26
+ validate!(options, infer_help: infer_help && argv.empty?)
29
27
 
30
28
  # success! add predicate? keys
31
- config.flags.select(&:bool?).map(&:key).each do
29
+ flags.select(&:bool?).map(&:key).each do
32
30
  options[:"#{_1}?"] = options[_1] if options.key?(_1)
33
31
  end
34
32
 
@@ -41,10 +39,10 @@ module RunKit
41
39
  # main parser
42
40
  #
43
41
 
44
- def parse_argv(argv, passthru: false)
42
+ def parse_argv(argv)
45
43
  {}.tap do |result|
46
44
  # any non-flags we find below
47
- operands = []
45
+ args = []
48
46
 
49
47
  # process argv as queue
50
48
  queue = argv.dup
@@ -53,45 +51,37 @@ module RunKit
53
51
  when Flag::SWITCH_RE, Flag::INLINE_RE then result.merge!(parse_switch(item, Regexp.last_match, queue))
54
52
  when /\A-[^-]/ then result.merge!(parse_smashed(item, queue))
55
53
  when "", /\A[^-]/
56
- operands << item
57
- if passthru
58
- operands.concat(queue)
59
- break
60
- end
61
- when "--" then break operands.concat(queue)
54
+ args << item
55
+ when "--" then break args.concat(queue)
62
56
  else; raise Error, "unexpected argument '#{item}' found"
63
57
  end
64
58
  end
65
59
 
66
60
  # positionals
67
- config.positionals.each { result[_1.key] = operands.shift }
61
+ config.positionals.each { result[_1.key] = _1.variadic? ? args.shift(args.length) : args.shift }
62
+ if config.positionals.any? && args.any?
63
+ raise Error, "unexpected argument '#{args.first}' found"
64
+ end
68
65
 
69
66
  # _args
70
- result[:_args] = operands
67
+ result[:_args] = args
71
68
  end
72
69
  end
73
70
 
74
71
  #
75
- # -x or -x=123 or --xyz or --xyz=123 or --no-xyz
72
+ # -x or -x=123 or --xyz or --xyz=123
76
73
  #
77
74
 
78
75
  def parse_switch(item, match, queue)
79
76
  switch = "-#{match[1]}"
80
77
  param = match[2]
81
- separator = param ? "=" : ""
82
78
 
83
79
  # -x or --xyz?
84
- if (flag = config.flag(switch))
85
- param = queue.shift if flag.takes_param? && separator.empty?
80
+ if (flag = lookup[switch])
81
+ param = queue.shift if flag.takes_param? && !param
86
82
  return {flag.key => flag.parse(switch, param)}
87
83
  end
88
84
 
89
- # --no-xyz?
90
- if (neg = find_negated_flag(switch))
91
- raise Error, "option '#{item}' does not take a value" if separator == "="
92
- return {neg.key => false}
93
- end
94
-
95
85
  raise Error, "unexpected argument '#{item}' found"
96
86
  end
97
87
 
@@ -105,7 +95,7 @@ module RunKit
105
95
  result = {}
106
96
  (1...group.length).each do |idx|
107
97
  switch = "-#{group[idx]}"
108
- flag = config.flag(switch)
98
+ flag = lookup[switch]
109
99
  raise Error, "unexpected argument '#{group}' found" unless flag
110
100
 
111
101
  # For `-qnLee`, `Lee` belongs to `-n`; for `-qn Lee`, shift the queue.
@@ -130,7 +120,7 @@ module RunKit
130
120
  #
131
121
 
132
122
  def parse_env
133
- config.flags.select { _1.env && ENV.key?(_1.env) }.map do |flag|
123
+ flags.select { _1.env && ENV.key?(_1.env) }.map do |flag|
134
124
  [flag.key, flag.parse_env(ENV[flag.env])]
135
125
  end.to_h
136
126
  end
@@ -139,19 +129,17 @@ module RunKit
139
129
  # helpers
140
130
  #
141
131
 
142
- def find_negated_flag(switch)
143
- if (m = Flag::NEGATE_RE.match(switch))
144
- flag = config.flag("--#{m[1]}")
145
- flag if flag&.bool?
146
- end
147
- end
148
-
149
- def validate!(options)
150
- config.required.each do
151
- raise Error, "required option '#{_1.switch}' is missing" if !options.key?(_1.key)
132
+ def validate!(options, infer_help: false)
133
+ # Infer help only for missing inputs, after ENV has been resolved.
134
+ flags.select(&:required?).each do
135
+ next if options.key?(_1.key)
136
+ raise HelpRequested if infer_help
137
+ raise Error, "required option '#{_1.switch}' is missing"
152
138
  end
153
139
  config.positionals.each do
154
- raise Error, "required argument '#{_1.meta}' is missing" if !options[_1.key]
140
+ next if _1.variadic? ? options[_1.key].any? : options[_1.key]
141
+ raise HelpRequested if infer_help
142
+ raise Error, "required argument '#{_1.meta}' is missing"
155
143
  end
156
144
  end
157
145
  end
@@ -1,11 +1,11 @@
1
1
  #
2
- # A required positional param like `<url>`.
2
+ # A required positional param like `<url>` or `<url...>`.
3
3
  #
4
4
 
5
5
  module RunKit
6
6
  module Options
7
7
  class Positional
8
- POSITIONAL_RE = /\A<([A-Z]\w*)>\z/i
8
+ POSITIONAL_RE = /\A<([A-Z]\w*)(\.\.\.)?>\z/i
9
9
 
10
10
  attr_reader :help, :meta
11
11
 
@@ -13,11 +13,14 @@ module RunKit
13
13
  @help, @meta = help, meta
14
14
  raise ArgumentError, "positional help must be a string" unless help.is_a?(String)
15
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)
16
+ unless POSITIONAL_RE.match?(meta)
17
+ raise ArgumentError, "invalid positional #{meta.inspect}; wrap the argument name in angle brackets, e.g. o.pos(\"<url>\", \"URL to fetch\")"
18
+ end
17
19
  end
18
20
 
19
21
  # one-liners
20
22
  def key = @key ||= POSITIONAL_RE.match(meta)[1].to_sym
23
+ def variadic? = meta.end_with?("...>")
21
24
  end
22
25
  end
23
26
  end
data/lib/run_kit/shell.rb CHANGED
@@ -78,6 +78,10 @@ module RunKit
78
78
  CSV($stdout) { _csv_write0(_1, rows, headers:) }
79
79
  end
80
80
 
81
+ def csv_write_to_s(rows, headers: nil)
82
+ CSV.generate { _csv_write0(_1, rows, headers:) }
83
+ end
84
+
81
85
  #
82
86
  # shell/shell!
83
87
  #
data/run_kit.gemspec CHANGED
@@ -1,6 +1,6 @@
1
1
  Gem::Specification.new do |s|
2
2
  s.name = "run_kit"
3
- s.version = "0.1.5"
3
+ s.version = "0.1.6"
4
4
  s.authors = ["Adam Doppelt"]
5
5
  s.email = "amd@gurge.com"
6
6
  s.summary = "Run kit."
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: run_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adam Doppelt
@@ -88,7 +88,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
88
88
  - !ruby/object:Gem::Version
89
89
  version: '0'
90
90
  requirements: []
91
- rubygems_version: 4.0.6
91
+ rubygems_version: 3.6.9
92
92
  specification_version: 4
93
93
  summary: Run kit.
94
94
  test_files: []