space-architect 3.0.0 → 5.0.0

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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "dry/cli"
4
4
  require "pastel"
5
+ require_relative "loop_status"
5
6
 
6
7
  module Space::Core::CLI
7
8
  # Colourful replacement for dry-cli's plain `Usage` listing — the "global
@@ -27,17 +28,30 @@ module Space::Core::CLI
27
28
 
28
29
  module_function
29
30
 
30
- def call(result, pastel: CLI.help_pastel)
31
- rows = listing(result)
32
- width = rows.map { |label, _| label.length }.max || 0
31
+ # Header for the trailing group of children that declare no phase. Left nil
32
+ # for the `space` binary (whose commands are all phase-less → one ungrouped
33
+ # listing); the `architect` binary sets it so its namespaces list under a
34
+ # "Groups" header. Keeps phase vocabulary out of this generic renderer.
35
+ def self.trailing_group_label = @trailing_group_label
36
+
37
+ def self.trailing_group_label=(label)
38
+ @trailing_group_label = label
39
+ end
33
40
 
34
- lines = rows.map do |label, description|
35
- painted = pastel.cyan(label.ljust(width))
36
- description ? " #{painted} #{pastel.dim("# #{description}")}" : " #{painted}"
41
+ def call(result, pastel: CLI.help_pastel)
42
+ groups = grouped_listing(result)
43
+ width = groups.flat_map { |_h, rows| rows.map { |label, _| label.length } }.max || 0
44
+
45
+ body = groups.flat_map do |group_header, rows|
46
+ lines = rows.map do |label, description|
47
+ painted = pastel.cyan(label.ljust(width))
48
+ description ? " #{painted} #{pastel.dim("# #{description}")}" : " #{painted}"
49
+ end
50
+ group_header ? ["", pastel.bold(group_header), *lines] : lines
37
51
  end
38
52
 
39
- [header(result, pastel), pastel.bold("Commands:"), *lines, footer(result, pastel)]
40
- .compact.join("\n")
53
+ [header(result, pastel), pastel.bold("Commands:"), *body,
54
+ footer(result, pastel), loop_status_block(result, pastel)].compact.join("\n")
41
55
  end
42
56
 
43
57
  # The richer header only makes sense at the true root (`space` / `architect`),
@@ -69,13 +83,66 @@ module Space::Core::CLI
69
83
  [prog, *names].join(" ")
70
84
  end
71
85
 
72
- # [[label_with_banner, description_or_nil], ...] sorted by command name.
73
- def listing(result)
74
- result.children.sort_by { |name, _| name }.filter_map do |name, node|
75
- next if node.hidden
86
+ # Group non-hidden children into an ordered list of [header_or_nil, rows].
87
+ # When no child declares a phase (e.g. the `space` binary), returns a single
88
+ # unlabelled group sorted by name byte-identical to the pre-phase listing.
89
+ # Otherwise groups declared commands under their phase label (groups, and
90
+ # members within a group, ordered by the declared order), with undeclared
91
+ # children (namespaces) trailing in the default group.
92
+ def grouped_listing(result)
93
+ decorated = result.children.filter_map do |name, node|
94
+ [name, node, phase_of(node)] unless node.hidden
95
+ end
96
+
97
+ if decorated.all? { |_name, _node, phase| phase.nil? }
98
+ rows = decorated.sort_by { |name, _node, _phase| name }
99
+ .map { |name, node, _phase| row(result, name, node) }
100
+ return [[nil, rows]]
101
+ end
76
102
 
77
- [label(result, name, node), description(node)]
103
+ phased, unphased = decorated.partition { |_name, _node, phase| phase }
104
+ groups = phased.group_by { |_name, _node, phase| phase.last }
105
+ listing = groups.sort_by { |_label, members| members.map { |_n, _node, phase| phase.first }.min }
106
+ .map do |label, members|
107
+ rows = members.sort_by { |_n, _node, phase| phase.first }
108
+ .map { |name, node, _phase| row(result, name, node) }
109
+ [label, rows]
78
110
  end
111
+ listing << [trailing_group_label, unphased.map { |name, node, _phase| row(result, name, node) }] \
112
+ unless unphased.empty?
113
+ listing
114
+ end
115
+
116
+ def row(result, name, node)
117
+ [label(result, name, node), description(node)]
118
+ end
119
+
120
+ def phase_of(node)
121
+ node.command.respond_to?(:phase) ? node.command.phase : nil
122
+ end
123
+
124
+ # Opportunistic, TOTAL loop-status embed: only the architect binary's ROOT
125
+ # listing gets it. Resolves the current space best-effort and renders the
126
+ # shared block from that space's own space.yaml `project` data (no
127
+ # space_architect dependency). ANY failure — no space, store failure,
128
+ # malformed yaml, no `project` block — omits the block; help always renders.
129
+ def loop_status_block(result, pastel)
130
+ return unless result.names.empty? && File.basename($PROGRAM_NAME) == "architect"
131
+
132
+ project = current_space_project
133
+ lines = project && LoopStatus.lines(project)
134
+ return if lines.nil? || lines.empty?
135
+
136
+ ["", pastel.bold("Loop:"), *lines.map { |l| " #{pastel.dim(l)}" }].join("\n")
137
+ rescue StandardError
138
+ nil
139
+ end
140
+
141
+ def current_space_project
142
+ store = Space::Core::SpaceStore.new(config: Space::Core::Config.load, state: Space::Core::State.load)
143
+ store.current.value_or(nil)&.data&.[]("project")
144
+ rescue StandardError
145
+ nil
79
146
  end
80
147
 
81
148
  def label(result, name, node)
@@ -109,15 +176,18 @@ module Space::Core::CLI
109
176
  end
110
177
 
111
178
  # Route dry-cli's plain namespace/root listing through our colourful renderer.
112
- # We replace Usage.call wholesale and depend only on the LookupResult/Node API
113
- # (children, command, leaf?/children?/hidden, names) rather than copying Usage's
114
- # internals — see notes/ruby-cli-gems-report.md.
179
+ # We prepend over Usage.call wholesale and depend only on the LookupResult/Node
180
+ # API (children, command, leaf?/children?/hidden, names) rather than copying
181
+ # Usage's internals — see notes/ruby-cli-gems-report.md.
115
182
  module Dry
116
183
  class CLI
117
184
  module Usage
118
- def self.call(result)
119
- Space::Core::CLI::Help.call(result)
185
+ module ColourPatch
186
+ def call(result)
187
+ Space::Core::CLI::Help.call(result)
188
+ end
120
189
  end
190
+ singleton_class.prepend(ColourPatch)
121
191
  end
122
192
  end
123
193
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Space::Core::CLI
4
+ # Compact Architect-Loop status block, derived from a space's own `project`
5
+ # block in space.yaml — so this carries NO dependency on space_architect. It
6
+ # is shared by the `architect --help` embed and the `space status` report.
7
+ # Returns plain lines (callers colourise) and never raises on malformed data.
8
+ module LoopStatus
9
+ module_function
10
+
11
+ # Lines for the compact block, or nil when there is no `project` block.
12
+ def lines(project)
13
+ return nil unless project.is_a?(Hash) && !project.empty?
14
+
15
+ rows = ["Project status: #{project["status"] || "(none)"}"]
16
+ iter = current_iteration(project)
17
+ rows << "Iteration: #{ordinal(iter)} #{iter["name"]} — #{state_of(iter)}" if iter
18
+ rows
19
+ end
20
+
21
+ def current_iteration(project)
22
+ name = project["current_iteration"]
23
+ return nil unless name
24
+
25
+ Array(project["iterations"]).find { |s| s["name"] == name }
26
+ end
27
+
28
+ def ordinal(iter)
29
+ iter["ordinal"] ? format("I%02d", iter["ordinal"]) : "I--"
30
+ end
31
+
32
+ # Derived loop state for the current iteration. Mirrors the precedence in
33
+ # `architect status`: a decided verdict wins, then an integrated lane
34
+ # (awaiting-verdict), then dispatched lanes, then a bare freeze, else spec.
35
+ def state_of(iter)
36
+ verdict = iter["verdict"]
37
+ return verdict if verdict && verdict != "pending"
38
+
39
+ lanes = Array(iter["lanes"])
40
+ return "awaiting-verdict" if lanes.any? { |l| l["integration_branch"] }
41
+ return "dispatched" if lanes.any?
42
+ return "frozen #{iter["freeze_sha"][0, 8]}" if iter["freeze_sha"]
43
+
44
+ "speccing"
45
+ end
46
+ end
47
+ end
@@ -6,7 +6,7 @@ require "dry/cli"
6
6
  # on each occurrence, so `-r a -r b` yields ["b"]. We want repeated flags to
7
7
  # accumulate (`-r a -r b -r c` => ["a", "b", "c"]) the way git/docker-style CLIs
8
8
  # do, while still accepting the comma form. dry-cli exposes no hook for this, so
9
- # we reopen two private seams, each mirroring dry-cli 1.4.1 with a single change:
9
+ # we prepend two private seams, each mirroring dry-cli 1.4.1 with a single change:
10
10
  #
11
11
  # * Parser.call — concat instead of assign for array options.
12
12
  # * Banner.extended_command_options — drop the "=VALUE1,VALUE2,.." hint that
@@ -14,62 +14,71 @@ require "dry/cli"
14
14
  # matching how you actually type it (-r VALUE).
15
15
  #
16
16
  # These mirror the released 1.4.1 source EXACTLY (not the dry-rb main branch,
17
- # which already differs). Pinned via `~> 1.4`; if a future dry-cli reworks these
18
- # methods, repeatable_options_test goes red and we re-sync. Rationale:
19
- # notes/ruby-cli-gems-report.md.
17
+ # which already differs), prepended onto the module's singleton class so `super`
18
+ # reaches dry-cli's own implementation instead of discarding it outright. Pinned
19
+ # via `~> 1.4`; if a future dry-cli reworks these methods, repeatable_options_test
20
+ # goes red and we re-sync. Rationale: notes/ruby-cli-gems-report.md.
20
21
  module Dry
21
22
  class CLI
22
23
  module Parser
23
- def self.call(command, arguments, prog_name)
24
- original_arguments = arguments.dup
25
- parsed_options = {}
24
+ # Reopened on Parser's own nesting (not CLI's) so `Result` resolves
25
+ # lexically, exactly as it does in dry-cli's own `Parser.call`.
26
+ module RepeatableOptionsPatch
27
+ def call(command, arguments, prog_name)
28
+ original_arguments = arguments.dup
29
+ parsed_options = {}
26
30
 
27
- OptionParser.new do |opts|
28
- command.options.each do |option|
29
- opts.on(*option.parser_options) do |value|
30
- if option.array?
31
- (parsed_options[option.name.to_sym] ||= []).concat(value)
32
- else
33
- parsed_options[option.name.to_sym] = value
31
+ OptionParser.new do |opts|
32
+ command.options.each do |option|
33
+ opts.on(*option.parser_options) do |value|
34
+ if option.array?
35
+ (parsed_options[option.name.to_sym] ||= []).concat(value)
36
+ else
37
+ parsed_options[option.name.to_sym] = value
38
+ end
34
39
  end
35
40
  end
36
- end
37
41
 
38
- opts.on_tail("-h", "--help") do
39
- return Result.help
40
- end
41
- end.parse!(arguments)
42
+ opts.on_tail("-h", "--help") do
43
+ return Result.help
44
+ end
45
+ end.parse!(arguments)
42
46
 
43
- parsed_options = command.default_params.merge(parsed_options)
44
- parse_required_params(command, arguments, prog_name, parsed_options)
45
- rescue ::OptionParser::ParseError, ValueError
46
- Result.failure("ERROR: \"#{prog_name}\" was called with arguments \"#{original_arguments.join(" ")}\"")
47
+ parsed_options = command.default_params.merge(parsed_options)
48
+ parse_required_params(command, arguments, prog_name, parsed_options)
49
+ rescue ::OptionParser::ParseError, ValueError
50
+ Result.failure("ERROR: \"#{prog_name}\" was called with arguments \"#{original_arguments.join(" ")}\"")
51
+ end
47
52
  end
53
+ singleton_class.prepend(RepeatableOptionsPatch)
48
54
  end
49
55
 
50
56
  module Banner
51
- def self.extended_command_options(command)
52
- result = command.options.map do |option|
53
- name = Inflector.dasherize(option.name)
54
- name = if option.boolean?
55
- "[no-]#{name}"
56
- elsif option.flag?
57
- name
58
- else
59
- # array options included: repeated flags accumulate, so show
60
- # the single repeatable form rather than "=VALUE1,VALUE2,..".
61
- "#{name}=VALUE"
62
- end
63
- name = "#{name}, #{option.alias_names.join(", ")}" if option.aliases.any?
64
- name = " --#{name.ljust(30)}"
65
- name = "#{name} # #{option.desc}"
66
- name = "#{name}, default: #{option.default.inspect}" unless option.default.nil?
67
- name
68
- end
57
+ module RepeatableOptionsPatch
58
+ def extended_command_options(command)
59
+ result = command.options.map do |option|
60
+ name = Inflector.dasherize(option.name)
61
+ name = if option.boolean?
62
+ "[no-]#{name}"
63
+ elsif option.flag?
64
+ name
65
+ else
66
+ # array options included: repeated flags accumulate, so show
67
+ # the single repeatable form rather than "=VALUE1,VALUE2,..".
68
+ "#{name}=VALUE"
69
+ end
70
+ name = "#{name}, #{option.alias_names.join(", ")}" if option.aliases.any?
71
+ name = " --#{name.ljust(30)}"
72
+ name = "#{name} # #{option.desc}"
73
+ name = "#{name}, default: #{option.default.inspect}" unless option.default.nil?
74
+ name
75
+ end
69
76
 
70
- result << " --#{"help, -h".ljust(30)} # Print this help"
71
- result.join("\n")
77
+ result << " --#{"help, -h".ljust(30)} # Print this help"
78
+ result.join("\n")
79
+ end
72
80
  end
81
+ singleton_class.prepend(RepeatableOptionsPatch)
73
82
  end
74
83
  end
75
84
  end
@@ -5,28 +5,69 @@ class Status < BaseCommand
5
5
  desc "Set a space status: active, paused, done, archived"
6
6
  argument :rest, type: :array, required: false, desc: "[SPACE] STATUS"
7
7
 
8
+ # One command, overloaded by its positional(s):
9
+ # help token (`help`, or dry-cli's -h/--help which never reach here) → help
10
+ # lone keyword / `<space> <keyword>` → set
11
+ # bare, or a lone non-keyword identifier → report
8
12
  def call(rest: [], **opts)
9
13
  setup_terminal(**opts.slice(:color, :colors))
14
+ args = Array(rest)
15
+ return show_help if help_token?(args)
16
+
10
17
  handle_errors do
11
- identifier, status_value = parse_status_args(Array(rest))
12
- render(store.find(identifier)) do |space|
13
- space.update_status(status_value)
14
- terminal.success "#{space.id} is #{space.status}"
15
- CLI.record_outcome(Outcome.new(exit_code: 0))
18
+ if set_args?(args)
19
+ set_status(args)
20
+ elsif args.length <= 1
21
+ report(args.first)
22
+ else
23
+ raise Space::Core::Error, "Usage: space status [SPACE] STATUS"
16
24
  end
17
25
  end
18
26
  end
19
27
 
20
28
  private
21
29
 
22
- def parse_status_args(args)
23
- case args.length
24
- when 1
25
- [nil, args.first]
26
- when 2
27
- args
28
- else
29
- raise Space::Core::Error, "Usage: space status [SPACE] STATUS"
30
+ # The bare word `help` shows the command help, like dry-cli's -h/--help (which
31
+ # it intercepts before dispatch). It must never set status to "help" or report.
32
+ def help_token?(args)
33
+ args == ["help"]
34
+ end
35
+
36
+ def show_help
37
+ out.puts Dry::CLI::Banner.call(self.class, Dry::CLI::ProgramName.call(["status"]))
38
+ CLI.record_outcome(Outcome.new(exit_code: 0))
39
+ end
40
+
41
+ # A set request: `<space> <keyword>` (two args), or a lone status keyword. A
42
+ # lone non-keyword arg is a space identifier to REPORT, not a malformed status.
43
+ def set_args?(args)
44
+ args.length == 2 ||
45
+ (args.length == 1 && Space::Core::Space::VALID_STATUSES.include?(args.first))
46
+ end
47
+
48
+ def set_status(args)
49
+ identifier, status_value = args.length == 2 ? args : [nil, args.first]
50
+ render(store.find(identifier)) do |space|
51
+ space.update_status(status_value)
52
+ terminal.success "#{space.id} is #{space.status}"
53
+ CLI.record_outcome(Outcome.new(exit_code: 0))
54
+ end
55
+ end
56
+
57
+ def report(identifier)
58
+ render(store.find(identifier)) do |space|
59
+ terminal.say "ID: #{space.id}"
60
+ terminal.say "Title: #{space.title}"
61
+ terminal.say "Status: #{space.status}"
62
+ terminal.say "Path: #{terminal.path(space.path)}"
63
+ terminal.say "Created: #{space.data['created_at']}"
64
+ terminal.say "Updated: #{space.data['updated_at']}"
65
+ lines = LoopStatus.lines(space.data["project"])
66
+ if lines
67
+ terminal.say ""
68
+ lines.each { |line| terminal.say line }
69
+ end
70
+ CLI.record_outcome(Outcome.new(exit_code: 0))
30
71
  end
31
72
  end
32
73
  end
@@ -295,7 +295,7 @@ module Space::Core
295
295
  complete -c space -f -n "__space_architect_complete_needs_command" -a path -d "Print a space path"
296
296
  complete -c space -f -n "__space_architect_complete_needs_command" -a use -d "Select and cd to a space with fish integration"
297
297
  complete -c space -f -n "__space_architect_complete_needs_command" -a current -d "Show the current space"
298
- complete -c space -f -n "__space_architect_complete_needs_command" -a status -d "Set a space status"
298
+ complete -c space -f -n "__space_architect_complete_needs_command" -a status -d "Report or set a space status"
299
299
  complete -c space -f -n "__space_architect_complete_needs_command" -a config -d "Show or update config"
300
300
  complete -c space -f -n "__space_architect_complete_needs_command" -a repo -d "Manage repos in the current space"
301
301
  complete -c space -f -n "__space_architect_complete_needs_command" -a repos -d "Manage repos in the current space"
@@ -9,6 +9,11 @@ module Space::Core
9
9
  METADATA_FILE = "space.yaml"
10
10
  VALID_STATUSES = %w[active paused done archived].freeze
11
11
 
12
+ # Canonical space.yaml shape: registry under `project:` (renamed from the
13
+ # pre-2.0 `architect:` key). Bumped from 1 in the release that did the
14
+ # rename, since that release shipped no read-side alias for it.
15
+ SCHEMA_VERSION = 2
16
+
12
17
  attr_reader :path, :data
13
18
 
14
19
  def self.load(path)
@@ -18,13 +23,58 @@ module Space::Core
18
23
  parsed = YAML.safe_load(metadata_path.read, aliases: false) || {}
19
24
  raise Error, "Space metadata must contain a YAML mapping: #{metadata_path}" unless parsed.is_a?(Hash)
20
25
 
21
- new(Pathname.new(path), stringify_keys(parsed))
26
+ data = stringify_keys(parsed)
27
+ normalize_schema!(data, metadata_path)
28
+ new(Pathname.new(path), data)
22
29
  end
23
30
 
24
31
  def self.stringify_keys(hash)
25
32
  hash.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
26
33
  end
27
34
 
35
+ # Normalizes a parsed space.yaml hash to canonical schema v2, in place:
36
+ # - future version (> SCHEMA_VERSION) → raise, refuse to misread it.
37
+ # - `architect:` only (v1a) → becomes `project:`.
38
+ # - `project:` only (v1b or v2) → left as-is.
39
+ # - both present, `project:` empty and `architect:` non-empty → the
40
+ # corruption from the old silent-default read path; take `architect:`.
41
+ # - both present and non-empty: identical → keep; differing → raise
42
+ # rather than silently pick one and lose data.
43
+ # Idempotent, so a canonical v2 space is a no-op through this method.
44
+ def self.normalize_schema!(data, metadata_path)
45
+ version = data["version"]
46
+ if version.is_a?(Integer) && version > SCHEMA_VERSION
47
+ raise Error,
48
+ "space.yaml schema version #{version} is newer than this gem supports " \
49
+ "(#{SCHEMA_VERSION}); upgrade space-architect: #{metadata_path}"
50
+ end
51
+
52
+ legacy = data.delete("architect")
53
+ current = data["project"]
54
+
55
+ data["project"] =
56
+ if legacy.nil?
57
+ current
58
+ elsif current.nil? || empty_project_block?(current)
59
+ legacy
60
+ elsif legacy == current
61
+ current
62
+ else
63
+ raise Error,
64
+ "#{metadata_path} has both 'architect:' and 'project:' blocks with " \
65
+ "conflicting content; resolve manually.\narchitect: #{legacy.inspect}\n" \
66
+ "project: #{current.inspect}"
67
+ end
68
+
69
+ data["version"] = SCHEMA_VERSION
70
+ end
71
+ private_class_method :normalize_schema!
72
+
73
+ def self.empty_project_block?(block)
74
+ block.is_a?(Hash) && Array(block["iterations"]).empty? && block["current_iteration"].nil?
75
+ end
76
+ private_class_method :empty_project_block?
77
+
28
78
  def initialize(path, data)
29
79
  @path = Pathname.new(path)
30
80
  @data = data
@@ -232,7 +232,7 @@ module Space::Core
232
232
  def metadata_for(id:, title:, timestamp:)
233
233
  iso_timestamp = timestamp.iso8601
234
234
  {
235
- "version" => 1,
235
+ "version" => 2,
236
236
  "id" => id,
237
237
  "title" => title,
238
238
  "status" => "active",
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Space
4
4
  module Core
5
- VERSION = "3.0.0"
5
+ VERSION = "5.0.0"
6
6
  end
7
7
  end
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "pastel"
4
4
  require "dry/monads"
5
- require "space_src/cli"
6
5
  require "space_src/cli/options"
7
6
  require "space_src/cloner"
8
7
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "pastel"
4
4
  require "yaml"
5
- require "space_src/cli"
6
5
  require "space_src/ui/mode"
7
6
  require "space_src/cli/options"
8
7
 
@@ -3,7 +3,6 @@
3
3
  require "pastel"
4
4
  require "fileutils"
5
5
  require "dry/monads"
6
- require "space_src/cli"
7
6
  require "space_src/ui/mode"
8
7
  require "space_src/cli/options"
9
8
  require "space_src/launchd/agent"
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "pastel"
4
4
  require "dry/monads"
5
- require "space_src/cli"
6
5
  require "space_src/ui/mode"
7
6
  require "space_src/cli/options"
8
7
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "pastel"
4
4
  require "dry/monads"
5
- require "space_src/cli"
6
5
  require "space_src/ui/mode"
7
6
  require "space_src/cli/options"
8
7
 
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "space_src/cli"
4
3
  require "space_src/shell_integration"
5
4
 
6
5
  module Space::Src
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pastel"
4
- require "space_src/cli"
5
4
  require "space_src/ui/mode"
6
5
  require "space_src/cli/options"
7
6
 
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "dry/monads"
4
- require "space_src/cli"
5
4
  require "space_src/cli/repo" # for Repo::Helpers.parse_ref
6
5
  require "space_src/cli/options"
7
6
  require "space_src/ui/mode"
data/lib/space_src/cli.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "dry/cli"
4
- require "space_src"
5
4
  require "space_src/migration"
6
5
 
7
6
  module Space::Src