Module: Space::Core::CLI::Help

Defined in:
lib/space_core/cli/help.rb

Overview

Colourful replacement for dry-cli's plain Usage listing — the "global help" shown by space, architect, and every bare namespace (space repo, worktree, ...). Per-command help still flows through dry-cli's Banner, whose content we like; only the listing is reskinned.

We reopen Dry::CLI::Usage.call (below) to delegate here, so BOTH the intercepted top-level help and dry-cli's own bare-namespace path get the same treatment from one place. Colour follows CLI.help_pastel, set once per invocation from the output stream's tty-ness and --color, so piped and test output stay plain. The src binary never loads space_core, so its own plain Usage is untouched.

Constant Summary collapse

TAGLINES =

One tagline per binary — the root header is served for both space and architect from here, so we pick by $PROGRAM_NAME. The default covers the test runner and any other invocation where the binary can't be identified.

{
  "space"     => "date-prefixed workspaces; repos provisioned on fibers at copy-on-write speed",
  "architect" => "the Architect Loop: structured judgment plus a fleet of headless AI builders"
}.freeze
DEFAULT_TAGLINE =
TAGLINES.fetch("space")

Class Method Summary collapse

Class Method Details

.arguments(command) ⇒ Object



164
165
166
167
168
169
170
# File 'lib/space_core/cli/help.rb', line 164

def arguments(command)
  return "" unless command.respond_to?(:required_arguments)

  names = command.required_arguments.map { |arg| arg.name.to_s.upcase }
  names += command.optional_arguments.map { |arg| "[#{arg.name.to_s.upcase}]" }
  names.empty? ? "" : " #{names.join(" ")}"
end


153
154
155
156
157
158
159
160
161
162
# File 'lib/space_core/cli/help.rb', line 153

def banner(node)
  # paths:exempt - node.children? is a dry-cli command-tree predicate, not a filesystem path
  if node.command && node.leaf? && node.children?
    " [ARGUMENT|SUBCOMMAND]"
  elsif node.leaf?
    arguments(node.command)
  else
    " [SUBCOMMAND]"
  end
end

.call(result, pastel: CLI.help_pastel) ⇒ Object



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/space_core/cli/help.rb', line 41

def call(result, pastel: CLI.help_pastel)
  groups = grouped_listing(result)
  width  = groups.flat_map { |_h, rows| rows.map { |label, _| label.length } }.max || 0

  body = groups.flat_map do |group_header, rows|
    lines = rows.map do |label, description|
      painted = pastel.cyan(label.ljust(width))
      description ? "  #{painted}   #{pastel.dim("# #{description}")}" : "  #{painted}"
    end
    group_header ? ["", pastel.bold(group_header), *lines] : lines
  end

  [header(result, pastel), pastel.bold("Commands:"), *body,
   footer(result, pastel), loop_status_block(result, pastel)].compact.join("\n")
end

.current_space_projectObject



142
143
144
145
146
147
# File 'lib/space_core/cli/help.rb', line 142

def current_space_project
  store = Space::Core::SpaceStore.new(config: Space::Core::Config.load, state: Space::Core::State.load)
  store.current.value_or(nil)&.data&.[]("project")
rescue StandardError
  nil
end

.description(node) ⇒ Object



172
173
174
175
176
# File 'lib/space_core/cli/help.rb', line 172

def description(node)
  return unless node.leaf? && node.command.respond_to?(:description)

  node.command.description
end


72
73
74
# File 'lib/space_core/cli/help.rb', line 72

def footer(result, pastel)
  "\n#{pastel.dim("Run `#{program_prefix(result)} <command> --help` for details on a command.")}"
end

.grouped_listing(result) ⇒ Object

Group non-hidden children into an ordered list of [header_or_nil, rows]. When no child declares a phase (e.g. the space binary), returns a single unlabelled group sorted by name — byte-identical to the pre-phase listing. Otherwise groups declared commands under their phase label (groups, and members within a group, ordered by the declared order), with undeclared children (namespaces) trailing in the default group.



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/space_core/cli/help.rb', line 92

def grouped_listing(result)
  # paths:exempt - result.children is a dry-cli command-tree node, not a filesystem path
  decorated = result.children.filter_map do |name, node|
    [name, node, phase_of(node)] unless node.hidden
  end

  if decorated.all? { |_name, _node, phase| phase.nil? }
    rows = decorated.sort_by { |name, _node, _phase| name }
                    .map { |name, node, _phase| row(result, name, node) }
    return [[nil, rows]]
  end

  phased, unphased = decorated.partition { |_name, _node, phase| phase }
  groups = phased.group_by { |_name, _node, phase| phase.last }
  listing = groups.sort_by { |_label, members| members.map { |_n, _node, phase| phase.first }.min }
                  .map do |label, members|
    rows = members.sort_by { |_n, _node, phase| phase.first }
                  .map { |name, node, _phase| row(result, name, node) }
    [label, rows]
  end
  listing << [trailing_group_label, unphased.map { |name, node, _phase| row(result, name, node) }] \
    unless unphased.empty?
  listing
end

.header(result, pastel) ⇒ Object

The richer header only makes sense at the true root (space / architect), not on every sub-namespace listing.



59
60
61
62
63
64
# File 'lib/space_core/cli/help.rb', line 59

def header(result, pastel)
  return unless result.names.empty?

  "#{pastel.bold.cyan("space-architect")} #{pastel.dim(Space::Core::VERSION)} " \
    "#{pastel.dim("#{tagline}")}\n"
end

.label(result, name, node) ⇒ Object



149
150
151
# File 'lib/space_core/cli/help.rb', line 149

def label(result, name, node)
  "#{program_prefix(result)} #{name}#{banner(node)}"
end

.loop_status_block(result, pastel) ⇒ Object

Opportunistic, TOTAL loop-status embed: only the architect binary's ROOT listing gets it. Resolves the current space best-effort and renders the shared block from that space's own space.yaml project data (no space_architect dependency). ANY failure — no space, store failure, malformed yaml, no project block — omits the block; help always renders.



130
131
132
133
134
135
136
137
138
139
140
# File 'lib/space_core/cli/help.rb', line 130

def loop_status_block(result, pastel)
  return unless result.names.empty? && File.basename($PROGRAM_NAME) == "architect"

  project = current_space_project
  lines = project && LoopStatus.lines(project)
  return if lines.nil? || lines.empty?

  ["", pastel.bold("Loop:"), *lines.map { |l| "  #{pastel.dim(l)}" }].join("\n")
rescue StandardError
  nil
end

.phase_of(node) ⇒ Object



121
122
123
# File 'lib/space_core/cli/help.rb', line 121

def phase_of(node)
  node.command.respond_to?(:phase) ? node.command.phase : nil
end

.program_prefix(result) ⇒ Object

"space" at the root, "space repo" inside a namespace. The space/architect binaries inject their name into ARGV, so $PROGRAM_NAME and the leading namespace segment can collide ("space space ..."); drop the duplicate.



79
80
81
82
83
84
# File 'lib/space_core/cli/help.rb', line 79

def program_prefix(result)
  prog  = File.basename($PROGRAM_NAME)
  names = result.names.dup
  names.shift if names.first == prog
  [prog, *names].join(" ")
end

.row(result, name, node) ⇒ Object



117
118
119
# File 'lib/space_core/cli/help.rb', line 117

def row(result, name, node)
  [label(result, name, node), description(node)]
end

.taglineObject

The tagline for the binary in hand: space and architect each get their own, keyed off $PROGRAM_NAME (the same signal program_prefix reads).



68
69
70
# File 'lib/space_core/cli/help.rb', line 68

def tagline
  TAGLINES.fetch(File.basename($PROGRAM_NAME), DEFAULT_TAGLINE)
end

.trailing_group_labelObject

Header for the trailing group of children that declare no phase. Left nil for the space binary (whose commands are all phase-less → one ungrouped listing); the architect binary sets it so its namespaces list under a "Groups" header. Keeps phase vocabulary out of this generic renderer.



35
# File 'lib/space_core/cli/help.rb', line 35

def self.trailing_group_label = @trailing_group_label

.trailing_group_label=(label) ⇒ Object



37
38
39
# File 'lib/space_core/cli/help.rb', line 37

def self.trailing_group_label=(label)
  @trailing_group_label = label
end