cli_class_tool 0.4.1 → 0.5.1

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: 79f28a830c3277c28e747b142fb6a12d40c8a69e5b868c4fef7f58e296f79dce
4
+ data.tar.gz: ca5b3535463ee163b56c2cdda397dd925111ea325d6695268c78acf5091b19cc
5
5
  SHA512:
6
- metadata.gz: 2d04141ca190c6a98bd4481156708c9fbff7f9550324bee3612c283959ad94b90efaf33484fd77b6425f3320ecf6e2e8996422e307506f837f07785267cf5686
7
- data.tar.gz: 761917986fb8e915fc189e2d4cee710bd39ac44c968b421cca4ee65affd0d07ec7de9455a3e46e15beb1ee09110e966c72bc2be01e9746254256e50067308d88
6
+ metadata.gz: 467252b31976d4fcb70cc795dc5ceaa06d2ad24576cd75a8b5ffaef3c3d5ef93f945da0f45414dfa40a80aeb7e3f5f882308ac8e48f5b2d4629267a6254eb078
7
+ data.tar.gz: 8abc7831b9c862d928ca3632a7b9d9f99e289b5058af2fe7c095cec6845a617de7d6111f8357f879b95c79711733ede70a93e86127abaa65e3e43e0861605bb9
data/CHANGELOG CHANGED
@@ -1,3 +1,21 @@
1
+ ------------------
2
+ 0.5.1 (2026-09-07)
3
+ ------------------
4
+
5
+ * Fix actions not working on extended classes if not defined in main ones
6
+
7
+ ------------------
8
+ 0.5.0 (2026-07-22)
9
+ ------------------
10
+
11
+ * 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
12
+ * Support recursive subcommand alias and shortcut resolution within deeply nested sub-CLIClasses
13
+ * Add CLI_COMMAND_ALIASES support to define top-level command shortcuts/aliases to deeply-nested subcommand actions
14
+ * Filter list_actions command output to exclude itself, preserving clean autocomplete query behavior
15
+ * Require optparse explicitly inside utils.rb to prevent NameErrors in production environments
16
+ * Safeguard attribute resolution and _runOnClass to gracefully default when ACTION_HELP/ACTION_LIST is missing
17
+ * Add recursive nested subcommand and namespace auto-discovery support
18
+
1
19
  ------------------
2
20
  0.4.1 (2026-06-05)
3
21
  ------------------
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()
55
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)
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,12 +200,12 @@ 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
67
-
68
205
  # Resolve overridden/extended class (addon)
69
206
  class_to_use = self.respond_to?(:getExtendedClass) ? self.getExtendedClass(x) : x
70
-
207
+ next if !class_to_use.const_defined?(:ACTION_LIST) ||
208
+ class_to_use::ACTION_LIST.index(action) == nil
71
209
  if sym != nil
72
210
  has_base = x.singleton_methods().index(sym) != nil
73
211
  has_addon = class_to_use != x && class_to_use.singleton_methods().index(sym) != nil
@@ -114,7 +252,7 @@ module CLIClassTool
114
252
  def execAction(opts, action, error_class = nil)
115
253
  caught_error_class = error_class || StandardError
116
254
 
117
- self._runOnClass(action, nil) {|kClass|
255
+ ret_code = self._runOnClass(action, nil) {|kClass|
118
256
  begin
119
257
  # Use load factory method if defined, else fall back to .new
120
258
  obj = kClass.respond_to?(:load) ? kClass.load() : kClass.new()
@@ -134,6 +272,15 @@ module CLIClassTool
134
272
  end
135
273
  end
136
274
  }
275
+
276
+ if ret_code == -1 && action == :list_actions
277
+ actions = self.getActionAttr("ACTION_LIST").map(){|x| self.actionToString(x)}
278
+ actions.reject! { |x| x == "list_actions" }
279
+ puts actions.join("\n")
280
+ return 0
281
+ end
282
+
283
+ return ret_code
137
284
  end
138
285
 
139
286
  # Set verbose logging
@@ -193,7 +340,7 @@ module CLIClassTool
193
340
  # @param opts [Hash] Initial options hash
194
341
  # @param argv [Array<String>] Command line arguments (defaults to ARGV)
195
342
  # @yield [parser, phase, action_opts] Custom options setup callback block
196
- def run_cli(opts = {}, argv = ARGV)
343
+ def run_cli(opts = {}, argv = ARGV, &block)
197
344
  # Fetch actions and action helps
198
345
  action_helps = self.getActionAttr("ACTION_HELP")
199
346
 
@@ -215,7 +362,7 @@ module CLIClassTool
215
362
  col_width = max_len + 4
216
363
  action_helps.each do |k, x|
217
364
  indent = col_width - self.actionToString(k).length
218
- action_parser.separator "\t * " + self.actionToString(k) + (" " * indent) + x
365
+ action_parser.separator "\t * " + self.actionToString(k) + (" " * indent) + x.to_s
219
366
  end
220
367
 
221
368
  # Include any registered custom addon class listings if defined
@@ -226,6 +373,19 @@ module CLIClassTool
226
373
  end
227
374
  end
228
375
 
376
+ # Include command aliases if defined
377
+ if self.const_defined?(:CLI_COMMAND_ALIASES)
378
+ aliases = self::CLI_COMMAND_ALIASES
379
+ if aliases.is_a?(Hash) && aliases.length > 0
380
+ action_parser.separator ""
381
+ action_parser.separator "Command aliases:"
382
+ aliases.each do |k, v|
383
+ exp_str = v.is_a?(Array) ? v.join(" ") : v.to_s
384
+ action_parser.separator "\t * #{k} -> #{exp_str}"
385
+ end
386
+ end
387
+ end
388
+
229
389
  rest = action_parser.order!(argv)
230
390
  if rest.length <= 0
231
391
  STDERR.puts("Error: No action provided")
@@ -233,7 +393,38 @@ module CLIClassTool
233
393
  exit 1
234
394
  end
235
395
 
396
+ # Expand command aliases
397
+ if self.const_defined?(:CLI_COMMAND_ALIASES)
398
+ aliases = self::CLI_COMMAND_ALIASES
399
+ if aliases.is_a?(Hash)
400
+ action_s = argv[0]
401
+ action_sym = action_s ? action_s.to_sym : nil
402
+ if aliases.key?(action_s) || (action_sym && aliases.key?(action_sym))
403
+ expansion = aliases[action_s] || aliases[action_sym]
404
+ expansion = expansion.split(' ') if expansion.is_a?(String)
405
+ argv.shift()
406
+ argv.unshift(*expansion)
407
+ end
408
+ end
409
+ end
410
+
236
411
  action_s = argv[0]
412
+
413
+ # Intercept subcommands here!
414
+ sub_actions = self.cli_sub_actions
415
+ if sub_actions.key?(action_s)
416
+ sub_cli = sub_actions[action_s]
417
+ if sub_cli.respond_to?(:verbose_log=)
418
+ sub_cli.verbose_log = self.verbose_log
419
+ end
420
+ argv.shift()
421
+ if block
422
+ exit(sub_cli.run_cli(opts, argv, &block))
423
+ else
424
+ exit(sub_cli.run_cli(opts, argv))
425
+ end
426
+ end
427
+
237
428
  action = opts[:action] = self.stringToAction(action_s)
238
429
  argv.shift()
239
430
 
@@ -255,11 +446,11 @@ module CLIClassTool
255
446
  self.setOpts(action, opts_parser, opts)
256
447
 
257
448
  # Order remaining arguments
449
+ rest = opts_parser.order!(argv)
258
450
  if opts[:ignore_opts] != true
259
- rest = opts_parser.order!(argv)
260
451
  raise("Extra Unexpected extra arguments provided: " + rest.map(){|x|"'" + x + "'"}.join(", ")) if rest.length != 0
261
452
  else
262
- opts[:extra_args] = argv
453
+ opts[:extra_args] = rest
263
454
  end
264
455
 
265
456
  # 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.1
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-09-07 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.