cli_class_tool 0.4.1 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 46f243576af73a6b5e4b56a097e4876377f2f4b0b2871ea013955d6eb75addd0
4
- data.tar.gz: c44bcb2a54d9f1c3e9d5c19543805ba16f0bd224f5ece98acb02da3be071bf31
3
+ metadata.gz: 2c2833d3bd929aa6e69df3df0cbadbbc3e92030afdbf8172387869a9ad4f95f3
4
+ data.tar.gz: 3bac4466f6a3d4e07bc739e36eb0eaaba2929c5a308a980715e4d0c55ef11fd7
5
5
  SHA512:
6
- metadata.gz: 2d04141ca190c6a98bd4481156708c9fbff7f9550324bee3612c283959ad94b90efaf33484fd77b6425f3320ecf6e2e8996422e307506f837f07785267cf5686
7
- data.tar.gz: 761917986fb8e915fc189e2d4cee710bd39ac44c968b421cca4ee65affd0d07ec7de9455a3e46e15beb1ee09110e966c72bc2be01e9746254256e50067308d88
6
+ metadata.gz: 5f8ab8e7152ade287dc0a8c50598d2069cbbd99ecbcc5d0bb9cc7fd37a8a9e3fa401a889030c3bcea9a387cb2f5ac5a8ab49ef40b78980a982355ecdcba3bd53
7
+ data.tar.gz: d031b3ca4da59379e5f3079ad091f10fd4934b0d1150df9f846ea2de77e1d439e236ac2070763418083394956cef64ed3f7ec2f7883222c66b14cd24e3ea9854
data/CHANGELOG CHANGED
@@ -1,3 +1,15 @@
1
+ ------------------
2
+ 0.5.0 (2026-07-22)
3
+ ------------------
4
+
5
+ * Support CLI_HELP_EXPAND on sub-CLIClass modules to recursively list sub-actions in parent help outputs, including support for boolean or custom string headers
6
+ * Support recursive subcommand alias and shortcut resolution within deeply nested sub-CLIClasses
7
+ * Add CLI_COMMAND_ALIASES support to define top-level command shortcuts/aliases to deeply-nested subcommand actions
8
+ * Filter list_actions command output to exclude itself, preserving clean autocomplete query behavior
9
+ * Require optparse explicitly inside utils.rb to prevent NameErrors in production environments
10
+ * Safeguard attribute resolution and _runOnClass to gracefully default when ACTION_HELP/ACTION_LIST is missing
11
+ * Add recursive nested subcommand and namespace auto-discovery support
12
+
1
13
  ------------------
2
14
  0.4.1 (2026-06-05)
3
15
  ------------------
data/README.md CHANGED
@@ -294,4 +294,103 @@ MyProject.run_cli(opts) do |parser, phase, action_opts|
294
294
  end
295
295
  end
296
296
  ```
297
+
298
+ ---
299
+
300
+ ## Nested CLI Subcommands (Subcommands & Recursive Routing)
301
+
302
+ `CLIClassTool` natively supports nested CLI subcommands, enabling hierarchical structures of the form:
303
+ ```
304
+ $ mytool <subcommand> <action> [options]
305
+ $ mytool <subcommand> <sub_subcommand> <action> [options]
306
+ ```
307
+
308
+ This allows you to decouple complex tools into self-contained sub-CLI components while executing them seamlessly under a single binary entry point.
309
+
310
+ ### 1. Auto-Discovery & Setup
311
+
312
+ Any nested module or class defined within your parent CLI module that extends `CLIClassTool::Utils` is **automatically discovered** as a subcommand:
313
+
314
+ ```ruby
315
+ module MyProject
316
+ extend CLIClassTool::Utils # Parent CLI
317
+
318
+ # 1. Nesting a subcommand module
319
+ module ConfigCLI
320
+ # Define custom subcommand help description
321
+ CLI_DESCRIPTION = "Manage application configurations"
322
+
323
+ # Optional: Customize the command name (defaults to "config_cli")
324
+ CLI_COMMAND_NAME = "config"
325
+
326
+ class ConfigError < StandardError; end
327
+
328
+ class Common < CLIClassTool::Common
329
+ def parent_module; ConfigCLI; end
330
+ end
331
+
332
+ class ConfigAction < Common
333
+ ACTION_LIST = [ :set_val ]
334
+ ACTION_HELP = { :set_val => "Set a config value" }
335
+
336
+ def set_val(opts)
337
+ # business logic...
338
+ return 0
339
+ end
340
+ end
341
+
342
+ ACTION_CLASS = [ ConfigAction ]
343
+ extend CLIClassTool::Utils
344
+ end
345
+ end
346
+ ```
347
+
348
+ ### 2. Help Aggregation & Name Resolution
349
+
350
+ - **Naming:** By default, subcommand trigger words are derived from the inner module/class name converted to `snake_case` (e.g., `ConfigCLI` becomes `config_cli`). Define `CLI_COMMAND_NAME = "custom_name"` on your submodule to override this behavior.
351
+ - **Help Menus:** Nested subcommands and their descriptions (`CLI_DESCRIPTION` or `HELP` constants) are automatically collected and listed in the parent CLI's usage output under `Possible actions:`.
352
+ - **Option Forwarding:** Global customization blocks and verbosity flags are recursively passed down to the active subcommand.
353
+ - **Help Expansion:** By default, `--help` lists the trigger name and description of subcommands. You can declare `CLI_HELP_EXPAND = true` on a sub-CLI module to recursively list all of its individual actions directly in the parent's help menu. If `CLI_HELP_EXPAND` is a `String` (e.g., `"Config options:"`), that string will be printed as a dedicated help header separator inside the parent help menu.
354
+
355
+ ### 3. Command Aliases (Shortcuts)
356
+
357
+ You can define command shortcuts/aliases at any CLI level by declaring a `CLI_COMMAND_ALIASES` Hash constant mapping shortcut names (symbols or strings) to subcommand/action pathways:
358
+
359
+ ```ruby
360
+ module MyProject
361
+ extend CLIClassTool::Utils
362
+
363
+ # Map shortcuts to deeply-nested actions
364
+ CLI_COMMAND_ALIASES = {
365
+ "myalias" => ["config", "set_val"],
366
+ "string_alias" => "config set_val"
367
+ }
368
+ end
369
+ ```
370
+
371
+ Running `$ mytool myalias --foo bar` will automatically expand and route arguments exactly as if the user had typed `$ mytool config set_val --foo bar`.
372
+ These shortcuts are also dynamically collected and formatted under a dedicated `Command aliases:` section when printing `--help`.
373
+
374
+ ### 4. Seamless Subcommand Exception Handling
375
+
376
+ `CLIClassTool` provides dynamic subclass exception matching to solve a common design challenge: *rescuing any exception originating from nested sub-CLIs under a single, central parent rescue block.*
377
+
378
+ When you extend `CLIClassTool::Utils` on a parent module, `CLIClassTool` overrides the case-equality (`===`) operator on the parent's base error class. This ensures:
379
+ - Any child subcommand exception (such as `MyProject::ConfigCLI::RunError`) is **seamlessly rescued** by the parent base error class.
380
+ - The original exception classes and names remain **completely unchanged**, meaning they can still be rescued by their specific sub-CLI exception class as normal.
381
+
382
+ This enables infinite sub-CLI nesting while preserving modular reuse across different parent projects or as standalone binaries:
383
+
384
+ ```ruby
385
+ # In bin/mytool
386
+ begin
387
+ MyProject.run_cli
388
+ rescue MyProjectError => e
389
+ # Seamlessly catches errors from MyProject::RunError,
390
+ # MyProject::ConfigCLI::RunError, or any deeper sublevel!
391
+ STDERR.puts "# ERROR: #{e.message}"
392
+ exit e.respond_to?(:err_code) ? e.err_code : 1
393
+ end
394
+ ```
395
+
297
396
  ```
@@ -222,7 +222,9 @@ module CLIClassTool
222
222
  # @param opts [Hash] Options hash
223
223
  # @return [Integer] 0
224
224
  def list_actions(opts)
225
- puts parent_module.getActionAttr("ACTION_LIST").map(){|x| parent_module.actionToString(x)}.join("\n")
225
+ actions = parent_module.getActionAttr("ACTION_LIST").map(){|x| parent_module.actionToString(x)}
226
+ actions.reject! { |x| x == "list_actions" }
227
+ puts actions.join("\n")
226
228
  return 0
227
229
  end
228
230
  end
@@ -1,3 +1,5 @@
1
+ require 'optparse'
2
+
1
3
  module CLIClassTool
2
4
  # Generic utilities for CLI class-based actions
3
5
  module Utils
@@ -13,7 +15,95 @@ module CLIClassTool
13
15
  raise("Could not find a base error class named #{superclass_name}")
14
16
  end
15
17
 
16
- CLIClassTool.define_run_error(base, Object.const_get(superclass_name))
18
+ error_class = Object.const_get(superclass_name)
19
+
20
+ # Override === on the error class to match nested subcommand errors recursively
21
+ error_class.singleton_class.class_eval do
22
+ define_method(:_cli_host_module) { base }
23
+
24
+ def ===(other)
25
+ return true if super
26
+
27
+ if other.is_a?(StandardError) && other.respond_to?(:err_code)
28
+ other_class_name = other.class.name
29
+ if other_class_name
30
+ parts = other_class_name.split('::')
31
+ if parts.size > 1
32
+ parent_mod_name = parts[0...-1].join('::')
33
+ host = _cli_host_module
34
+ if host.respond_to?(:cli_sub_actions)
35
+ all_sub_names = _all_sub_module_names(host)
36
+ return all_sub_names.include?(parent_mod_name)
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ false
43
+ end
44
+
45
+ define_method(:_all_sub_module_names) do |host_mod|
46
+ names = []
47
+ if host_mod.respond_to?(:cli_sub_actions)
48
+ host_mod.cli_sub_actions.values.each do |sub_cli|
49
+ if sub_cli.name
50
+ names << sub_cli.name
51
+ names.concat(_all_sub_module_names(sub_cli))
52
+ end
53
+ end
54
+ end
55
+ names.uniq
56
+ end
57
+ end
58
+
59
+ CLIClassTool.define_run_error(base, error_class)
60
+ end
61
+
62
+ # Convert CamelCase to snake_case
63
+ # @param str [String]
64
+ # @return [String]
65
+ def _to_snake_case(str)
66
+ str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
67
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
68
+ .tr('-', '_')
69
+ .downcase
70
+ end
71
+
72
+ # Dynamically discover and return a map of sub-CLI tools
73
+ #
74
+ # @return [Hash<String, Module>] Map of subcommand string to CLI modules
75
+ def cli_sub_actions
76
+ sub_actions = {}
77
+
78
+ # First, check if manual CLI_SUB_ACTIONS mapping exists
79
+ if self.const_defined?(:CLI_SUB_ACTIONS)
80
+ manual_actions = self::CLI_SUB_ACTIONS
81
+ if manual_actions.is_a?(Hash)
82
+ manual_actions.each do |k, v|
83
+ sub_actions[k.to_s] = v
84
+ end
85
+ end
86
+ end
87
+
88
+ # Then, dynamically discover any inner modules/classes extending CLIClassTool::Utils
89
+ self.constants(false).each do |const_sym|
90
+ begin
91
+ const_val = self.const_get(const_sym)
92
+ if const_val.is_a?(Module) && const_val.is_a?(CLIClassTool::Utils)
93
+ cmd_name = if const_val.const_defined?(:CLI_COMMAND_NAME)
94
+ const_val::CLI_COMMAND_NAME.to_s
95
+ else
96
+ _to_snake_case(const_sym.to_s)
97
+ end
98
+ # Only add if not already manually specified
99
+ sub_actions[cmd_name] ||= const_val
100
+ end
101
+ rescue NameError, LoadError
102
+ # ignore uninitialized autoloads
103
+ end
104
+ end
105
+
106
+ return sub_actions
17
107
  end
18
108
 
19
109
  # Convert a string to an action symbol, validating it against available actions
@@ -40,19 +130,67 @@ module CLIClassTool
40
130
  # @param attr [Symbol] Attribute name (e.g., "ACTION_LIST")
41
131
  # @return [Hash, Array] Aggregated attributes
42
132
  def getActionAttr(attr)
43
- action_classes = self::ACTION_CLASS
44
- common_class = self::Common
133
+ common_class = self.const_defined?(:Common) ? self::Common : CLIClassTool::Common
134
+ is_hash = if common_class.const_defined?(attr)
135
+ common_class.const_get(attr).is_a?(Hash)
136
+ else
137
+ attr.to_s.include?("HELP")
138
+ end
139
+
140
+ action_classes = self.const_defined?(:ACTION_CLASS) ? self::ACTION_CLASS : []
45
141
 
46
142
  # Resolve overridden/extended class (addon) if getExtendedClass is defined
47
143
  resolved_classes = action_classes.map do |x|
48
144
  self.respond_to?(:getExtendedClass) ? self.getExtendedClass(x) : x
49
145
  end
50
146
 
51
- if common_class.const_get(attr).class == Hash
52
- return resolved_classes.inject({}){|h, x| h.merge(x.const_get(attr))}
147
+ res = if is_hash
148
+ resolved_classes.inject({}) do |h, x|
149
+ x.const_defined?(attr) ? h.merge(x.const_get(attr)) : h
150
+ end
53
151
  else
54
- return resolved_classes.map(){|x| x.const_get(attr)}.flatten()
152
+ resolved_classes.map do |x|
153
+ x.const_defined?(attr) ? x.const_get(attr) : []
154
+ end.flatten()
155
+ end
156
+
157
+ # If it's ACTION_LIST, append discovered subcommand names
158
+ if attr.to_s == "ACTION_LIST"
159
+ sub_actions = self.cli_sub_actions
160
+ res += sub_actions.keys.map(&:to_sym)
161
+ res << :list_actions unless res.include?(:list_actions)
162
+ # If it's ACTION_HELP, merge subcommand helps
163
+ elsif attr.to_s == "ACTION_HELP"
164
+ sub_helps = {}
165
+ self.cli_sub_actions.each do |cmd_name, sub_cli|
166
+ expand_val = sub_cli.const_defined?(:CLI_HELP_EXPAND) ? sub_cli::CLI_HELP_EXPAND : nil
167
+ if expand_val
168
+ if expand_val.is_a?(String)
169
+ sub_helps[expand_val] = ""
170
+ end
171
+ sub_helps_rec = sub_cli.getActionAttr("ACTION_HELP")
172
+ sub_helps_rec.each do |k, desc|
173
+ sub_helps["#{cmd_name} #{k}".to_sym] = desc
174
+ end
175
+ else
176
+ desc = ""
177
+ if sub_cli.const_defined?(:CLI_DESCRIPTION)
178
+ desc = sub_cli::CLI_DESCRIPTION
179
+ elsif sub_cli.const_defined?(:HELP)
180
+ desc = sub_cli::HELP
181
+ end
182
+ sub_helps[cmd_name.to_sym] = desc
183
+ end
184
+ end
185
+ if self.const_defined?(:CLI_SUB_ACTIONS_HELP)
186
+ self::CLI_SUB_ACTIONS_HELP.each do |k, desc|
187
+ sub_helps[k.to_sym] = desc
188
+ end
189
+ end
190
+ res = res.merge(sub_helps)
55
191
  end
192
+
193
+ return res
56
194
  end
57
195
 
58
196
  # Run a block on the class responsible for a specific action
@@ -62,8 +200,9 @@ module CLIClassTool
62
200
  # @yield [Class] The class handling the action
63
201
  # @return [Object] Result of the block or error code
64
202
  def _runOnClass(action, sym, &block)
203
+ return -1 unless self.const_defined?(:ACTION_CLASS)
65
204
  self::ACTION_CLASS.each(){|x|
66
- next if x::ACTION_LIST.index(action) == nil
205
+ next if !x.const_defined?(:ACTION_LIST) || x::ACTION_LIST.index(action) == nil
67
206
 
68
207
  # Resolve overridden/extended class (addon)
69
208
  class_to_use = self.respond_to?(:getExtendedClass) ? self.getExtendedClass(x) : x
@@ -114,7 +253,7 @@ module CLIClassTool
114
253
  def execAction(opts, action, error_class = nil)
115
254
  caught_error_class = error_class || StandardError
116
255
 
117
- self._runOnClass(action, nil) {|kClass|
256
+ ret_code = self._runOnClass(action, nil) {|kClass|
118
257
  begin
119
258
  # Use load factory method if defined, else fall back to .new
120
259
  obj = kClass.respond_to?(:load) ? kClass.load() : kClass.new()
@@ -134,6 +273,15 @@ module CLIClassTool
134
273
  end
135
274
  end
136
275
  }
276
+
277
+ if ret_code == -1 && action == :list_actions
278
+ actions = self.getActionAttr("ACTION_LIST").map(){|x| self.actionToString(x)}
279
+ actions.reject! { |x| x == "list_actions" }
280
+ puts actions.join("\n")
281
+ return 0
282
+ end
283
+
284
+ return ret_code
137
285
  end
138
286
 
139
287
  # Set verbose logging
@@ -193,7 +341,7 @@ module CLIClassTool
193
341
  # @param opts [Hash] Initial options hash
194
342
  # @param argv [Array<String>] Command line arguments (defaults to ARGV)
195
343
  # @yield [parser, phase, action_opts] Custom options setup callback block
196
- def run_cli(opts = {}, argv = ARGV)
344
+ def run_cli(opts = {}, argv = ARGV, &block)
197
345
  # Fetch actions and action helps
198
346
  action_helps = self.getActionAttr("ACTION_HELP")
199
347
 
@@ -215,7 +363,7 @@ module CLIClassTool
215
363
  col_width = max_len + 4
216
364
  action_helps.each do |k, x|
217
365
  indent = col_width - self.actionToString(k).length
218
- action_parser.separator "\t * " + self.actionToString(k) + (" " * indent) + x
366
+ action_parser.separator "\t * " + self.actionToString(k) + (" " * indent) + x.to_s
219
367
  end
220
368
 
221
369
  # Include any registered custom addon class listings if defined
@@ -226,6 +374,19 @@ module CLIClassTool
226
374
  end
227
375
  end
228
376
 
377
+ # Include command aliases if defined
378
+ if self.const_defined?(:CLI_COMMAND_ALIASES)
379
+ aliases = self::CLI_COMMAND_ALIASES
380
+ if aliases.is_a?(Hash) && aliases.length > 0
381
+ action_parser.separator ""
382
+ action_parser.separator "Command aliases:"
383
+ aliases.each do |k, v|
384
+ exp_str = v.is_a?(Array) ? v.join(" ") : v.to_s
385
+ action_parser.separator "\t * #{k} -> #{exp_str}"
386
+ end
387
+ end
388
+ end
389
+
229
390
  rest = action_parser.order!(argv)
230
391
  if rest.length <= 0
231
392
  STDERR.puts("Error: No action provided")
@@ -233,7 +394,38 @@ module CLIClassTool
233
394
  exit 1
234
395
  end
235
396
 
397
+ # Expand command aliases
398
+ if self.const_defined?(:CLI_COMMAND_ALIASES)
399
+ aliases = self::CLI_COMMAND_ALIASES
400
+ if aliases.is_a?(Hash)
401
+ action_s = argv[0]
402
+ action_sym = action_s ? action_s.to_sym : nil
403
+ if aliases.key?(action_s) || (action_sym && aliases.key?(action_sym))
404
+ expansion = aliases[action_s] || aliases[action_sym]
405
+ expansion = expansion.split(' ') if expansion.is_a?(String)
406
+ argv.shift()
407
+ argv.unshift(*expansion)
408
+ end
409
+ end
410
+ end
411
+
236
412
  action_s = argv[0]
413
+
414
+ # Intercept subcommands here!
415
+ sub_actions = self.cli_sub_actions
416
+ if sub_actions.key?(action_s)
417
+ sub_cli = sub_actions[action_s]
418
+ if sub_cli.respond_to?(:verbose_log=)
419
+ sub_cli.verbose_log = self.verbose_log
420
+ end
421
+ argv.shift()
422
+ if block
423
+ exit(sub_cli.run_cli(opts, argv, &block))
424
+ else
425
+ exit(sub_cli.run_cli(opts, argv))
426
+ end
427
+ end
428
+
237
429
  action = opts[:action] = self.stringToAction(action_s)
238
430
  argv.shift()
239
431
 
@@ -255,11 +447,11 @@ module CLIClassTool
255
447
  self.setOpts(action, opts_parser, opts)
256
448
 
257
449
  # Order remaining arguments
450
+ rest = opts_parser.order!(argv)
258
451
  if opts[:ignore_opts] != true
259
- rest = opts_parser.order!(argv)
260
452
  raise("Extra Unexpected extra arguments provided: " + rest.map(){|x|"'" + x + "'"}.join(", ")) if rest.length != 0
261
453
  else
262
- opts[:extra_args] = argv
454
+ opts[:extra_args] = rest
263
455
  end
264
456
 
265
457
  # Validate options and execute action
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cli_class_tool
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nicolas Morey
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-06-05 00:00:00.000000000 Z
10
+ date: 2026-07-22 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rake
@@ -83,7 +83,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
83
83
  - !ruby/object:Gem::Version
84
84
  version: '0'
85
85
  requirements: []
86
- rubygems_version: 4.0.10
86
+ rubygems_version: 4.0.16
87
87
  specification_version: 4
88
88
  summary: A lightweight object-oriented framework for class-based command-line interface
89
89
  (CLI) applications.