space-architect 1.1.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 (80) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +284 -0
  4. data/exe/architect +13 -0
  5. data/exe/space +13 -0
  6. data/lib/space_architect/architect_mission.rb +436 -0
  7. data/lib/space_architect/atomic_write.rb +21 -0
  8. data/lib/space_architect/cli/architect.rb +388 -0
  9. data/lib/space_architect/cli/config.rb +61 -0
  10. data/lib/space_architect/cli/current.rb +22 -0
  11. data/lib/space_architect/cli/helpers.rb +117 -0
  12. data/lib/space_architect/cli/init.rb +35 -0
  13. data/lib/space_architect/cli/list.rb +30 -0
  14. data/lib/space_architect/cli/new.rb +43 -0
  15. data/lib/space_architect/cli/options.rb +12 -0
  16. data/lib/space_architect/cli/path.rb +22 -0
  17. data/lib/space_architect/cli/repo.rb +88 -0
  18. data/lib/space_architect/cli/shell.rb +137 -0
  19. data/lib/space_architect/cli/show.rb +27 -0
  20. data/lib/space_architect/cli/space.rb +35 -0
  21. data/lib/space_architect/cli/src.rb +32 -0
  22. data/lib/space_architect/cli/status.rb +39 -0
  23. data/lib/space_architect/cli/use.rb +23 -0
  24. data/lib/space_architect/cli.rb +102 -0
  25. data/lib/space_architect/config.rb +152 -0
  26. data/lib/space_architect/dispatcher.rb +21 -0
  27. data/lib/space_architect/errors.rb +14 -0
  28. data/lib/space_architect/git_client.rb +49 -0
  29. data/lib/space_architect/harness.rb +168 -0
  30. data/lib/space_architect/mise_client.rb +37 -0
  31. data/lib/space_architect/repo_reference.rb +19 -0
  32. data/lib/space_architect/repo_resolver.rb +167 -0
  33. data/lib/space_architect/shell_integration.rb +438 -0
  34. data/lib/space_architect/slugger.rb +16 -0
  35. data/lib/space_architect/space.rb +110 -0
  36. data/lib/space_architect/space_store.rb +319 -0
  37. data/lib/space_architect/state.rb +86 -0
  38. data/lib/space_architect/templates/architect.md.erb +48 -0
  39. data/lib/space_architect/templates/iteration.md.erb +66 -0
  40. data/lib/space_architect/terminal.rb +163 -0
  41. data/lib/space_architect/version.rb +5 -0
  42. data/lib/space_architect/warnings.rb +13 -0
  43. data/lib/space_architect/xdg.rb +33 -0
  44. data/lib/space_architect.rb +26 -0
  45. data/vendor/repo-tender/lib/space_architect/pristine/cli/clone.rb +55 -0
  46. data/vendor/repo-tender/lib/space_architect/pristine/cli/config.rb +66 -0
  47. data/vendor/repo-tender/lib/space_architect/pristine/cli/daemon.rb +347 -0
  48. data/vendor/repo-tender/lib/space_architect/pristine/cli/options.rb +21 -0
  49. data/vendor/repo-tender/lib/space_architect/pristine/cli/org.rb +200 -0
  50. data/vendor/repo-tender/lib/space_architect/pristine/cli/repo.rb +170 -0
  51. data/vendor/repo-tender/lib/space_architect/pristine/cli/status.rb +76 -0
  52. data/vendor/repo-tender/lib/space_architect/pristine/cli/sync.rb +149 -0
  53. data/vendor/repo-tender/lib/space_architect/pristine/cli.rb +137 -0
  54. data/vendor/repo-tender/lib/space_architect/pristine/cloner.rb +75 -0
  55. data/vendor/repo-tender/lib/space_architect/pristine/config/contract.rb +54 -0
  56. data/vendor/repo-tender/lib/space_architect/pristine/config/duration.rb +79 -0
  57. data/vendor/repo-tender/lib/space_architect/pristine/config/model.rb +49 -0
  58. data/vendor/repo-tender/lib/space_architect/pristine/config/store.rb +156 -0
  59. data/vendor/repo-tender/lib/space_architect/pristine/forge/client.rb +31 -0
  60. data/vendor/repo-tender/lib/space_architect/pristine/forge/github.rb +98 -0
  61. data/vendor/repo-tender/lib/space_architect/pristine/launchd/agent.rb +195 -0
  62. data/vendor/repo-tender/lib/space_architect/pristine/launchd/plist.rb +129 -0
  63. data/vendor/repo-tender/lib/space_architect/pristine/log_rotator.rb +46 -0
  64. data/vendor/repo-tender/lib/space_architect/pristine/paths.rb +72 -0
  65. data/vendor/repo-tender/lib/space_architect/pristine/scm/client.rb +87 -0
  66. data/vendor/repo-tender/lib/space_architect/pristine/scm/git.rb +232 -0
  67. data/vendor/repo-tender/lib/space_architect/pristine/scm/status.rb +24 -0
  68. data/vendor/repo-tender/lib/space_architect/pristine/shell.rb +90 -0
  69. data/vendor/repo-tender/lib/space_architect/pristine/state/lock.rb +59 -0
  70. data/vendor/repo-tender/lib/space_architect/pristine/state/store.rb +140 -0
  71. data/vendor/repo-tender/lib/space_architect/pristine/sync/engine.rb +464 -0
  72. data/vendor/repo-tender/lib/space_architect/pristine/sync/repo_plan.rb +215 -0
  73. data/vendor/repo-tender/lib/space_architect/pristine/ui/interactive_reporter.rb +280 -0
  74. data/vendor/repo-tender/lib/space_architect/pristine/ui/json_reporter.rb +39 -0
  75. data/vendor/repo-tender/lib/space_architect/pristine/ui/mode.rb +68 -0
  76. data/vendor/repo-tender/lib/space_architect/pristine/ui/plain_reporter.rb +53 -0
  77. data/vendor/repo-tender/lib/space_architect/pristine/ui/reporter.rb +48 -0
  78. data/vendor/repo-tender/lib/space_architect/pristine/version.rb +7 -0
  79. data/vendor/repo-tender/lib/space_architect/pristine.rb +37 -0
  80. metadata +307 -0
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpaceArchitect
4
+ module CLI
5
+ class New < Dry::CLI::Command
6
+ include GlobalOptions
7
+ include Helpers
8
+
9
+ desc "Create a new project space"
10
+ argument :title, required: true, desc: "Space title"
11
+ argument :repos, type: :array, required: false, desc: "Repo refs to clone"
12
+ option :git, type: :boolean, default: true, desc: "Initialize the space as a Git repository (use --no-git to skip)"
13
+
14
+ def call(title:, repos: [], git: true, **opts)
15
+ setup_terminal(**opts.slice(:color, :colors))
16
+ result = store.create(title, git: git).bind do |space|
17
+ terminal.success "Created #{space.id}"
18
+
19
+ repo_specs = Array(repos).compact
20
+ repo_specs.each { |spec| terminal.say "Queued #{spec}" }
21
+
22
+ next Success(space) if repo_specs.empty?
23
+
24
+ progress = RepoProgress.new(repo_specs.length)
25
+ terminal.with_spinner(-> { progress.message }) do
26
+ store.add_repos_to(space, repo_specs, reporter: progress)
27
+ end.fmap do |results|
28
+ results.each do |r|
29
+ terminal.success "Added #{r.fetch(:repo).fetch('full_name')}"
30
+ terminal.say terminal.path(r.fetch(:path))
31
+ end
32
+ space
33
+ end
34
+ end
35
+ render(result) do |space|
36
+ terminal.say terminal.path(space.path)
37
+ CLI.record_outcome(Outcome.new(exit_code: 0))
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpaceArchitect
4
+ module CLI
5
+ module GlobalOptions
6
+ def self.included(base)
7
+ base.option :color, type: :string, default: "auto", desc: "Color output: auto, always, never"
8
+ base.option :colors, type: :string, desc: "Alias for --color"
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpaceArchitect
4
+ module CLI
5
+ class Path < Dry::CLI::Command
6
+ include GlobalOptions
7
+ include Helpers
8
+
9
+ desc "Print the path for a space or the current space"
10
+ argument :identifier, required: false, desc: "Space ID or title slug"
11
+
12
+ def call(identifier: nil, **opts)
13
+ setup_terminal(**opts.slice(:color, :colors))
14
+ render(store.path_for(identifier)) do |path|
15
+ terminal.say terminal.path(path)
16
+ CLI.record_outcome(Outcome.new(exit_code: 0))
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpaceArchitect
4
+ module CLI
5
+ module Repo
6
+ class Add < Dry::CLI::Command
7
+ include GlobalOptions
8
+ include Helpers
9
+
10
+ desc "Clone repos into the current space"
11
+ argument :repos, type: :array, required: false, desc: "REPO [REPO...]"
12
+
13
+ def call(repos: [], **opts)
14
+ setup_terminal(**opts.slice(:color, :colors))
15
+ handle_errors do
16
+ specs = Array(repos).compact
17
+ if specs.empty?
18
+ terminal.error("Usage: space repo add REPO [REPO...]")
19
+ CLI.record_outcome(Outcome.new(exit_code: 1))
20
+ next
21
+ end
22
+
23
+ progress = RepoProgress.new(specs.length)
24
+ add_result = terminal.with_spinner(-> { progress.message }) do
25
+ store.add_repos(specs, reporter: progress)
26
+ end
27
+ render(add_result) do |results|
28
+ results.each do |result|
29
+ terminal.success "Added #{result.fetch(:repo).fetch('full_name')}"
30
+ terminal.say terminal.path(result.fetch(:path))
31
+ end
32
+ CLI.record_outcome(Outcome.new(exit_code: 0))
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ class RepoList < Dry::CLI::Command
39
+ include GlobalOptions
40
+ include Helpers
41
+
42
+ desc "List repos in the current space"
43
+
44
+ def call(**opts)
45
+ setup_terminal(**opts.slice(:color, :colors))
46
+ handle_errors do
47
+ render(store.repos) do |repos|
48
+ if repos.empty?
49
+ id = store.find.fmap(&:id).value_or("(unknown space)")
50
+ terminal.say "No repos found in #{id}"
51
+ next
52
+ end
53
+
54
+ rows = repos.map { |repo| [repo.fetch("full_name", repo["name"]), repo.fetch("path", "")] }
55
+ terminal.say terminal.table(["Repo", "Path"], rows)
56
+ CLI.record_outcome(Outcome.new(exit_code: 0))
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ class Resolve < Dry::CLI::Command
63
+ include GlobalOptions
64
+ include Helpers
65
+
66
+ desc "Resolve repo refs without cloning"
67
+ argument :repos, type: :array, required: false, desc: "REPO [REPO...]"
68
+
69
+ def call(repos: [], **opts)
70
+ setup_terminal(**opts.slice(:color, :colors))
71
+ handle_errors do
72
+ specs = Array(repos).compact
73
+ if specs.empty?
74
+ terminal.error("Usage: space repo resolve REPO [REPO...]")
75
+ CLI.record_outcome(Outcome.new(exit_code: 1))
76
+ next
77
+ end
78
+
79
+ references = specs.map { |spec| RepoResolver.new(project_config).resolve(spec) }
80
+ terminal.say terminal.table(["Repo", "Clone URL"], references.map { |ref| [ref.full_name, ref.clone_url] })
81
+ CLI.record_outcome(Outcome.new(exit_code: 0))
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpaceArchitect
4
+ module CLI
5
+ module Shell
6
+ class ShellInit < Dry::CLI::Command
7
+ include GlobalOptions
8
+ include Helpers
9
+
10
+ desc "Print shell integration script"
11
+ argument :shell_name, required: true, desc: "Shell name (e.g. fish)"
12
+
13
+ def call(shell_name:, **opts)
14
+ setup_terminal(**opts.slice(:color, :colors))
15
+ handle_errors do
16
+ terminal.say ShellIntegration.for(shell_name)
17
+ CLI.record_outcome(Outcome.new(exit_code: 0))
18
+ end
19
+ end
20
+ end
21
+
22
+ class Fish < Dry::CLI::Command
23
+ include GlobalOptions
24
+ include Helpers
25
+
26
+ desc "Manage fish shell integration: install, uninstall, path"
27
+ argument :subcommand, required: false, desc: "install, uninstall, or path (default: install)"
28
+ option :force, type: :boolean, default: false, desc: "Overwrite or remove existing shell files"
29
+
30
+ def call(subcommand: "install", force: false, **opts)
31
+ setup_terminal(**opts.slice(:color, :colors))
32
+ handle_errors do
33
+ case subcommand
34
+ when "install"
35
+ result = ShellIntegration.install("fish", env: project_config.env, force: force)
36
+ terminal.success fish_install_message(result.fetch(:action), result.fetch(:path))
37
+ terminal.success fish_completions_install_message(result.fetch(:completions_action), result.fetch(:completions_path))
38
+ terminal.say "Restart fish to load the integration in this terminal: exec fish"
39
+ when "uninstall"
40
+ result = ShellIntegration.uninstall("fish", env: project_config.env, force: force)
41
+ terminal.success fish_uninstall_message(result.fetch(:action), result.fetch(:path))
42
+ terminal.success fish_completions_uninstall_message(result.fetch(:completions_action), result.fetch(:completions_path))
43
+ when "path"
44
+ terminal.say "Function: #{terminal.path(ShellIntegration.path_for('fish', env: project_config.env))}"
45
+ terminal.say "Completions: #{terminal.path(ShellIntegration.completions_path_for('fish', env: project_config.env))}"
46
+ else
47
+ err.puts "Usage: space shell fish [install|uninstall|path]"
48
+ CLI.record_outcome(Outcome.new(exit_code: 1))
49
+ next
50
+ end
51
+ CLI.record_outcome(Outcome.new(exit_code: 0))
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def fish_install_message(action, path)
58
+ case action
59
+ when :unchanged then "Fish integration already installed: #{terminal.path(path)}"
60
+ when :updated then "Updated fish integration: #{terminal.path(path)}"
61
+ else "Installed fish integration: #{terminal.path(path)}"
62
+ end
63
+ end
64
+
65
+ def fish_uninstall_message(action, path)
66
+ case action
67
+ when :missing then "Fish integration was not installed: #{terminal.path(path)}"
68
+ else "Removed fish integration: #{terminal.path(path)}"
69
+ end
70
+ end
71
+
72
+ def fish_completions_install_message(action, path)
73
+ case action
74
+ when :unchanged then "Fish completions already installed: #{terminal.path(path)}"
75
+ when :updated then "Updated fish completions: #{terminal.path(path)}"
76
+ else "Installed fish completions: #{terminal.path(path)}"
77
+ end
78
+ end
79
+
80
+ def fish_completions_uninstall_message(action, path)
81
+ case action
82
+ when :missing then "Fish completions were not installed: #{terminal.path(path)}"
83
+ else "Removed fish completions: #{terminal.path(path)}"
84
+ end
85
+ end
86
+ end
87
+
88
+ class Complete < Dry::CLI::Command
89
+ include GlobalOptions
90
+ include Helpers
91
+
92
+ desc "Print completion candidates"
93
+ argument :kind, required: true, desc: "Completion kind"
94
+ argument :extra, type: :array, required: false, desc: "Extra args for completion"
95
+
96
+ def call(kind:, extra: [], **opts)
97
+ setup_terminal(**opts.slice(:color, :colors))
98
+ handle_errors do
99
+ completion_candidates(kind, Array(extra)).each { |c| terminal.say c }
100
+ CLI.record_outcome(Outcome.new(exit_code: 0))
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def completion_candidates(kind, args)
107
+ case kind
108
+ when "spaces" then store.list.map { |space| "#{space.id}\t#{space.title}" }
109
+ when "statuses" then Space::VALID_STATUSES
110
+ when "config-keys" then SpaceArchitect::Config::EDITABLE_KEYS
111
+ when "config-values" then completion_values_for_config_key(args.first)
112
+ when "shells" then ["fish"]
113
+ when "color-modes" then %w[auto always never]
114
+ when "repo-subcommands" then %w[add list ls resolve]
115
+ when "config-subcommands" then %w[show path set]
116
+ when "fish-subcommands" then %w[install uninstall path]
117
+ else
118
+ raise SpaceArchitect::Error, "Usage: space shell complete #{completion_kinds.join('|')}"
119
+ end
120
+ end
121
+
122
+ def completion_values_for_config_key(key)
123
+ case key
124
+ when "git_clone_protocol" then SpaceArchitect::Config::VALID_GIT_CLONE_PROTOCOLS
125
+ when "default_provider" then %w[github.com gitlab.com]
126
+ else []
127
+ end
128
+ end
129
+
130
+ def completion_kinds
131
+ %w[spaces statuses config-keys config-values shells color-modes repo-subcommands config-subcommands fish-subcommands]
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpaceArchitect
4
+ module CLI
5
+ class Show < Dry::CLI::Command
6
+ include GlobalOptions
7
+ include Helpers
8
+
9
+ desc "Show metadata for a space or the current space"
10
+ argument :identifier, required: false, desc: "Space ID or title slug"
11
+
12
+ def call(identifier: nil, **opts)
13
+ setup_terminal(**opts.slice(:color, :colors))
14
+ render(store.find(identifier)) do |space|
15
+ terminal.say "ID: #{space.id}"
16
+ terminal.say "Title: #{space.title}"
17
+ terminal.say "Status: #{space.status}"
18
+ terminal.say "Path: #{terminal.path(space.path)}"
19
+ terminal.say "Created: #{space.data['created_at']}"
20
+ terminal.say "Updated: #{space.data['updated_at']}"
21
+ CLI.record_outcome(Outcome.new(exit_code: 0))
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ SpaceArchitect::CLI::Registry.register "space" do |prefix|
4
+ prefix.register "init", SpaceArchitect::CLI::Init
5
+ prefix.register "new", SpaceArchitect::CLI::New
6
+ prefix.register "list", SpaceArchitect::CLI::List
7
+ prefix.register "ls", SpaceArchitect::CLI::List
8
+ prefix.register "show", SpaceArchitect::CLI::Show
9
+ prefix.register "path", SpaceArchitect::CLI::Path
10
+ prefix.register "use", SpaceArchitect::CLI::Use
11
+ prefix.register "current", SpaceArchitect::CLI::Current
12
+ prefix.register "status", SpaceArchitect::CLI::Status
13
+ prefix.register "config" do |c|
14
+ c.register "show", SpaceArchitect::CLI::Config::Show
15
+ c.register "path", SpaceArchitect::CLI::Config::ConfigPath
16
+ c.register "set", SpaceArchitect::CLI::Config::Set
17
+ end
18
+ prefix.register "repo" do |r|
19
+ r.register "add", SpaceArchitect::CLI::Repo::Add
20
+ r.register "list", SpaceArchitect::CLI::Repo::RepoList
21
+ r.register "ls", SpaceArchitect::CLI::Repo::RepoList
22
+ r.register "resolve", SpaceArchitect::CLI::Repo::Resolve
23
+ end
24
+ prefix.register "repos" do |r|
25
+ r.register "add", SpaceArchitect::CLI::Repo::Add
26
+ r.register "list", SpaceArchitect::CLI::Repo::RepoList
27
+ r.register "ls", SpaceArchitect::CLI::Repo::RepoList
28
+ r.register "resolve", SpaceArchitect::CLI::Repo::Resolve
29
+ end
30
+ prefix.register "shell" do |s|
31
+ s.register "init", SpaceArchitect::CLI::Shell::ShellInit
32
+ s.register "fish", SpaceArchitect::CLI::Shell::Fish
33
+ s.register "complete", SpaceArchitect::CLI::Shell::Complete
34
+ end
35
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "space_architect/pristine/cli"
4
+
5
+ module SpaceArchitect
6
+ module CLI
7
+ # Exit-code bridge to the vendored Pristine (repo-tender) CLI engine.
8
+ # `architect src <args>` hands the raw remainder to Pristine's own dry-cli
9
+ # registry and translates Pristine's recorded Outcome into the host exit code.
10
+ # Pristine has its own Registry, Outcome, and :repo_tender_cli_* thread-locals;
11
+ # this is the seam between the two registries (NOT a re-registration). Pristine's
12
+ # top-level help/version interceptors call Kernel.exit, so we reproduce that
13
+ # interception here against the injected IO instead of delegating to them.
14
+ # dry-cli's internal exit on a bare group / unknown command propagates as
15
+ # SystemExit — same as the host's own bare groups (e.g. `space repo`) — and is
16
+ # intentionally NOT rescued (accepted behavior change).
17
+ def self.dispatch_src(rest, out = $stdout, err = $stderr)
18
+ if Pristine::CLI::TOP_LEVEL_HELP.include?(rest)
19
+ out.puts Dry::CLI::Usage.call(Pristine::CLI::Registry.get([]))
20
+ return 0
21
+ end
22
+ if Pristine::CLI::VERSION_REQUEST.include?(rest)
23
+ out.puts SpaceArchitect::Pristine::VERSION
24
+ return 0
25
+ end
26
+
27
+ Thread.current[:repo_tender_cli_outcome] = nil
28
+ Dry::CLI.new(Pristine::CLI::Registry).call(arguments: rest, out: out, err: err)
29
+ Pristine::CLI.last_outcome&.exit_code || 0
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpaceArchitect
4
+ module CLI
5
+ class Status < Dry::CLI::Command
6
+ include GlobalOptions
7
+ include Helpers
8
+
9
+ desc "Set a space status: active, paused, done, archived"
10
+ argument :rest, type: :array, required: false, desc: "[SPACE] STATUS"
11
+
12
+ def call(rest: [], **opts)
13
+ setup_terminal(**opts.slice(:color, :colors))
14
+ handle_errors do
15
+ identifier, status_value = parse_status_args(Array(rest))
16
+ render(store.find(identifier)) do |space|
17
+ space.update_status(status_value)
18
+ terminal.success "#{space.id} is #{space.status}"
19
+ CLI.record_outcome(Outcome.new(exit_code: 0))
20
+ end
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def parse_status_args(args)
27
+ case args.length
28
+ when 1
29
+ [nil, args.first]
30
+ when 2
31
+ args
32
+ else
33
+ raise SpaceArchitect::Error, "Usage: space status [SPACE] STATUS"
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpaceArchitect
4
+ module CLI
5
+ class Use < Dry::CLI::Command
6
+ include GlobalOptions
7
+ include Helpers
8
+
9
+ desc "Remember a space in recent state and print its path"
10
+ argument :identifier, required: true, desc: "Space ID or title slug"
11
+
12
+ def call(identifier:, **opts)
13
+ setup_terminal(**opts.slice(:color, :colors))
14
+ render(store.use(identifier)) do |space|
15
+ terminal.success "Recent space: #{space.id}"
16
+ terminal.say terminal.path(space.path)
17
+ CLI.record_outcome(Outcome.new(exit_code: 0))
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+
5
+ module SpaceArchitect
6
+ module CLI
7
+ Outcome = Data.define(:exit_code, :message) do
8
+ def initialize(exit_code:, message: nil) = super
9
+ end
10
+
11
+ def self.record_outcome(o) = (Thread.current[:space_architect_outcome] = o)
12
+ def self.last_outcome = Thread.current[:space_architect_outcome]
13
+
14
+ module Registry
15
+ extend Dry::CLI::Registry
16
+ end
17
+
18
+ TOP_LEVEL_HELP = [[], ["--help"], ["-h"], ["help"]].freeze
19
+ VERSION_REQUEST = [["version"], ["--version"]].freeze
20
+
21
+ def self.call(argv, out = $stdout, err = $stderr)
22
+ Thread.current[:space_architect_outcome] = nil
23
+
24
+ if TOP_LEVEL_HELP.include?(argv)
25
+ out.puts Dry::CLI::Usage.call(Registry.get([]))
26
+ return 0
27
+ end
28
+
29
+ if VERSION_REQUEST.include?(argv)
30
+ out.puts SpaceArchitect::VERSION
31
+ return 0
32
+ end
33
+
34
+ if argv.first == "src"
35
+ return dispatch_src(argv[1..], out, err)
36
+ end
37
+
38
+ Dry::CLI.new(Registry).call(arguments: normalize_args(argv), out: out, err: err)
39
+ last_outcome&.exit_code || 0
40
+ end
41
+
42
+ # Move --color/--colors options to the end of the argument list so dry-cli's
43
+ # command routing is not confused by options before the subcommand name.
44
+ #
45
+ # Two passes:
46
+ # 1. Leading: extract two-token form (--color VALUE) and =-form from the
47
+ # front while args still look like options.
48
+ # 2. Non-leading: extract =-form (--color=VALUE / --colors=VALUE) from any
49
+ # position before the -- separator. The bare two-token form is ambiguous
50
+ # with a subcommand name in non-leading position and is left in place.
51
+ def self.normalize_args(argv)
52
+ args = argv.dup
53
+ extracted = []
54
+
55
+ # Pass 1: leading two-token and =-form (existing behavior, unchanged)
56
+ while (arg = args.first) && arg != "--" && arg.start_with?("-")
57
+ if %w[--color --colors].include?(arg)
58
+ extracted << args.shift
59
+ extracted << args.shift if args.first && !args.first.start_with?("-")
60
+ elsif arg.start_with?("--color=", "--colors=")
61
+ extracted << args.shift
62
+ else
63
+ break
64
+ end
65
+ end
66
+
67
+ # Pass 2: =-form from any non-leading position, stop at --
68
+ sep = args.index("--")
69
+ head = sep ? args[0, sep] : args
70
+ tail = sep ? args[sep..] : []
71
+ mid_color, head = head.partition { |a| a.start_with?("--color=", "--colors=") }
72
+ extracted += mid_color
73
+ args = head + tail
74
+
75
+ extracted.empty? ? args : args + extracted
76
+ end
77
+
78
+ def self.run(argv, out = $stdout, err = $stderr)
79
+ Kernel.exit(call(argv, out, err))
80
+ rescue Interrupt
81
+ err.puts "interrupted"
82
+ Kernel.exit(130)
83
+ end
84
+ end
85
+ end
86
+
87
+ require_relative "cli/options"
88
+ require_relative "cli/helpers"
89
+ require_relative "cli/init"
90
+ require_relative "cli/new"
91
+ require_relative "cli/list"
92
+ require_relative "cli/show"
93
+ require_relative "cli/path"
94
+ require_relative "cli/use"
95
+ require_relative "cli/current"
96
+ require_relative "cli/status"
97
+ require_relative "cli/config"
98
+ require_relative "cli/repo"
99
+ require_relative "cli/shell"
100
+ require_relative "cli/architect"
101
+ require_relative "cli/space"
102
+ require_relative "cli/src"