ace-support-cli 0.6.2
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/CHANGELOG.md +61 -0
- data/LICENSE +1 -0
- data/README.md +35 -0
- data/Rakefile +13 -0
- data/exe/ace-support-cli +6 -0
- data/lib/ace/support/cli/argv_coalescer.rb +66 -0
- data/lib/ace/support/cli/base.rb +76 -0
- data/lib/ace/support/cli/command.rb +68 -0
- data/lib/ace/support/cli/error.rb +71 -0
- data/lib/ace/support/cli/errors.rb +20 -0
- data/lib/ace/support/cli/help/banner.rb +238 -0
- data/lib/ace/support/cli/help/concise.rb +66 -0
- data/lib/ace/support/cli/help/help_command.rb +67 -0
- data/lib/ace/support/cli/help/two_tier_help.rb +24 -0
- data/lib/ace/support/cli/help/usage.rb +178 -0
- data/lib/ace/support/cli/help/version_command.rb +38 -0
- data/lib/ace/support/cli/models/argument.rb +31 -0
- data/lib/ace/support/cli/models/option.rb +52 -0
- data/lib/ace/support/cli/parser.rb +272 -0
- data/lib/ace/support/cli/registry.rb +86 -0
- data/lib/ace/support/cli/registry_dsl.rb +44 -0
- data/lib/ace/support/cli/runner.rb +69 -0
- data/lib/ace/support/cli/standard_options.rb +14 -0
- data/lib/ace/support/cli/version.rb +9 -0
- data/lib/ace/support/cli.rb +36 -0
- metadata +71 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5f90e1991953f285459a32cb147c449db7e0e8156962690106734d1260fbee56
|
|
4
|
+
data.tar.gz: c974cdf497c7b6885a3a5cd29dce34cf531b0b4f257dd249012382e42c478e90
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 192b5345131894c1c0bd44bbb53887411361eadba6d82d8306e3fb15cb1bc0b7b2df34433ea29dfb51f40963e9be7764c3ecec1a2827d35a91421b2701131a1c
|
|
7
|
+
data.tar.gz: 90954c90d0cdeb4984ba8bc9fc8a5ba047b2b01f9ab39bf295d5713b6f31c907f888629fe10bbd223b4dbdaa22164163bc7130b521e279b8760b5676fa174fad
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.6.2] - 2026-03-22
|
|
8
|
+
|
|
9
|
+
### Technical
|
|
10
|
+
- Removed trailing blank lines in README code fences for installation and basic usage examples.
|
|
11
|
+
|
|
12
|
+
## [0.6.1] - 2026-03-22
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- Expanded the README with a clear package tagline, installation guidance, runnable basic usage example, API overview, and Part of ACE footer.
|
|
16
|
+
|
|
17
|
+
## [0.6.0] - 2026-03-18
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- Moved `Error`, `Base`, `StandardOptions`, and `RegistryDsl` classes from ace-support-core into ace-support-cli as their canonical home.
|
|
21
|
+
- Added `.module(gem_name:, version:)` factory to `VersionCommand` for dynamic version display modules.
|
|
22
|
+
- Added `argument :args` to `HelpCommand` for accepting trailing arguments.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- Runner now raises `Ace::Support::Cli::Error` directly instead of bridging through `Ace::Core::CLI::Error`.
|
|
26
|
+
|
|
27
|
+
## [0.5.1] - 2026-03-17
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- Restored compatibility for repeated scalar options, `key=value` hash options, and `--` passthrough handling so migrated ACE CLIs preserve existing argument semantics.
|
|
31
|
+
- Normalized help and command resolution behavior: top-level help now renders without raw command lookup failures, rich help no longer exits the process directly, and usage rendering supports real ACE registry metadata shapes.
|
|
32
|
+
- Fixed the public `ArgvCoalescer` contract by loading it from the top-level entrypoint and aligning the canonical constant name with the file path while keeping the legacy alias available.
|
|
33
|
+
|
|
34
|
+
## [0.5.0] - 2026-03-17
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
- Rich `--help` interception in Parser: commands with `desc` or `examples` metadata now render structured help (NAME, USAGE, DESCRIPTION, OPTIONS, EXAMPLES) via the existing Banner/Concise/TwoTierHelp formatters instead of OptionParser's bare-bones output.
|
|
38
|
+
- Runner passes computed command name (e.g., `ace-task show`) to Parser for accurate help rendering.
|
|
39
|
+
- `PROGRAM_NAME` constant lookup on registry modules for correct program name resolution.
|
|
40
|
+
|
|
41
|
+
## [0.4.0] - 2026-03-15
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
- Runner improvements: enhanced command runner lifecycle with better error propagation and exit code handling
|
|
45
|
+
- Registry DSL support: added declarative registry definition helpers for cleaner command registration
|
|
46
|
+
- Parse error re-raising: parse errors now propagate with structured context for downstream error handling
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
- Removed runtime dependency on `ace-support-core` to avoid circular dependency during support-core CLI migration.
|
|
50
|
+
|
|
51
|
+
## [0.3.0] - 2026-03-14
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
- Added a native help subsystem with full banner rendering, concise `-h` rendering, registry usage rendering, and two-tier help dispatch helpers.
|
|
55
|
+
- Added `HelpCommand` and `VersionCommand` factory modules in `Ace::Support::Cli`.
|
|
56
|
+
- Added focused tests for help rendering and dispatch behavior.
|
|
57
|
+
|
|
58
|
+
## [0.2.0] - 2026-03-13
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
- Initial `ace-support-cli` gem scaffold with core command/parsing/runtime classes.
|
data/LICENSE
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
MIT License
|
data/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1> ACE - Support CLI </h1>
|
|
3
|
+
|
|
4
|
+
Shared command primitives for consistent ACE CLI behavior.
|
|
5
|
+
|
|
6
|
+
<img src="https://raw.githubusercontent.com/cs3b/ace/main/docs/brand/AgenticCodingEnvironment.Logo.XS.jpg" alt="ACE Logo" width="480">
|
|
7
|
+
<br><br>
|
|
8
|
+
|
|
9
|
+
<a href="https://rubygems.org/gems/ace-support-cli"><img alt="Gem Version" src="https://img.shields.io/gem/v/ace-support-cli.svg" /></a>
|
|
10
|
+
<a href="https://www.ruby-lang.org"><img alt="Ruby" src="https://img.shields.io/badge/Ruby-3.2+-CC342D?logo=ruby" /></a>
|
|
11
|
+
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-blue.svg" /></a>
|
|
12
|
+
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
> Works with: Claude Code, Codex CLI, OpenCode, Gemini CLI, pi-agent, and more.
|
|
16
|
+
|
|
17
|
+
`ace-support-cli` is the foundation layer for ACE commands, providing metadata-driven command definitions, parser behavior, and execution orchestration. Packages like [ace-llm](../ace-llm), [ace-review](../ace-review), and [ace-search](../ace-search) build their CLI surfaces on top of these shared primitives.
|
|
18
|
+
|
|
19
|
+
## How It Works
|
|
20
|
+
|
|
21
|
+
1. Package commands extend shared `Command` base classes and define arguments/options declaratively.
|
|
22
|
+
2. Parsers normalize argv into structured Ruby types and route to a command registry.
|
|
23
|
+
3. Runners execute command objects with consistent help, error, and exit semantics.
|
|
24
|
+
|
|
25
|
+
## Use Cases
|
|
26
|
+
|
|
27
|
+
**Build a new ACE CLI tool quickly** - reuse shared conventions for command declaration, option parsing, and execution so new packages like [ace-retro](../ace-retro) or [ace-sim](../ace-sim) get consistent behavior from day one.
|
|
28
|
+
|
|
29
|
+
**Standardize command behavior across packages** - enforce predictable option parsing, help text, and exit codes for both human and agent callers.
|
|
30
|
+
|
|
31
|
+
**Keep agent invocations safe** - preserve a stable CLI contract so coding agents can call `ace-*` tools reliably in mixed human/agent workflows.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
Part of [ACE](https://github.com/cs3b/ace)
|
data/Rakefile
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
|
|
6
|
+
Rake::TestTask.new(:test) do |t|
|
|
7
|
+
t.libs << "test"
|
|
8
|
+
t.libs << "lib"
|
|
9
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
task spec: :test
|
|
13
|
+
task default: :test
|
data/exe/ace-support-cli
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Support
|
|
5
|
+
module Cli
|
|
6
|
+
module ArgvCoalescer
|
|
7
|
+
def self.call(argv, flags:, separator: ",")
|
|
8
|
+
normalized = normalize_flags(flags)
|
|
9
|
+
accum = normalized.values.to_h { |canonical| [canonical, []] }
|
|
10
|
+
passthrough = []
|
|
11
|
+
|
|
12
|
+
i = 0
|
|
13
|
+
while i < argv.length
|
|
14
|
+
token = argv[i]
|
|
15
|
+
flag = token.include?("=") ? token.split("=", 2)[0] : token
|
|
16
|
+
canonical = normalized[flag]
|
|
17
|
+
|
|
18
|
+
if canonical
|
|
19
|
+
value = extract_value(token, argv, i)
|
|
20
|
+
accum[canonical] << value unless value.to_s.empty?
|
|
21
|
+
i = next_index(token, argv, i)
|
|
22
|
+
else
|
|
23
|
+
passthrough << token
|
|
24
|
+
i += 1
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
result = passthrough.dup
|
|
29
|
+
accum.each do |canonical, values|
|
|
30
|
+
next if values.empty?
|
|
31
|
+
|
|
32
|
+
result << canonical
|
|
33
|
+
result << values.join(separator)
|
|
34
|
+
end
|
|
35
|
+
result
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.normalize_flags(flags)
|
|
39
|
+
flags.each_with_object({}) do |(canonical, aliases), memo|
|
|
40
|
+
memo[canonical] = canonical
|
|
41
|
+
aliases.each { |entry| memo[entry] = canonical }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
private_class_method :normalize_flags
|
|
45
|
+
|
|
46
|
+
def self.extract_value(token, argv, index)
|
|
47
|
+
return token.split("=", 2)[1] if token.include?("=")
|
|
48
|
+
return argv[index + 1] if index + 1 < argv.length && !argv[index + 1].start_with?("-")
|
|
49
|
+
|
|
50
|
+
""
|
|
51
|
+
end
|
|
52
|
+
private_class_method :extract_value
|
|
53
|
+
|
|
54
|
+
def self.next_index(token, argv, index)
|
|
55
|
+
return index + 1 if token.include?("=")
|
|
56
|
+
return index + 2 if index + 1 < argv.length && !argv[index + 1].start_with?("-")
|
|
57
|
+
|
|
58
|
+
index + 1
|
|
59
|
+
end
|
|
60
|
+
private_class_method :next_index
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
ArgvCollector = ArgvCoalescer
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
require_relative "standard_options"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Support
|
|
8
|
+
module Cli
|
|
9
|
+
# Shared CLI helper methods and option constants used across ACE commands.
|
|
10
|
+
module Base
|
|
11
|
+
STANDARD_OPTIONS = %i[quiet verbose debug].freeze
|
|
12
|
+
RESERVED_FLAGS = %i[h v q d o].freeze
|
|
13
|
+
|
|
14
|
+
def verbose?(options)
|
|
15
|
+
options[:verbose] == true
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def quiet?(options)
|
|
19
|
+
options[:quiet] == true
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def debug?(options)
|
|
23
|
+
options[:debug] == true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def help?(options)
|
|
27
|
+
options[:help] == true || options[:h] == true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def debug_log(message, options)
|
|
31
|
+
warn "DEBUG: #{message}" if debug?(options)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def raise_cli_error(message, exit_code: 1)
|
|
35
|
+
raise Ace::Support::Cli::Error.new(message, exit_code: exit_code)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def validate_required!(options, *required)
|
|
39
|
+
missing = required - options.keys.select { |key| !options[key].nil? }
|
|
40
|
+
return if missing.empty?
|
|
41
|
+
|
|
42
|
+
raise ArgumentError, "Missing required options: #{missing.join(", ")}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def format_pairs(hash)
|
|
46
|
+
hash.map { |key, value| "#{key}=#{value}" }.join(" ")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Type coercion for CLI option values.
|
|
50
|
+
def coerce_types(options, conversions)
|
|
51
|
+
conversions.each do |key, type|
|
|
52
|
+
next if options[key].nil?
|
|
53
|
+
|
|
54
|
+
case type
|
|
55
|
+
when :integer
|
|
56
|
+
begin
|
|
57
|
+
options[key] = Integer(options[key])
|
|
58
|
+
rescue ArgumentError, TypeError
|
|
59
|
+
raise ArgumentError, "Invalid value for --#{key.to_s.tr("_", "-")}: " \
|
|
60
|
+
"'#{options[key]}' is not a valid integer"
|
|
61
|
+
end
|
|
62
|
+
when :float
|
|
63
|
+
begin
|
|
64
|
+
options[key] = Float(options[key])
|
|
65
|
+
rescue ArgumentError, TypeError
|
|
66
|
+
raise ArgumentError, "Invalid value for --#{key.to_s.tr("_", "-")}: " \
|
|
67
|
+
"'#{options[key]}' is not a valid number"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
options
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "models/option"
|
|
4
|
+
require_relative "models/argument"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Support
|
|
8
|
+
module Cli
|
|
9
|
+
class Command
|
|
10
|
+
class << self
|
|
11
|
+
attr_reader :description
|
|
12
|
+
|
|
13
|
+
def inherited(subclass)
|
|
14
|
+
super
|
|
15
|
+
subclass.instance_variable_set(:@description, description)
|
|
16
|
+
subclass.instance_variable_set(:@options, options.dup)
|
|
17
|
+
subclass.instance_variable_set(:@arguments, arguments.dup)
|
|
18
|
+
subclass.instance_variable_set(:@examples, examples.dup)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def desc(text)
|
|
22
|
+
@description = text
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def option(name, type: :string, default: nil, desc: "", aliases: [], values: nil, required: false, repeat: false, **_extra)
|
|
26
|
+
@options ||= []
|
|
27
|
+
@options << Models::Option.new(
|
|
28
|
+
name: name,
|
|
29
|
+
type: type,
|
|
30
|
+
default: default,
|
|
31
|
+
desc: desc,
|
|
32
|
+
aliases: aliases,
|
|
33
|
+
values: values,
|
|
34
|
+
required: required,
|
|
35
|
+
repeat: repeat
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def argument(name, type: :string, required: true, desc: "")
|
|
40
|
+
@arguments ||= []
|
|
41
|
+
@arguments << Models::Argument.new(name: name, type: type, required: required, desc: desc)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def example(lines)
|
|
45
|
+
@examples ||= []
|
|
46
|
+
@examples.concat(Array(lines).map(&:to_s))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def options
|
|
50
|
+
@options ||= []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def arguments
|
|
54
|
+
@arguments ||= []
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def examples
|
|
58
|
+
@examples ||= []
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def call(**_params)
|
|
63
|
+
raise NotImplementedError, "#{self.class} must implement #call"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Support
|
|
5
|
+
module Cli
|
|
6
|
+
# Exception raised to signal non-zero exit code from CLI commands.
|
|
7
|
+
#
|
|
8
|
+
# This exception is used in the exception-based exit code pattern
|
|
9
|
+
# defined in ADR-023. Commands raise this error on failure, and
|
|
10
|
+
# the exe wrapper catches it and exits with the specified code.
|
|
11
|
+
#
|
|
12
|
+
# @example Raising from a command
|
|
13
|
+
# def call(file:, **options)
|
|
14
|
+
# raise Error.new("file required") if file.nil?
|
|
15
|
+
#
|
|
16
|
+
# result = do_work(file)
|
|
17
|
+
#
|
|
18
|
+
# if result[:success]
|
|
19
|
+
# puts result[:message]
|
|
20
|
+
# # Success - no exception, exits 0
|
|
21
|
+
# else
|
|
22
|
+
# raise Error.new(result[:error])
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# @example Catching in exe wrapper
|
|
27
|
+
# # exe/ace-gem
|
|
28
|
+
# begin
|
|
29
|
+
# Ace::Gem::CLI.start(ARGV)
|
|
30
|
+
# rescue Ace::Support::Cli::Error => e
|
|
31
|
+
# warn e.message
|
|
32
|
+
# exit(e.exit_code)
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# @see ADR-023 CLI framework conventions
|
|
36
|
+
class Error < StandardError
|
|
37
|
+
# Exit code to return when this exception is caught
|
|
38
|
+
# @return [Integer]
|
|
39
|
+
attr_reader :exit_code
|
|
40
|
+
|
|
41
|
+
# Original error message without prefix
|
|
42
|
+
# @return [String]
|
|
43
|
+
attr_reader :original_message
|
|
44
|
+
|
|
45
|
+
# Initialize a new CLI error
|
|
46
|
+
#
|
|
47
|
+
# @param message [String] Error message to display
|
|
48
|
+
# @param exit_code [Integer] Exit code (default: 1)
|
|
49
|
+
def initialize(message, exit_code: 1)
|
|
50
|
+
@original_message = message
|
|
51
|
+
super(message)
|
|
52
|
+
@exit_code = exit_code
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Return the original message without prefix.
|
|
56
|
+
# This ensures .message returns what was passed to the constructor.
|
|
57
|
+
# @return [String] Original message
|
|
58
|
+
def message
|
|
59
|
+
@original_message
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Prepend "Error: " to message for consistent user-facing output.
|
|
63
|
+
# exe wrappers use warn e.to_s which calls this method.
|
|
64
|
+
# @return [String] Message with "Error: " prefix
|
|
65
|
+
def to_s
|
|
66
|
+
"Error: #{@original_message}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Support
|
|
5
|
+
module Cli
|
|
6
|
+
class ParseError < StandardError; end
|
|
7
|
+
class CommandNotFoundError < StandardError; end
|
|
8
|
+
|
|
9
|
+
class HelpRendered < StandardError
|
|
10
|
+
attr_reader :output, :status
|
|
11
|
+
|
|
12
|
+
def initialize(output, status: 0)
|
|
13
|
+
@output = output
|
|
14
|
+
@status = status
|
|
15
|
+
super("Help rendered")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Support
|
|
5
|
+
module Cli
|
|
6
|
+
module Help
|
|
7
|
+
module Banner
|
|
8
|
+
COLUMN_WIDTH = 34
|
|
9
|
+
|
|
10
|
+
def self.call(command, name)
|
|
11
|
+
[
|
|
12
|
+
section_name(command, name),
|
|
13
|
+
section_usage(command, name),
|
|
14
|
+
section_description(command),
|
|
15
|
+
section_subcommands(command),
|
|
16
|
+
section_arguments(command),
|
|
17
|
+
section_options(command),
|
|
18
|
+
section_examples(command, name)
|
|
19
|
+
].compact.join("\n\n")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.section_name(command, name)
|
|
23
|
+
summary = first_line(description(command))
|
|
24
|
+
line = summary ? "#{name} - #{summary}" : name.to_s
|
|
25
|
+
"NAME\n #{line}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.section_usage(command, name)
|
|
29
|
+
usage = "#{name}#{arguments_synopsis(command)}"
|
|
30
|
+
usage += " [OPTIONS]" if options(command).any?
|
|
31
|
+
usage += " | #{name} SUBCOMMAND" if subcommands(command).any?
|
|
32
|
+
"USAGE\n #{usage}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.section_description(command)
|
|
36
|
+
text = description(command)
|
|
37
|
+
return nil if text.nil?
|
|
38
|
+
|
|
39
|
+
lines = text.to_s.strip.split("\n")
|
|
40
|
+
return nil if lines.size <= 1
|
|
41
|
+
|
|
42
|
+
rest = lines.drop(1).drop_while { |line| line.strip.empty? }
|
|
43
|
+
return nil if rest.empty?
|
|
44
|
+
|
|
45
|
+
"DESCRIPTION\n#{rest.map { |line| " #{line.strip}" }.join("\n")}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.section_subcommands(command)
|
|
49
|
+
entries = subcommands(command)
|
|
50
|
+
return nil if entries.empty?
|
|
51
|
+
|
|
52
|
+
lines = entries.filter_map do |name, subcommand|
|
|
53
|
+
next if hidden?(subcommand)
|
|
54
|
+
|
|
55
|
+
desc = description(subcommand)
|
|
56
|
+
" #{name.to_s.ljust(COLUMN_WIDTH)}#{first_line(desc)}"
|
|
57
|
+
end
|
|
58
|
+
return nil if lines.empty?
|
|
59
|
+
|
|
60
|
+
"SUBCOMMANDS\n#{lines.join("\n")}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.section_arguments(command)
|
|
64
|
+
args = arguments(command)
|
|
65
|
+
return nil if args.empty?
|
|
66
|
+
|
|
67
|
+
lines = args.map do |arg|
|
|
68
|
+
label = arg.name.to_s.upcase
|
|
69
|
+
label = "[#{label}]" unless argument_required?(arg)
|
|
70
|
+
details = []
|
|
71
|
+
details << argument_desc(arg) unless argument_desc(arg).to_s.empty?
|
|
72
|
+
details << "(required)" if argument_required?(arg)
|
|
73
|
+
" #{label.ljust(COLUMN_WIDTH)}#{details.join(" ")}"
|
|
74
|
+
end
|
|
75
|
+
"ARGUMENTS\n#{lines.join("\n")}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.section_options(command)
|
|
79
|
+
lines = options(command).map { |option| format_option(option) }
|
|
80
|
+
lines << " #{"--help, -h".ljust(COLUMN_WIDTH)}Show this help"
|
|
81
|
+
"OPTIONS\n#{lines.join("\n")}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.section_examples(command, name)
|
|
85
|
+
items = examples(command)
|
|
86
|
+
return nil if items.empty?
|
|
87
|
+
|
|
88
|
+
lines = items.map do |item|
|
|
89
|
+
cleaned = item.to_s.sub(/\A#{Regexp.escape(name)}\s*/, "")
|
|
90
|
+
" $ #{name} #{cleaned}".rstrip
|
|
91
|
+
end
|
|
92
|
+
"EXAMPLES\n#{lines.join("\n")}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.format_option(option)
|
|
96
|
+
rendered = option_name(option)
|
|
97
|
+
rendered = "#{rendered}, #{option_aliases(option).join(", ")}" if option_aliases(option).any?
|
|
98
|
+
label = " --#{rendered}"
|
|
99
|
+
|
|
100
|
+
details = []
|
|
101
|
+
desc = option_desc(option)
|
|
102
|
+
details << desc unless desc.to_s.empty?
|
|
103
|
+
values = option_values(option)
|
|
104
|
+
details << "(values: #{Array(values).join(", ")})" if values && !Array(values).empty?
|
|
105
|
+
default = option_default(option)
|
|
106
|
+
details << "(default: #{default.inspect})" unless default.nil?
|
|
107
|
+
details << "(required)" if option_required?(option)
|
|
108
|
+
|
|
109
|
+
return label if details.empty?
|
|
110
|
+
|
|
111
|
+
"#{label.ljust(COLUMN_WIDTH + 2)}#{details.join(" ")}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.option_name(option)
|
|
115
|
+
name = dasherize(option_name_raw(option))
|
|
116
|
+
if option_boolean?(option)
|
|
117
|
+
"[no-]#{name}"
|
|
118
|
+
elsif option_array?(option)
|
|
119
|
+
"#{name}=VALUE1,VALUE2,.."
|
|
120
|
+
elsif option_flag?(option)
|
|
121
|
+
name
|
|
122
|
+
else
|
|
123
|
+
"#{name}=VALUE"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.arguments_synopsis(command)
|
|
128
|
+
required = arguments(command).select { |arg| argument_required?(arg) }.map { |arg| arg.name.to_s.upcase }
|
|
129
|
+
optional = arguments(command).reject { |arg| argument_required?(arg) }.map { |arg| "[#{arg.name.to_s.upcase}]" }
|
|
130
|
+
values = required + optional
|
|
131
|
+
values.empty? ? "" : " #{values.join(" ")}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.description(command)
|
|
135
|
+
command.respond_to?(:description) ? command.description : nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def self.subcommands(command)
|
|
139
|
+
return [] unless command.respond_to?(:subcommands)
|
|
140
|
+
|
|
141
|
+
value = command.subcommands
|
|
142
|
+
return value.to_a if value.respond_to?(:to_a)
|
|
143
|
+
|
|
144
|
+
[]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def self.hidden?(command)
|
|
148
|
+
command.respond_to?(:hidden) && command.hidden
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def self.arguments(command)
|
|
152
|
+
return command.arguments if command.respond_to?(:arguments)
|
|
153
|
+
return [] unless command.respond_to?(:required_arguments) && command.respond_to?(:optional_arguments)
|
|
154
|
+
|
|
155
|
+
command.required_arguments + command.optional_arguments
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self.argument_required?(argument)
|
|
159
|
+
argument.respond_to?(:required?) ? argument.required? : !!argument.required
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def self.argument_desc(argument)
|
|
163
|
+
argument.respond_to?(:desc) ? argument.desc : nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def self.options(command)
|
|
167
|
+
command.respond_to?(:options) ? command.options : []
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def self.examples(command)
|
|
171
|
+
command.respond_to?(:examples) ? command.examples : []
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def self.option_name_raw(option)
|
|
175
|
+
return option.name if option.respond_to?(:name)
|
|
176
|
+
return option.option_name if option.respond_to?(:option_name)
|
|
177
|
+
|
|
178
|
+
"option"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def self.option_aliases(option)
|
|
182
|
+
return option.alias_names if option.respond_to?(:alias_names)
|
|
183
|
+
return option.aliases if option.respond_to?(:aliases)
|
|
184
|
+
|
|
185
|
+
[]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def self.option_desc(option)
|
|
189
|
+
option.respond_to?(:desc) ? option.desc : nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def self.option_default(option)
|
|
193
|
+
option.respond_to?(:default) ? option.default : nil
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def self.option_values(option)
|
|
197
|
+
option.respond_to?(:values) ? option.values : nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def self.option_required?(option)
|
|
201
|
+
return option.required if option.respond_to?(:required)
|
|
202
|
+
return option.required? if option.respond_to?(:required?)
|
|
203
|
+
|
|
204
|
+
false
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def self.option_boolean?(option)
|
|
208
|
+
return option.boolean? if option.respond_to?(:boolean?)
|
|
209
|
+
|
|
210
|
+
option.respond_to?(:type) && option.type.to_sym == :boolean
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def self.option_array?(option)
|
|
214
|
+
return option.array? if option.respond_to?(:array?)
|
|
215
|
+
|
|
216
|
+
option.respond_to?(:type) && option.type.to_sym == :array
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def self.option_flag?(option)
|
|
220
|
+
return option.flag? if option.respond_to?(:flag?)
|
|
221
|
+
|
|
222
|
+
false
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def self.first_line(text)
|
|
226
|
+
return nil if text.nil?
|
|
227
|
+
|
|
228
|
+
text.to_s.strip.split("\n").first&.strip
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def self.dasherize(value)
|
|
232
|
+
value.to_s.tr("_", "-")
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|