space-cadet 9.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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +279 -0
  5. data/exe/space +13 -0
  6. data/lib/space_core/atomic_write.rb +21 -0
  7. data/lib/space_core/cli/base_command.rb +19 -0
  8. data/lib/space_core/cli/build.rb +27 -0
  9. data/lib/space_core/cli/config.rb +49 -0
  10. data/lib/space_core/cli/current.rb +16 -0
  11. data/lib/space_core/cli/help.rb +195 -0
  12. data/lib/space_core/cli/helpers.rb +115 -0
  13. data/lib/space_core/cli/init.rb +29 -0
  14. data/lib/space_core/cli/list.rb +24 -0
  15. data/lib/space_core/cli/loop_status.rb +47 -0
  16. data/lib/space_core/cli/new.rb +38 -0
  17. data/lib/space_core/cli/pack.rb +29 -0
  18. data/lib/space_core/cli/path.rb +16 -0
  19. data/lib/space_core/cli/repeatable_options.rb +84 -0
  20. data/lib/space_core/cli/repo.rb +76 -0
  21. data/lib/space_core/cli/run.rb +44 -0
  22. data/lib/space_core/cli/shell.rb +125 -0
  23. data/lib/space_core/cli/show.rb +21 -0
  24. data/lib/space_core/cli/status.rb +74 -0
  25. data/lib/space_core/cli/use.rb +17 -0
  26. data/lib/space_core/cli.rb +177 -0
  27. data/lib/space_core/cloner.rb +77 -0
  28. data/lib/space_core/commands.rb +51 -0
  29. data/lib/space_core/config.rb +152 -0
  30. data/lib/space_core/errors.rb +14 -0
  31. data/lib/space_core/git_client.rb +49 -0
  32. data/lib/space_core/mise_client.rb +37 -0
  33. data/lib/space_core/oci_builder.rb +56 -0
  34. data/lib/space_core/oci_packer.rb +103 -0
  35. data/lib/space_core/oci_runner.rb +89 -0
  36. data/lib/space_core/paths.rb +58 -0
  37. data/lib/space_core/repo_reference.rb +19 -0
  38. data/lib/space_core/repo_resolver.rb +167 -0
  39. data/lib/space_core/scm/client.rb +87 -0
  40. data/lib/space_core/scm/git.rb +232 -0
  41. data/lib/space_core/scm/status.rb +24 -0
  42. data/lib/space_core/shell.rb +90 -0
  43. data/lib/space_core/shell_integration.rb +438 -0
  44. data/lib/space_core/slugger.rb +16 -0
  45. data/lib/space_core/space.rb +174 -0
  46. data/lib/space_core/space_store.rb +319 -0
  47. data/lib/space_core/state.rb +86 -0
  48. data/lib/space_core/templates/oci/dockerfile.erb +73 -0
  49. data/lib/space_core/templates/oci/dockerignore.erb +17 -0
  50. data/lib/space_core/templates/oci/entrypoint.sh.erb +16 -0
  51. data/lib/space_core/terminal.rb +161 -0
  52. data/lib/space_core/version.rb +7 -0
  53. data/lib/space_core/warnings.rb +13 -0
  54. data/lib/space_core/xdg.rb +33 -0
  55. data/lib/space_core.rb +26 -0
  56. metadata +199 -0
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require "pastel"
5
+ require_relative "loop_status"
6
+
7
+ module Space::Core::CLI
8
+ # Colourful replacement for dry-cli's plain `Usage` listing — the "global
9
+ # help" shown by `space`, `architect`, and every bare namespace (`space repo`,
10
+ # `worktree`, ...). Per-command help still flows through dry-cli's Banner,
11
+ # whose content we like; only the listing is reskinned.
12
+ #
13
+ # We reopen Dry::CLI::Usage.call (below) to delegate here, so BOTH the
14
+ # intercepted top-level help and dry-cli's own bare-namespace path get the
15
+ # same treatment from one place. Colour follows CLI.help_pastel, set once per
16
+ # invocation from the output stream's tty-ness and --color, so piped and test
17
+ # output stay plain. The `src` binary never loads space_core, so its own plain
18
+ # Usage is untouched.
19
+ module Help
20
+ # One tagline per binary — the root header is served for both `space` and
21
+ # `architect` from here, so we pick by $PROGRAM_NAME. The default covers the
22
+ # test runner and any other invocation where the binary can't be identified.
23
+ TAGLINES = {
24
+ "space" => "date-prefixed workspaces; repos provisioned on fibers at copy-on-write speed",
25
+ "architect" => "the Architect Loop: structured judgment plus a fleet of headless AI builders"
26
+ }.freeze
27
+ DEFAULT_TAGLINE = TAGLINES.fetch("space")
28
+
29
+ module_function
30
+
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
40
+
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
51
+ end
52
+
53
+ [header(result, pastel), pastel.bold("Commands:"), *body,
54
+ footer(result, pastel), loop_status_block(result, pastel)].compact.join("\n")
55
+ end
56
+
57
+ # The richer header only makes sense at the true root (`space` / `architect`),
58
+ # not on every sub-namespace listing.
59
+ def header(result, pastel)
60
+ return unless result.names.empty?
61
+
62
+ "#{pastel.bold.cyan("space-cadet")} #{pastel.dim(Space::Core::VERSION)} " \
63
+ "#{pastel.dim("— #{tagline}")}\n"
64
+ end
65
+
66
+ # The tagline for the binary in hand: `space` and `architect` each get their
67
+ # own, keyed off $PROGRAM_NAME (the same signal program_prefix reads).
68
+ def tagline
69
+ TAGLINES.fetch(File.basename($PROGRAM_NAME), DEFAULT_TAGLINE)
70
+ end
71
+
72
+ def footer(result, pastel)
73
+ "\n#{pastel.dim("Run `#{program_prefix(result)} <command> --help` for details on a command.")}"
74
+ end
75
+
76
+ # "space" at the root, "space repo" inside a namespace. The `space`/`architect`
77
+ # binaries inject their name into ARGV, so $PROGRAM_NAME and the leading
78
+ # namespace segment can collide ("space space ..."); drop the duplicate.
79
+ def program_prefix(result)
80
+ prog = File.basename($PROGRAM_NAME)
81
+ names = result.names.dup
82
+ names.shift if names.first == prog
83
+ [prog, *names].join(" ")
84
+ end
85
+
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
+ # paths:exempt - result.children is a dry-cli command-tree node, not a filesystem path
94
+ decorated = result.children.filter_map do |name, node|
95
+ [name, node, phase_of(node)] unless node.hidden
96
+ end
97
+
98
+ if decorated.all? { |_name, _node, phase| phase.nil? }
99
+ rows = decorated.sort_by { |name, _node, _phase| name }
100
+ .map { |name, node, _phase| row(result, name, node) }
101
+ return [[nil, rows]]
102
+ end
103
+
104
+ phased, unphased = decorated.partition { |_name, _node, phase| phase }
105
+ groups = phased.group_by { |_name, _node, phase| phase.last }
106
+ listing = groups.sort_by { |_label, members| members.map { |_n, _node, phase| phase.first }.min }
107
+ .map do |label, members|
108
+ rows = members.sort_by { |_n, _node, phase| phase.first }
109
+ .map { |name, node, _phase| row(result, name, node) }
110
+ [label, rows]
111
+ end
112
+ listing << [trailing_group_label, unphased.map { |name, node, _phase| row(result, name, node) }] \
113
+ unless unphased.empty?
114
+ listing
115
+ end
116
+
117
+ def row(result, name, node)
118
+ [label(result, name, node), description(node)]
119
+ end
120
+
121
+ def phase_of(node)
122
+ node.command.respond_to?(:phase) ? node.command.phase : nil
123
+ end
124
+
125
+ # Opportunistic, TOTAL loop-status embed: only the architect binary's ROOT
126
+ # listing gets it. Resolves the current space best-effort and renders the
127
+ # shared block from that space's own space.yaml `project` data (no
128
+ # space_cadet dependency). ANY failure — no space, store failure,
129
+ # malformed yaml, no `project` block — omits the block; help always renders.
130
+ def loop_status_block(result, pastel)
131
+ return unless result.names.empty? && File.basename($PROGRAM_NAME) == "architect"
132
+
133
+ project = current_space_project
134
+ lines = project && LoopStatus.lines(project)
135
+ return if lines.nil? || lines.empty?
136
+
137
+ ["", pastel.bold("Loop:"), *lines.map { |l| " #{pastel.dim(l)}" }].join("\n")
138
+ rescue StandardError
139
+ nil
140
+ end
141
+
142
+ def current_space_project
143
+ store = Space::Core::SpaceStore.new(config: Space::Core::Config.load, state: Space::Core::State.load)
144
+ store.current.value_or(nil)&.data&.[]("project")
145
+ rescue StandardError
146
+ nil
147
+ end
148
+
149
+ def label(result, name, node)
150
+ "#{program_prefix(result)} #{name}#{banner(node)}"
151
+ end
152
+
153
+ def banner(node)
154
+ # paths:exempt - node.children? is a dry-cli command-tree predicate, not a filesystem path
155
+ if node.command && node.leaf? && node.children?
156
+ " [ARGUMENT|SUBCOMMAND]"
157
+ elsif node.leaf?
158
+ arguments(node.command)
159
+ else
160
+ " [SUBCOMMAND]"
161
+ end
162
+ end
163
+
164
+ def arguments(command)
165
+ return "" unless command.respond_to?(:required_arguments)
166
+
167
+ names = command.required_arguments.map { |arg| arg.name.to_s.upcase }
168
+ names += command.optional_arguments.map { |arg| "[#{arg.name.to_s.upcase}]" }
169
+ names.empty? ? "" : " #{names.join(" ")}"
170
+ end
171
+
172
+ def description(node)
173
+ return unless node.leaf? && node.command.respond_to?(:description)
174
+
175
+ node.command.description
176
+ end
177
+ end
178
+ end
179
+
180
+ # Route dry-cli's plain namespace/root listing through our colourful renderer.
181
+ # We prepend over Usage.call wholesale and depend only on the LookupResult/Node
182
+ # API (children, command, leaf?/children?/hidden, names) rather than copying
183
+ # Usage's internals — see notes/ruby-cli-gems-report.md.
184
+ module Dry
185
+ class CLI
186
+ module Usage
187
+ module ColourPatch
188
+ def call(result)
189
+ Space::Core::CLI::Help.call(result)
190
+ end
191
+ end
192
+ singleton_class.prepend(ColourPatch)
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/monads"
4
+
5
+ module Space::Core::CLI
6
+ module Helpers
7
+ include Dry::Monads[:result]
8
+ def project_config
9
+ @project_config ||= Space::Core::Config.load
10
+ end
11
+
12
+ def state
13
+ @state ||= Space::Core::State.load
14
+ end
15
+
16
+ def store
17
+ @store ||= Space::Core::SpaceStore.new(config: project_config, state: state)
18
+ end
19
+
20
+ def terminal
21
+ @terminal
22
+ end
23
+
24
+ def setup_terminal(color: "auto", colors: nil)
25
+ @terminal = Space::Core::Terminal.new(
26
+ config: project_config,
27
+ stdout: out,
28
+ stderr: err,
29
+ color_mode: colors || color || "auto"
30
+ )
31
+ end
32
+
33
+ def display_date(space)
34
+ id_date = space.id.match(/\A(\d{4})(\d{2})(\d{2})/)
35
+ return "#{id_date[1]}-#{id_date[2]}-#{id_date[3]}" if id_date
36
+
37
+ space.data["created_at"].to_s[0, 10]
38
+ end
39
+
40
+ def handle_errors
41
+ yield
42
+ rescue Space::Core::Error => e
43
+ if terminal
44
+ terminal.error(e.message)
45
+ else
46
+ err.puts e.message
47
+ end
48
+ CLI.record_outcome(Outcome.new(exit_code: 1, message: e.message))
49
+ end
50
+
51
+ def render(result)
52
+ case result
53
+ when Dry::Monads::Result::Success
54
+ yield result.value! if block_given?
55
+ when Dry::Monads::Result::Failure
56
+ error = result.failure
57
+ message = error.respond_to?(:message) ? error.message : error.to_s
58
+ terminal ? terminal.error(message) : err.puts(message)
59
+ CLI.record_outcome(Outcome.new(exit_code: 1, message: message))
60
+ end
61
+ end
62
+ end
63
+
64
+ class RepoProgress
65
+ def initialize(total)
66
+ @total = total
67
+ @statuses = {}
68
+ end
69
+
70
+ def start(addition)
71
+ source = addition[:src_source]
72
+ @statuses[addition.fetch(:reference).full_name] = source&.directory? ? :copying : :cloning
73
+ end
74
+
75
+ def trust(addition)
76
+ @statuses[addition.fetch(:reference).full_name] = :trusting
77
+ end
78
+
79
+ def finish(addition)
80
+ @statuses[addition.fetch(:reference).full_name] = :done
81
+ end
82
+
83
+ def fail(addition)
84
+ @statuses[addition.fetch(:reference).full_name] = :failed
85
+ end
86
+
87
+ def message
88
+ done = @statuses.count { |_repo, status| status == :done }
89
+ failed = @statuses.count { |_repo, status| status == :failed }
90
+ copying = @statuses.select { |_repo, status| status == :copying }.keys
91
+ cloning = @statuses.select { |_repo, status| status == :cloning }.keys
92
+ trusting = @statuses.select { |_repo, status| status == :trusting }.keys
93
+
94
+ if @total == 1
95
+ copying_repo = copying.first
96
+ cloning_repo = cloning.first
97
+ trusting_repo = trusting.first
98
+ return "Copying #{copying_repo}" if copying_repo
99
+ return "Cloning #{cloning_repo}" if cloning_repo
100
+ return "Trusting #{trusting_repo}" if trusting_repo
101
+ return "Fetch failed" if failed.positive?
102
+
103
+ "Preparing repos"
104
+ else
105
+ active = []
106
+ active << "copying #{copying.join(', ')}" unless copying.empty?
107
+ active << "cloning #{cloning.join(', ')}" unless cloning.empty?
108
+ active << "trusting #{trusting.join(', ')}" unless trusting.empty?
109
+ suffix = active.empty? ? nil : ": #{active.join('; ')}"
110
+ failed_text = failed.positive? ? ", #{failed} failed" : ""
111
+ "Fetching repos #{done}/#{@total}#{failed_text}#{suffix}"
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Space::Core::CLI
4
+ class Init < BaseCommand
5
+ desc "Create default XDG config and state files"
6
+ option :force, type: :boolean, default: false, desc: "Overwrite existing config and state files"
7
+
8
+ def call(force: false, **opts)
9
+ setup_terminal(**opts.slice(:color, :colors))
10
+ handle_errors do
11
+ if force
12
+ @project_config = Space::Core::Config.new
13
+ @state = Space::Core::State.new
14
+ project_config.save
15
+ state.save
16
+ else
17
+ project_config.ensure_exists!
18
+ state.ensure_exists!
19
+ end
20
+
21
+ FileUtils.mkdir_p(project_config.spaces_dir)
22
+ terminal.success "Config: #{terminal.path(project_config.path)}"
23
+ terminal.success "State: #{terminal.path(state.path)}"
24
+ terminal.success "Spaces: #{terminal.path(project_config.spaces_dir)}"
25
+ CLI.record_outcome(Outcome.new(exit_code: 0))
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Space::Core::CLI
4
+ class List < BaseCommand
5
+ desc "List spaces"
6
+
7
+ def call(**opts)
8
+ setup_terminal(**opts.slice(:color, :colors))
9
+ handle_errors do
10
+ spaces = store.list
11
+ if spaces.empty?
12
+ terminal.say "No spaces found in #{terminal.path(project_config.spaces_dir)}"
13
+ next
14
+ end
15
+
16
+ rows = spaces.map do |space|
17
+ [space.status, display_date(space), space.title, terminal.path(space.path)]
18
+ end
19
+ terminal.say terminal.table(%w[Status Date Title Path], rows)
20
+ CLI.record_outcome(Outcome.new(exit_code: 0))
21
+ end
22
+ end
23
+ end
24
+ 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_cadet. 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
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Space::Core::CLI
4
+ class New < BaseCommand
5
+ desc "Create a new project space"
6
+ argument :title, required: true, desc: "Space title"
7
+ option :repo, type: :array, aliases: ["-r"], desc: "Repo ref to clone (repeatable: pass -r once per repo)"
8
+ option :git, type: :boolean, default: true, desc: "Initialize the space as a Git repository (use --no-git to skip)"
9
+ example "\"My Space\" -r org/repo -r example-tools/alpha # clone two repos into the space"
10
+
11
+ def call(title:, repo: [], git: true, **opts)
12
+ setup_terminal(**opts.slice(:color, :colors))
13
+ result = store.create(title, git: git).bind do |space|
14
+ terminal.success "Created #{space.id}"
15
+
16
+ repo_specs = Array(repo).compact
17
+ repo_specs.each { |spec| terminal.say "Queued #{spec}" }
18
+
19
+ next Success(space) if repo_specs.empty?
20
+
21
+ progress = RepoProgress.new(repo_specs.length)
22
+ terminal.with_spinner(-> { progress.message }) do
23
+ store.add_repos_to(space, repo_specs, reporter: progress)
24
+ end.fmap do |results|
25
+ results.each do |r|
26
+ terminal.success "Added #{r.fetch(:repo).fetch('full_name')}"
27
+ terminal.say terminal.path(r.fetch(:path))
28
+ end
29
+ space
30
+ end
31
+ end
32
+ render(result) do |space|
33
+ terminal.say terminal.path(space.path)
34
+ CLI.record_outcome(Outcome.new(exit_code: 0))
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "../oci_packer"
5
+
6
+ module Space::Core::CLI
7
+ class Pack < BaseCommand
8
+ desc "Generate a portable OCI build context for the current space"
9
+ option :output, aliases: ["-o"], type: :string, default: nil,
10
+ desc: "Output directory (default: build/oci/ under the space root)"
11
+
12
+ def call(output: nil, **opts)
13
+ setup_terminal(**opts.slice(:color, :colors))
14
+ handle_errors do
15
+ result = store.current.bind do |space|
16
+ out_dir = output ? Pathname.new(output).expand_path : space.path.join("build", "oci")
17
+ Space::Core::OciPacker.new(space: space, output_dir: out_dir).generate
18
+ .fmap { |dir| { space: space, dir: dir } }
19
+ end
20
+ render(result) do |r|
21
+ terminal.success "Generated OCI context: #{r[:dir]}"
22
+ terminal.say "Build: docker build -f #{r[:dir]}/Dockerfile -t #{r[:space].id}:latest ."
23
+ terminal.say " (run from: #{r[:space].path})"
24
+ CLI.record_outcome(Outcome.new(exit_code: 0))
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Space::Core::CLI
4
+ class Path < BaseCommand
5
+ desc "Print the path for a space or the current space"
6
+ argument :identifier, required: false, desc: "Space ID or title slug"
7
+
8
+ def call(identifier: nil, **opts)
9
+ setup_terminal(**opts.slice(:color, :colors))
10
+ render(store.path_for(identifier)) do |path|
11
+ terminal.say terminal.path(path)
12
+ CLI.record_outcome(Outcome.new(exit_code: 0))
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+
5
+ # dry-cli (1.4.x) treats `type: :array` options as comma-separated and OVERWRITES
6
+ # on each occurrence, so `-r a -r b` yields ["b"]. We want repeated flags to
7
+ # accumulate (`-r a -r b -r c` => ["a", "b", "c"]) the way git/docker-style CLIs
8
+ # do, while still accepting the comma form. dry-cli exposes no hook for this, so
9
+ # we prepend two private seams, each mirroring dry-cli 1.4.1 with a single change:
10
+ #
11
+ # * Parser.call — concat instead of assign for array options.
12
+ # * Banner.extended_command_options — drop the "=VALUE1,VALUE2,.." hint that
13
+ # advertised the comma form as the only way; show the plain repeatable flag,
14
+ # matching how you actually type it (-r VALUE).
15
+ #
16
+ # These mirror the released 1.4.1 source EXACTLY (not the dry-rb main branch,
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.
21
+ module Dry
22
+ class CLI
23
+ module Parser
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 = {}
30
+
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
39
+ end
40
+ end
41
+
42
+ opts.on_tail("-h", "--help") do
43
+ return Result.help
44
+ end
45
+ end.parse!(arguments)
46
+
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
52
+ end
53
+ singleton_class.prepend(RepeatableOptionsPatch)
54
+ end
55
+
56
+ module Banner
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
76
+
77
+ result << " --#{"help, -h".ljust(30)} # Print this help"
78
+ result.join("\n")
79
+ end
80
+ end
81
+ singleton_class.prepend(RepeatableOptionsPatch)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Space::Core::CLI
4
+ module Repo
5
+ class Add < BaseCommand
6
+ desc "Add repos to the current space (copy-on-write from an evergreen checkout when available, else clone)"
7
+ argument :repos, type: :array, required: false, desc: "REPO [REPO...]"
8
+
9
+ def call(repos: [], **opts)
10
+ setup_terminal(**opts.slice(:color, :colors))
11
+ handle_errors do
12
+ specs = Array(repos).compact
13
+ if specs.empty?
14
+ terminal.error("Usage: space repo add REPO [REPO...]")
15
+ CLI.record_outcome(Outcome.new(exit_code: 1))
16
+ next
17
+ end
18
+
19
+ progress = RepoProgress.new(specs.length)
20
+ add_result = terminal.with_spinner(-> { progress.message }) do
21
+ store.add_repos(specs, reporter: progress)
22
+ end
23
+ render(add_result) do |results|
24
+ results.each do |result|
25
+ terminal.success "Added #{result.fetch(:repo).fetch('full_name')}"
26
+ terminal.say terminal.path(result.fetch(:path))
27
+ end
28
+ CLI.record_outcome(Outcome.new(exit_code: 0))
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ class RepoList < BaseCommand
35
+ desc "List repos in the current space"
36
+
37
+ def call(**opts)
38
+ setup_terminal(**opts.slice(:color, :colors))
39
+ handle_errors do
40
+ render(store.repos) do |repos|
41
+ if repos.empty?
42
+ id = store.find.fmap(&:id).value_or("(unknown space)")
43
+ terminal.say "No repos found in #{id}"
44
+ next
45
+ end
46
+
47
+ rows = repos.map { |repo| [repo.fetch("full_name", repo["name"]), repo.fetch("path", "")] }
48
+ terminal.say terminal.table(["Repo", "Path"], rows)
49
+ CLI.record_outcome(Outcome.new(exit_code: 0))
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ class Resolve < BaseCommand
56
+ desc "Resolve repo refs without cloning"
57
+ argument :repos, type: :array, required: false, desc: "REPO [REPO...]"
58
+
59
+ def call(repos: [], **opts)
60
+ setup_terminal(**opts.slice(:color, :colors))
61
+ handle_errors do
62
+ specs = Array(repos).compact
63
+ if specs.empty?
64
+ terminal.error("Usage: space repo resolve REPO [REPO...]")
65
+ CLI.record_outcome(Outcome.new(exit_code: 1))
66
+ next
67
+ end
68
+
69
+ references = specs.map { |spec| Space::Core::RepoResolver.new(project_config).resolve(spec) }
70
+ terminal.say terminal.table(["Repo", "Clone URL"], references.map { |ref| [ref.full_name, ref.clone_url] })
71
+ CLI.record_outcome(Outcome.new(exit_code: 0))
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end