Class: Clyops::Cli
- Inherits:
-
Object
- Object
- Clyops::Cli
- Defined in:
- lib/clyops.rb
Overview
A program’s command-line interface. Register options and arguments, then call run (or parse).
Instance Attribute Summary collapse
-
#cwd ⇒ String
readonly
Working directory for CLI paths.
-
#env ⇒ Hash{String => String}
readonly
Environment used during parsing.
-
#name ⇒ String
Program name shown in help and completion.
-
#root ⇒ String
readonly
Base directory for default and environment paths.
-
#values ⇒ Clyops::Values
readonly
Resolved runtime values.
Instance Method Summary collapse
-
#arg(name, description = "", default = "", rule = "") ⇒ Clyops::Cli
Register a positional argument.
-
#arg_variadic(name, description = "", rule = "") ⇒ Clyops::Cli
Register a final positional argument that collects all remaining tokens.
-
#command(name, description = "") ⇒ Clyops::Cli
Register a command (spec section 1.7) and return it, to register its options and arguments on.
-
#command_path ⇒ Array<String>
The command words selected by the last parse, e.g.
-
#completion_data(words = []) ⇒ String
Tab-separated completion records (spec section 9).
-
#completion_script(shell) ⇒ String?
Shell script that enables completion for this program (spec section 9): eval “$(prog –completion bash)”.
-
#exclusive(*longs) ⇒ Clyops::Cli
At most one of these options may be given.
-
#explicitly_set?(long) ⇒ Boolean
Whether the option came from CLI, config, or environment rather than a default.
-
#get(name) ⇒ String, ...
Return a resolved variable or argument value, or nil when absent.
-
#initialize(name: nil, root: nil, cwd: nil, env: nil) ⇒ Clyops::Cli
constructor
Create a CLI from the supplied name, root, working directory and environment; nil uses process defaults.
-
#json_schema ⇒ String
JSON description of the CLI (spec section 8).
-
#one_of(*longs) ⇒ Clyops::Cli
At least one of these options must be given.
-
#opt(var, long, short = "", default = "", description = "", group = "Options", rule = "") ⇒ Clyops::Cli
Register an option.
-
#opt_array(var, long, short = "", description = "", group = "Options", rule = "") ⇒ Clyops::Cli
Register a repeatable option whose values accumulate into a list.
-
#parse(argv = ARGV) ⇒ Clyops::ParseResult
Parse without exiting.
-
#require_command(command, description, install_hint = "") ⇒ Clyops::Cli
Require a command on PATH; description and install_hint explain a missing executable.
-
#requires(long, *longs) ⇒ Clyops::Cli
When the first option is given, the others must be too.
-
#run(argv = ARGV) ⇒ Clyops::Values
Parse like a CLI: handles –help, –help-json-schema, –completion and –bash-completion, prints errors and exits on failure.
-
#set?(long) ⇒ Boolean
Whether the option came from the command line.
-
#set_config(option, prefixes) ⇒ Clyops::Cli
‘option` holds the config file path; `prefixes` is comma-separated.
-
#set_description(text) ⇒ Clyops::Cli
Set the help description and return this CLI.
-
#set_effects(*effects) ⇒ Clyops::Cli
What running the program does: read-only, idempotent, destructive, network.
-
#set_epilog(text) ⇒ Clyops::Cli
Set the final help text and return this CLI.
-
#set_path_search(long, dirs) ⇒ Clyops::Cli
Fallback dirs (colon-separated, relative to root) for bare relative values of a path option.
-
#set_stdin(description, content_type = "") ⇒ Clyops::Cli
What the program reads on stdin; ‘content_type` is a MIME type or a comma-separated list.
-
#set_stdout(description, content_type = "") ⇒ Clyops::Cli
What the program writes on stdout; undeclared means text.
-
#source(long) ⇒ String
Where an option’s value came from: cli, config, env, default or unset.
-
#usage ⇒ String
Help text (spec section 7), for the selected command.
-
#values_json ⇒ String
Resolved values as JSON (spec section 10).
Constructor Details
#initialize(name: nil, root: nil, cwd: nil, env: nil) ⇒ Clyops::Cli
Create a CLI from the supplied name, root, working directory and environment; nil uses process defaults.
407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 |
# File 'lib/clyops.rb', line 407 def initialize(name: nil, root: nil, cwd: nil, env: nil) @name = name || File.basename($PROGRAM_NAME || "cli") @cwd = cwd || Dir.pwd @root = Clyops.normpath(@cwd, root.to_s.empty? ? "." : root) @env = env || ENV.to_h @description = "" @epilog = "" @values = Values.new @options = [] @by_long = {} @by_short = {} @args = [] @commands = [] @config_option = "" @config_prefixes = [] @raw = {} @arg_raw = {} @sources = {} @config = {} # key -> [value, dir] @effects = [] @stdin = nil @stdout = nil @constraints = [] @children = [] @parent = nil @word = "" # The command selected by the last parse (this one when it has no commands). @selected = self end |
Instance Attribute Details
#cwd ⇒ String (readonly)
Working directory for CLI paths.
393 394 395 |
# File 'lib/clyops.rb', line 393 def cwd @cwd end |
#env ⇒ Hash{String => String} (readonly)
Environment used during parsing.
399 400 401 |
# File 'lib/clyops.rb', line 399 def env @env end |
#name ⇒ String
Program name shown in help and completion.
387 388 389 |
# File 'lib/clyops.rb', line 387 def name @name end |
#root ⇒ String (readonly)
Base directory for default and environment paths.
396 397 398 |
# File 'lib/clyops.rb', line 396 def root @root end |
#values ⇒ Clyops::Values (readonly)
Resolved runtime values.
390 391 392 |
# File 'lib/clyops.rb', line 390 def values @values end |
Instance Method Details
#arg(name, description = "", default = "", rule = "") ⇒ Clyops::Cli
Register a positional argument. An empty default makes it required.
573 574 575 |
# File 'lib/clyops.rb', line 573 def arg(name, description = "", default = "", rule = "") add_arg(Arg.new(name, description, default, rule, false)) end |
#arg_variadic(name, description = "", rule = "") ⇒ Clyops::Cli
Register a final positional argument that collects all remaining tokens.
582 583 584 |
# File 'lib/clyops.rb', line 582 def arg_variadic(name, description = "", rule = "") add_arg(Arg.new(name, description, "", rule, true)) end |
#command(name, description = "") ⇒ Clyops::Cli
Register a command (spec section 1.7) and return it, to register its options and arguments on.
486 487 488 489 490 491 492 493 494 |
# File 'lib/clyops.rb', line 486 def command(name, description = "") raise DefinitionError, "Cannot mix commands and positional arguments" unless @args.empty? raise DefinitionError, "Duplicate command #{name}" if @children.any? { |c| c.word == name } child = Cli.new(name: "#{@name} #{name}", root: @root, cwd: @cwd, env: @env) child.adopt(self, name, description) @children << child child end |
#command_path ⇒ Array<String>
The command words selected by the last parse, e.g. [“db”, “migrate”].
498 499 500 501 502 503 504 505 506 |
# File 'lib/clyops.rb', line 498 def command_path words = [] node = @selected while node && !node.equal?(self) words.unshift(node.word) node = node.parent end words end |
#completion_data(words = []) ⇒ String
Tab-separated completion records (spec section 9). ‘words` are the words typed after the program name; a program with commands follows them.
822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 |
# File 'lib/clyops.rb', line 822 def completion_data(words = []) ensure_help clean = ->(s) { s.tr("\t\n", " ") } lines = ["#clyops-completion 1"] node = self unless @children.empty? skip = 0 words.each do |w| child = node.children.find { |c| c.word == w } break unless child node = child skip += 1 end return "#{lines[0]}\n" if !node.children.empty? && skip < words.length && !words[skip].start_with?("-") lines << "skip\t#{skip}" node.children.each { |c| lines << "cmd\t#{c.word}\t#{clean.(c.description)}" } end = [] n = node while n .concat(n.) n = n.parent end .each do |o| short = o.short.empty? ? "-" : "-#{o.short}" if o.kind == "flag" lines << "opt\t--#{o.long}\t#{short}\tflag\tnone\t-\t#{clean.(o.description)}" else kind, values = Clyops.completion_kind(o.rule, o.search_dirs) lines << "opt\t--#{o.long}\t#{short}\tvalue\t#{kind}\t#{values.empty? ? '-' : values}\t#{clean.(o.description)}" end lines << "opt\t--no-#{o.long}\t-\tflag\tnone\t-\t#{clean.(o.description)}" if o.bool_like? end node.args.each do |a| kind, values = Clyops.completion_kind(a.rule, []) lines << "arg\t#{a.name}\t#{a.variadic ? 'variadic' : 'single'}\t#{kind}\t#{values.empty? ? '-' : values}\t#{clean.(a.description)}" end "#{lines.join("\n")}\n" end |
#completion_script(shell) ⇒ String?
Shell script that enables completion for this program (spec section 9): eval “$(prog –completion bash)”. nil for an unknown shell.
811 812 813 814 815 816 |
# File 'lib/clyops.rb', line 811 def completion_script(shell) template = COMPLETION_SCRIPTS[shell] return nil if template.nil? template.gsub("__CLYOPS_FUNC__", @name.gsub(/[^A-Za-z0-9_]/, "_")).gsub("__CLYOPS_PROG__", @name) end |
#exclusive(*longs) ⇒ Clyops::Cli
At most one of these options may be given.
471 472 473 474 475 |
# File 'lib/clyops.rb', line 471 def exclusive(*longs) = add_constraint("exclusive", longs) # When the first option is given, the others must be too. # @param long [String] Long option name without --. # @param longs [Array<String>] The longs value. # @return [Clyops::Cli] this CLI or the new child command. |
#explicitly_set?(long) ⇒ Boolean
Whether the option came from CLI, config, or environment rather than a default.
688 |
# File 'lib/clyops.rb', line 688 def explicitly_set?(long) = %w[cli config env].include?(source(long)) |
#get(name) ⇒ String, ...
Return a resolved variable or argument value, or nil when absent.
675 |
# File 'lib/clyops.rb', line 675 def get(name) = @values[name] |
#json_schema ⇒ String
JSON description of the CLI (spec section 8).
802 803 804 805 |
# File 'lib/clyops.rb', line 802 def json_schema ensure_help Clyops.pretty_json({ "clyops" => 1, "script" => @name }.merge(schema_node)) end |
#one_of(*longs) ⇒ Clyops::Cli
At least one of these options must be given.
480 |
# File 'lib/clyops.rb', line 480 def one_of(*longs) = add_constraint("oneOf", longs) |
#opt(var, long, short = "", default = "", description = "", group = "Options", rule = "") ⇒ Clyops::Cli
Register an option. ‘default` is a value, “flag”, “optional”, or “” (required).
549 550 551 552 553 |
# File 'lib/clyops.rb', line 549 def opt(var, long, short = "", default = "", description = "", group = "Options", rule = "") kind = default == "flag" ? "flag" : "value" value = %w[flag optional].include?(default) ? "" : default add(Option.new(var, long, short, kind, value, default == "", description, group, rule, [], false)) end |
#opt_array(var, long, short = "", description = "", group = "Options", rule = "") ⇒ Clyops::Cli
Register a repeatable option whose values accumulate into a list.
563 564 565 |
# File 'lib/clyops.rb', line 563 def opt_array(var, long, short = "", description = "", group = "Options", rule = "") add(Option.new(var, long, short, "array", "", false, description, group, rule, [], false)) end |
#parse(argv = ARGV) ⇒ Clyops::ParseResult
Parse without exiting. Values are in ‘values` when status is “ok”.
591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 |
# File 'lib/clyops.rb', line 591 def parse(argv = ARGV) @raw = {} @arg_raw = {} @sources = {} @config = {} @values = Values.new @selected = self ensure_help scan_error = nil begin scan(argv.to_a) load_config unless @config_option.empty? rescue ParseError => e scan_error = e. end return ParseResult.new("help") if @raw["help"] == "true" return ParseResult.new("error", scan_error) if scan_error begin resolve rescue ParseError, ValidationError => e return ParseResult.new("error", e.) end missing_cmds = chain.reverse.flat_map { |n| n.commands }.reject { |c| which(c[0]) } unless missing_cmds.empty? detail = missing_cmds.flat_map do |cmd, desc, hint| [" #{cmd} - #{desc}"] + (hint.empty? ? [] : [" Install: #{hint}"]) end return ParseResult.new("error", "Missing required command(s): #{missing_cmds.map(&:first).join(', ')}", false, detail) end missing = .select { |o| o.required && @raw[o.long].to_s.empty? }.map { |o| "--#{o.long}" } return ParseResult.new("error", "Missing required argument(s): #{missing.join(' ')}") unless missing.empty? conflict = check_constraints return ParseResult.new("error", conflict) if conflict ParseResult.new("ok") end |
#require_command(command, description, install_hint = "") ⇒ Clyops::Cli
Require a command on PATH; description and install_hint explain a missing executable.
523 524 525 526 |
# File 'lib/clyops.rb', line 523 def require_command(command, description, install_hint = "") @commands << [command, description, install_hint] self end |
#requires(long, *longs) ⇒ Clyops::Cli
When the first option is given, the others must be too.
476 477 478 479 |
# File 'lib/clyops.rb', line 476 def requires(long, *longs) = add_constraint("requires", [long, *longs]) # At least one of these options must be given. # @param longs [Array<String>] The longs value. # @return [Clyops::Cli] this CLI or the new child command. |
#run(argv = ARGV) ⇒ Clyops::Values
Parse like a CLI: handles –help, –help-json-schema, –completion and –bash-completion, prints errors and exits on failure. Returns the values.
637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 |
# File 'lib/clyops.rb', line 637 def run(argv = ARGV) argv = argv.to_a head = argv.include?("--") ? argv[0...argv.index("--")] : argv if head.include?("--help-json-schema") $stdout.write("#{json_schema}\n") exit(0) end if head.include?("--bash-completion") $stdout.write(completion_data(argv.include?("--") ? argv[(argv.index("--") + 1)..] : [])) exit(0) end if (i = head.index("--completion")) shell = head[i + 1].to_s script = completion_script(shell) Clyops.die(1, "Unknown shell '#{shell}' (expected bash, zsh or fish)") if script.nil? $stdout.write(script) exit(0) end result = parse(argv) if result.status == "help" $stdout.write(usage) exit(0) end if result.status == "error" Clyops.emit("error", result.error, force: true) result.detail.each { |line| $stderr.write("#{line}\n") } $stderr.write(usage) if result.show_usage exit(1) end @values end |
#set?(long) ⇒ Boolean
Whether the option came from the command line.
684 685 686 687 |
# File 'lib/clyops.rb', line 684 def set?(long) = source(long) == "cli" # Whether the option came from CLI, config, or environment rather than a default. # @param long [String] Long option name without --. # @return [Boolean] the documented result. |
#set_config(option, prefixes) ⇒ Clyops::Cli
‘option` holds the config file path; `prefixes` is comma-separated.
512 513 514 515 516 |
# File 'lib/clyops.rb', line 512 def set_config(option, prefixes) @config_option = option @config_prefixes = prefixes.split(",").map(&:strip).reject(&:empty?) self end |
#set_description(text) ⇒ Clyops::Cli
Set the help description and return this CLI.
442 443 444 445 |
# File 'lib/clyops.rb', line 442 def set_description(text) = tap { @description = text } # Set the final help text and return this CLI. # @param text [String] The text value. # @return [Clyops::Cli] this CLI or the new child command. |
#set_effects(*effects) ⇒ Clyops::Cli
What running the program does: read-only, idempotent, destructive, network.
451 452 453 454 455 |
# File 'lib/clyops.rb', line 451 def set_effects(*effects) effects.each { |e| raise DefinitionError, "Unknown effect '#{e}'" unless EFFECTS.include?(e) } @effects = effects self end |
#set_epilog(text) ⇒ Clyops::Cli
Set the final help text and return this CLI.
446 |
# File 'lib/clyops.rb', line 446 def set_epilog(text) = tap { @epilog = text } |
#set_path_search(long, dirs) ⇒ Clyops::Cli
Fallback dirs (colon-separated, relative to root) for bare relative values of a path option.
532 533 534 535 536 537 538 |
# File 'lib/clyops.rb', line 532 def set_path_search(long, dirs) opt = @by_long.fetch(long) items = dirs.is_a?(String) ? dirs.split(":") : dirs opt.search_dirs = items.reject(&:empty?).map { |d| Clyops.normpath(@root, d) } opt.rule = "path" if opt.rule.empty? self end |
#set_stdin(description, content_type = "") ⇒ Clyops::Cli
What the program reads on stdin; ‘content_type` is a MIME type or a comma-separated list.
461 462 463 464 465 |
# File 'lib/clyops.rb', line 461 def set_stdin(description, content_type = "") = tap { @stdin = { "description" => description, "contentType" => content_type } } # What the program writes on stdout; undeclared means text. # @param description [String] The description value. # @param content_type [String] The content type value. # @return [Clyops::Cli] this CLI or the new child command. |
#set_stdout(description, content_type = "") ⇒ Clyops::Cli
What the program writes on stdout; undeclared means text.
466 |
# File 'lib/clyops.rb', line 466 def set_stdout(description, content_type = "") = tap { @stdout = { "description" => description, "contentType" => content_type } } |
#source(long) ⇒ String
Where an option’s value came from: cli, config, env, default or unset.
680 681 682 683 |
# File 'lib/clyops.rb', line 680 def source(long) = @sources.fetch(long.delete_prefix("--"), "unset") # Whether the option came from the command line. # @param long [String] Long option name without --. # @return [Boolean] the documented result. |
#usage ⇒ String
Help text (spec section 7), for the selected command.
707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 |
# File 'lib/clyops.rb', line 707 def usage ensure_help node = @selected = width = @env.fetch("CLYOPS_MAX_WIDTH", "") max_width = width.match?(/\A[0-9]+\z/) && width.to_i.positive? ? width.to_i : 100 longest = (.map { |o| o.label.length } + node.children.map { |c| c.word.length }).max || 0 indent = [50, [32, longest + 4].max].min text_width = [20, max_width - indent].max row = lambda do |label, text| left = " #{label}" left = left.length < indent ? left.ljust(indent) : "#{left} " lines = Clyops.wrap_text(text, text_width) ["#{left}#{lines[0]}"] + lines[1..].map { |line| (" " * indent) + line } end annotate = ->(text, notes) { notes.empty? ? text : "#{text} (#{notes.join(', ')})" } sections = [] line = "Usage: #{node.name}" line += " <command>" unless node.children.empty? node.args.each do |a| line += if a.variadic then " [<#{a.name}...>]" elsif !a.default.empty? then " [<#{a.name}>]" else " <#{a.name}>" end end sections << ["#{line} [OPTIONS]"] sections << Clyops.wrap_text(node.description, max_width) unless node.description.empty? io = [["Input:", node.stdin], ["Output:", node.stdout]].filter_map do |label, decl| next unless decl ctype = decl["contentType"] [label, decl["description"], ctype.empty? ? "" : "(#{ctype})"].reject(&:empty?).join(" ") end sections << io unless io.empty? sections << (["Commands:"] + node.children.flat_map { |c| row.(c.word, c.description) }) unless node.children.empty? unless node.args.empty? lines = ["Positional Arguments:"] node.args.each do |a| notes = (a.variadic ? ["variadic"] : []) + (a.default.empty? ? [] : ["default: #{a.default}"]) notes << "accepts: #{Clyops.describe_rule(a.rule)}" unless a.rule.empty? lines += row.(a.name, annotate.(a.description, notes)) end sections << lines end commands = chain.reverse.flat_map { |n| n.commands } unless commands.empty? lines = ["Required Commands:"] commands.each do |cmd, desc, hint| status = which(cmd) ? "installed" : "not found" lines += row.("#{cmd} [#{status}]", hint.empty? ? desc : "#{desc} (#{hint})") end sections << lines end constraints = chain.reverse.flat_map { |n| n.constraints } list = ->(longs) { longs.map { |l| "--#{l}" }.join(", ") } .map(&:group).uniq.each do |group| lines = ["#{group}:"] .select { |o| o.group == group }.each do |o| notes = [] notes << "required" if o.required notes << "multiple" if o.kind == "array" notes << "secret" if o.secret notes << "config: #{o.secret ? '***' : @config[o.long][0]}" if @config.key?(o.long) notes << "default: #{o.default}" unless o.default.empty? notes << "accepts: #{Clyops.describe_rule(o.rule)}" unless o.rule.empty? constraints.each do |c| longs = c["options"] next unless longs.include?(o.long) case c["type"] when "exclusive" then notes << "conflicts with: #{list.(longs - [o.long])}" when "requires" then notes << "requires: #{list.(longs[1..])}" if longs[0] == o.long when "oneOf" then notes << "one of: #{list.(longs)}" end end lines += row.(o.label, annotate.(o.description, notes)) end sections << lines end sections << node.epilog.sub(/\n+\z/, "").split("\n", -1) unless node.epilog.empty? text = sections.map { |s| s.join("\n") }.join("\n\n") "#{text.split("\n", -1).map(&:rstrip).join("\n")}\n" end |
#values_json ⇒ String
Resolved values as JSON (spec section 10).
692 693 694 695 696 697 698 699 700 701 |
# File 'lib/clyops.rb', line 692 def values_json out = {} .each do |o| v = @values[o.var] out[o.var] = o.secret && !v.nil? ? (v.is_a?(Array) ? v.map { "***" } : "***") : v end @selected.args.each { |a| out[a.name] = @values[a.name] } out["command"] = command_path unless @children.empty? Clyops.pretty_json(out) end |