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 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
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "ace/support/cli"
5
+
6
+ puts "ace-support-cli #{Ace::Support::Cli::VERSION}"
@@ -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