Class: Clyops::Cli

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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.

Parameters:

  • name (String) (defaults to: nil) —

    Name identifying the value, command, or program.

  • root (String, nil) (defaults to: nil) —

    The root value.

  • cwd (String, nil) (defaults to: nil) —

    The cwd value.

  • env (Hash{String => String}, nil) (defaults to: nil) —

    The env value.



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.

Returns:

  • (String)


393
394
395
# File 'lib/clyops.rb', line 393

def cwd
  @cwd
end

#env ⇒ Hash{String => String} (readonly)

Environment used during parsing.

Returns:

  • (Hash{String => String})


399
400
401
# File 'lib/clyops.rb', line 399

def env
  @env
end

#name ⇒ String

Program name shown in help and completion.

Returns:

  • (String)


387
388
389
# File 'lib/clyops.rb', line 387

def name
  @name
end

#root ⇒ String (readonly)

Base directory for default and environment paths.

Returns:

  • (String)


396
397
398
# File 'lib/clyops.rb', line 396

def root
  @root
end

#values ⇒ Clyops::Values (readonly)

Resolved runtime values.

Returns:



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.

Parameters:

  • name (String) —

    Name identifying the value, command, or program.

  • description (String) (defaults to: "") —

    The description value.

  • default (String) (defaults to: "") —

    Default text, flag, optional, or empty for required.

  • rule (String) (defaults to: "") —

    Validation rule.

Returns:



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.

Parameters:

  • name (String) —

    Name identifying the value, command, or program.

  • description (String) (defaults to: "") —

    The description value.

  • rule (String) (defaults to: "") —

    Validation rule.

Returns:



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.

Parameters:

  • name (String) —

    Name identifying the value, command, or program.

  • description (String) (defaults to: "") —

    The description value.

Returns:

Raises:



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”].

Returns:

  • (Array<String>) —

    the documented result.



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.

Parameters:

  • words (Array<String>) (defaults to: []) —

    Words entered after the executable name.

Returns:

  • (String) —

    the documented result.



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
  options = []
  n = node
  while n
    options.concat(n.options)
    n = n.parent
  end
  options.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.

Parameters:

  • shell (Object) —

    The shell value.

Returns:

  • (String, nil) —

    the script, or nil for an unsupported 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.

Parameters:

  • longs (Array<String>) —

    The longs value.

Returns:



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.

Parameters:

  • long (String) —

    Long option name without –.

Returns:

  • (Boolean) —

    the documented result.



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.

Parameters:

  • name (String) —

    Name identifying the value, command, or program.

Returns:

  • (String, Integer, Float, Boolean, Array, nil) —

    the documented result.



675
# File 'lib/clyops.rb', line 675

def get(name) = @values[name]

#json_schema ⇒ String

JSON description of the CLI (spec section 8).

Returns:

  • (String) —

    the documented result.



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.

Parameters:

  • longs (Array<String>) —

    The longs value.

Returns:



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).

Parameters:

  • var (String) —

    Variable and environment key.

  • long (String) —

    Long option name without –.

  • short (String) (defaults to: "") —

    The short value.

  • default (String) (defaults to: "") —

    Default text, flag, optional, or empty for required.

  • description (String) (defaults to: "") —

    The description value.

  • group (String) (defaults to: "Options") —

    The group value.

  • rule (String) (defaults to: "") —

    Validation rule.

Returns:



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.

Parameters:

  • var (String) —

    Variable and environment key.

  • long (String) —

    Long option name without –.

  • short (String) (defaults to: "") —

    The short value.

  • description (String) (defaults to: "") —

    The description value.

  • group (String) (defaults to: "Options") —

    The group value.

  • rule (String) (defaults to: "") —

    Validation rule.

Returns:



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”.

Parameters:

  • argv (Array<String>) (defaults to: ARGV) —

    Arguments excluding the executable name.

Returns:



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.message
  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.message)
  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 = chain_options.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.

Parameters:

  • command (String) —

    The command value.

  • description (String) —

    The description value.

  • install_hint (String) (defaults to: "") —

    The install hint value.

Returns:



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.

Parameters:

  • long (String) —

    Long option name without –.

  • longs (Array<String>) —

    The longs value.

Returns:



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.

Parameters:

  • argv (Array<String>) (defaults to: ARGV) —

    Arguments excluding the executable name.

Returns:



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.

Parameters:

  • long (String) —

    Long option name without –.

Returns:

  • (Boolean) —

    the documented result.



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.

Parameters:

  • option (String) —

    The option value.

  • prefixes (String) —

    The prefixes value.

Returns:



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.

Parameters:

  • text (String) —

    The text value.

Returns:



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.

Parameters:

  • effects (Array<String>) —

    The effects value.

Returns:



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.

Parameters:

  • text (String) —

    The text value.

Returns:



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.

Parameters:

  • long (String) —

    Long option name without –.

  • dirs (String, Array<String>) —

    The dirs value.

Returns:



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.

Parameters:

  • description (String) —

    The description value.

  • content_type (String) (defaults to: "") —

    The content type value.

Returns:



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.

Parameters:

  • description (String) —

    The description value.

  • content_type (String) (defaults to: "") —

    The content type value.

Returns:



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.

Parameters:

  • long (String) —

    Long option name without –.

Returns:

  • (String) —

    the documented result.



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.

Returns:

  • (String) —

    the documented result.



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
  options = chain_options
  width = @env.fetch("CLYOPS_MAX_WIDTH", "")
  max_width = width.match?(/\A[0-9]+\z/) && width.to_i.positive? ? width.to_i : 100
  longest = (options.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(", ") }
  options.map(&:group).uniq.each do |group|
    lines = ["#{group}:"]
    options.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).

Returns:

  • (String) —

    the documented result.



692
693
694
695
696
697
698
699
700
701
# File 'lib/clyops.rb', line 692

def values_json
  out = {}
  chain_options.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