space-architect 1.3.0 → 2.0.0.rc2

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 (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +103 -0
  3. data/README.md +267 -158
  4. data/exe/architect +1 -1
  5. data/exe/space +2 -2
  6. data/exe/src +13 -0
  7. data/lib/space_architect/{architect_mission.rb → architect_project.rb} +475 -125
  8. data/lib/space_architect/cli/architect.rb +248 -178
  9. data/lib/space_architect/cli/research.rb +94 -0
  10. data/lib/space_architect/cli/space.rb +25 -31
  11. data/lib/space_architect/cli/src.rb +20 -14
  12. data/lib/space_architect/cli.rb +22 -22
  13. data/lib/space_architect/dispatcher.rb +5 -1
  14. data/lib/space_architect/gate_evaluator.rb +65 -0
  15. data/lib/space_architect/gate_lint.rb +140 -0
  16. data/lib/space_architect/harness.rb +145 -17
  17. data/lib/space_architect/research/mux.rb +127 -0
  18. data/lib/space_architect/research/registry.rb +70 -0
  19. data/lib/space_architect/research/renderer.rb +101 -0
  20. data/lib/space_architect/research/run.rb +7 -0
  21. data/lib/space_architect/research/supervisor.rb +108 -0
  22. data/lib/space_architect/research.rb +13 -0
  23. data/lib/space_architect/run_creator.rb +53 -0
  24. data/lib/space_architect/skill_installer.rb +81 -79
  25. data/lib/space_architect/templates/architect.md.erb +15 -4
  26. data/lib/space_architect/templates/brief.md.erb +5 -5
  27. data/lib/space_architect/templates/iteration.md.erb +17 -6
  28. data/lib/space_architect.rb +8 -21
  29. data/lib/{space_architect → space_core}/atomic_write.rb +1 -1
  30. data/lib/space_core/cli/base_command.rb +19 -0
  31. data/lib/space_core/cli/build.rb +27 -0
  32. data/lib/space_core/cli/config.rb +49 -0
  33. data/lib/space_core/cli/current.rb +16 -0
  34. data/lib/space_core/cli/help.rb +123 -0
  35. data/lib/space_core/cli/helpers.rb +115 -0
  36. data/lib/space_core/cli/init.rb +29 -0
  37. data/lib/space_core/cli/list.rb +24 -0
  38. data/lib/space_core/cli/new.rb +38 -0
  39. data/lib/space_core/cli/pack.rb +29 -0
  40. data/lib/space_core/cli/path.rb +16 -0
  41. data/lib/space_core/cli/repeatable_options.rb +75 -0
  42. data/lib/space_core/cli/repo.rb +76 -0
  43. data/lib/space_core/cli/run.rb +29 -0
  44. data/lib/space_core/cli/shell.rb +125 -0
  45. data/lib/space_core/cli/show.rb +21 -0
  46. data/lib/space_core/cli/status.rb +33 -0
  47. data/lib/space_core/cli/use.rb +17 -0
  48. data/lib/space_core/cli.rb +177 -0
  49. data/lib/{space_architect → space_core}/config.rb +1 -1
  50. data/lib/{space_architect → space_core}/errors.rb +1 -1
  51. data/lib/{space_architect → space_core}/git_client.rb +1 -1
  52. data/lib/{space_architect → space_core}/mise_client.rb +1 -1
  53. data/lib/space_core/oci_builder.rb +56 -0
  54. data/lib/space_core/oci_packer.rb +99 -0
  55. data/lib/space_core/oci_runner.rb +73 -0
  56. data/lib/{space_architect → space_core}/repo_reference.rb +1 -1
  57. data/lib/{space_architect → space_core}/repo_resolver.rb +1 -1
  58. data/lib/{space_architect → space_core}/shell_integration.rb +1 -1
  59. data/lib/{space_architect → space_core}/slugger.rb +1 -1
  60. data/lib/{space_architect → space_core}/space.rb +11 -3
  61. data/lib/{space_architect → space_core}/space_store.rb +13 -13
  62. data/lib/{space_architect → space_core}/state.rb +1 -1
  63. data/lib/space_core/templates/oci/dockerfile.erb +63 -0
  64. data/lib/space_core/templates/oci/dockerignore.erb +17 -0
  65. data/lib/space_core/templates/oci/entrypoint.sh.erb +10 -0
  66. data/lib/{space_architect → space_core}/terminal.rb +1 -1
  67. data/lib/space_core/version.rb +7 -0
  68. data/lib/{space_architect → space_core}/warnings.rb +1 -1
  69. data/lib/{space_architect → space_core}/xdg.rb +1 -1
  70. data/lib/space_core.rb +24 -0
  71. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/clone.rb +5 -5
  72. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/config.rb +7 -7
  73. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/daemon.rb +46 -30
  74. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/options.rb +1 -1
  75. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/org.rb +9 -9
  76. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/repo.rb +9 -9
  77. data/lib/space_src/cli/shell.rb +122 -0
  78. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/status.rb +7 -7
  79. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/sync.rb +17 -17
  80. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli.rb +42 -11
  81. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cloner.rb +3 -3
  82. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/contract.rb +1 -1
  83. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/duration.rb +1 -1
  84. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/model.rb +1 -1
  85. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/store.rb +5 -5
  86. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/forge/client.rb +2 -2
  87. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/forge/github.rb +4 -4
  88. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/launchd/agent.rb +5 -5
  89. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/launchd/plist.rb +3 -3
  90. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/log_rotator.rb +1 -1
  91. data/lib/space_src/migration.rb +43 -0
  92. data/lib/space_src/nav.rb +98 -0
  93. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/paths.rb +2 -2
  94. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/client.rb +1 -1
  95. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/git.rb +4 -4
  96. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/status.rb +1 -1
  97. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/shell.rb +1 -1
  98. data/lib/space_src/shell_integration.rb +321 -0
  99. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/state/lock.rb +1 -1
  100. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/state/store.rb +2 -2
  101. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/sync/engine.rb +12 -12
  102. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/sync/repo_plan.rb +3 -3
  103. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/interactive_reporter.rb +1 -1
  104. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/json_reporter.rb +1 -1
  105. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/mode.rb +1 -1
  106. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/plain_reporter.rb +1 -1
  107. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/reporter.rb +1 -1
  108. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/version.rb +2 -2
  109. data/lib/space_src.rb +37 -0
  110. data/skill/architect/SKILL.md +110 -54
  111. data/skill/architect/dispatch.md +147 -39
  112. data/skill/architect/research.md +46 -37
  113. data/skill/architect-research/SKILL.md +2 -2
  114. data/skill/architect-vocabulary/SKILL.md +24 -21
  115. metadata +127 -68
  116. data/lib/space_architect/cli/config.rb +0 -61
  117. data/lib/space_architect/cli/current.rb +0 -22
  118. data/lib/space_architect/cli/helpers.rb +0 -117
  119. data/lib/space_architect/cli/init.rb +0 -35
  120. data/lib/space_architect/cli/list.rb +0 -30
  121. data/lib/space_architect/cli/new.rb +0 -43
  122. data/lib/space_architect/cli/options.rb +0 -12
  123. data/lib/space_architect/cli/path.rb +0 -22
  124. data/lib/space_architect/cli/repo.rb +0 -88
  125. data/lib/space_architect/cli/shell.rb +0 -137
  126. data/lib/space_architect/cli/show.rb +0 -27
  127. data/lib/space_architect/cli/status.rb +0 -39
  128. data/lib/space_architect/cli/use.rb +0 -23
  129. data/lib/space_architect/version.rb +0 -5
  130. data/vendor/repo-tender/lib/space_architect/pristine.rb +0 -44
@@ -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,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,75 @@
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 reopen 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). 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.
20
+ module Dry
21
+ class CLI
22
+ module Parser
23
+ def self.call(command, arguments, prog_name)
24
+ original_arguments = arguments.dup
25
+ parsed_options = {}
26
+
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
34
+ end
35
+ end
36
+ end
37
+
38
+ opts.on_tail("-h", "--help") do
39
+ return Result.help
40
+ end
41
+ end.parse!(arguments)
42
+
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
+ end
48
+ end
49
+
50
+ 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
69
+
70
+ result << " --#{"help, -h".ljust(30)} # Print this help"
71
+ result.join("\n")
72
+ end
73
+ end
74
+ end
75
+ 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
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../oci_runner"
5
+
6
+ module Space::Core::CLI
7
+ class Run < BaseCommand
8
+ desc "Run the packed OCI image for the current space (auth injected at runtime)"
9
+
10
+ argument :command, type: :array, required: false, desc: "Command to run in the container (default: login shell)"
11
+ option :tty, type: :boolean, default: nil, desc: "Force interactive TTY (default: auto-detect)"
12
+
13
+ def call(command: [], tty: nil, **opts)
14
+ setup_terminal(**opts.slice(:color, :colors))
15
+ handle_errors do
16
+ result = store.current.bind do |space|
17
+ runner = Space::Core::OciRunner.new(space: space, interactive: tty.nil? ? CLI.tty?(out) : tty)
18
+ runner.command(command).fmap { |argv| { argv: argv, runner: runner } }
19
+ end
20
+ render(result) do |r|
21
+ r[:runner].host_dirs.each { |d| FileUtils.mkdir_p(d) }
22
+ terminal.say "Running: #{r[:argv].join(' ')}"
23
+ out.flush # Kernel.exec replaces the process without flushing buffered IO
24
+ Kernel.exec(*r[:argv])
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Space::Core::CLI
4
+ module Shell
5
+ class ShellInit < BaseCommand
6
+ desc "Print shell integration script"
7
+ argument :shell_name, required: true, desc: "Shell name (e.g. fish)"
8
+
9
+ def call(shell_name:, **opts)
10
+ setup_terminal(**opts.slice(:color, :colors))
11
+ handle_errors do
12
+ terminal.say Space::Core::ShellIntegration.for(shell_name)
13
+ CLI.record_outcome(Outcome.new(exit_code: 0))
14
+ end
15
+ end
16
+ end
17
+
18
+ class Fish < BaseCommand
19
+ desc "Manage fish shell integration: install, uninstall, path"
20
+ argument :subcommand, required: false, desc: "install, uninstall, or path (default: install)"
21
+ option :force, type: :boolean, default: false, desc: "Overwrite or remove existing shell files"
22
+
23
+ def call(subcommand: "install", force: false, **opts)
24
+ setup_terminal(**opts.slice(:color, :colors))
25
+ handle_errors do
26
+ case subcommand
27
+ when "install"
28
+ result = Space::Core::ShellIntegration.install("fish", env: project_config.env, force: force)
29
+ terminal.success fish_install_message(result.fetch(:action), result.fetch(:path))
30
+ terminal.success fish_completions_install_message(result.fetch(:completions_action), result.fetch(:completions_path))
31
+ terminal.say "Restart fish to load the integration in this terminal: exec fish"
32
+ when "uninstall"
33
+ result = Space::Core::ShellIntegration.uninstall("fish", env: project_config.env, force: force)
34
+ terminal.success fish_uninstall_message(result.fetch(:action), result.fetch(:path))
35
+ terminal.success fish_completions_uninstall_message(result.fetch(:completions_action), result.fetch(:completions_path))
36
+ when "path"
37
+ terminal.say "Function: #{terminal.path(Space::Core::ShellIntegration.path_for('fish', env: project_config.env))}"
38
+ terminal.say "Completions: #{terminal.path(Space::Core::ShellIntegration.completions_path_for('fish', env: project_config.env))}"
39
+ else
40
+ err.puts "Usage: space shell fish [install|uninstall|path]"
41
+ CLI.record_outcome(Outcome.new(exit_code: 1))
42
+ next
43
+ end
44
+ CLI.record_outcome(Outcome.new(exit_code: 0))
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def fish_install_message(action, path)
51
+ case action
52
+ when :unchanged then "Fish integration already installed: #{terminal.path(path)}"
53
+ when :updated then "Updated fish integration: #{terminal.path(path)}"
54
+ else "Installed fish integration: #{terminal.path(path)}"
55
+ end
56
+ end
57
+
58
+ def fish_uninstall_message(action, path)
59
+ case action
60
+ when :missing then "Fish integration was not installed: #{terminal.path(path)}"
61
+ else "Removed fish integration: #{terminal.path(path)}"
62
+ end
63
+ end
64
+
65
+ def fish_completions_install_message(action, path)
66
+ case action
67
+ when :unchanged then "Fish completions already installed: #{terminal.path(path)}"
68
+ when :updated then "Updated fish completions: #{terminal.path(path)}"
69
+ else "Installed fish completions: #{terminal.path(path)}"
70
+ end
71
+ end
72
+
73
+ def fish_completions_uninstall_message(action, path)
74
+ case action
75
+ when :missing then "Fish completions were not installed: #{terminal.path(path)}"
76
+ else "Removed fish completions: #{terminal.path(path)}"
77
+ end
78
+ end
79
+ end
80
+
81
+ class Complete < BaseCommand
82
+ desc "Print completion candidates"
83
+ argument :kind, required: true, desc: "Completion kind"
84
+ argument :extra, type: :array, required: false, desc: "Extra args for completion"
85
+
86
+ def call(kind:, extra: [], **opts)
87
+ setup_terminal(**opts.slice(:color, :colors))
88
+ handle_errors do
89
+ completion_candidates(kind, Array(extra)).each { |c| terminal.say c }
90
+ CLI.record_outcome(Outcome.new(exit_code: 0))
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def completion_candidates(kind, args)
97
+ case kind
98
+ when "spaces" then store.list.map { |space| "#{space.id}\t#{space.title}" }
99
+ when "statuses" then Space::Core::Space::VALID_STATUSES
100
+ when "config-keys" then Space::Core::Config::EDITABLE_KEYS
101
+ when "config-values" then completion_values_for_config_key(args.first)
102
+ when "shells" then ["fish"]
103
+ when "color-modes" then %w[auto always never]
104
+ when "repo-subcommands" then %w[add list ls resolve]
105
+ when "config-subcommands" then %w[show path set]
106
+ when "fish-subcommands" then %w[install uninstall path]
107
+ else
108
+ raise Space::Core::Error, "Usage: space shell complete #{completion_kinds.join('|')}"
109
+ end
110
+ end
111
+
112
+ def completion_values_for_config_key(key)
113
+ case key
114
+ when "git_clone_protocol" then Space::Core::Config::VALID_GIT_CLONE_PROTOCOLS
115
+ when "default_provider" then %w[github.com gitlab.com]
116
+ else []
117
+ end
118
+ end
119
+
120
+ def completion_kinds
121
+ %w[spaces statuses config-keys config-values shells color-modes repo-subcommands config-subcommands fish-subcommands]
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Space::Core::CLI
4
+ class Show < BaseCommand
5
+ desc "Show metadata 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.find(identifier)) do |space|
11
+ terminal.say "ID: #{space.id}"
12
+ terminal.say "Title: #{space.title}"
13
+ terminal.say "Status: #{space.status}"
14
+ terminal.say "Path: #{terminal.path(space.path)}"
15
+ terminal.say "Created: #{space.data['created_at']}"
16
+ terminal.say "Updated: #{space.data['updated_at']}"
17
+ CLI.record_outcome(Outcome.new(exit_code: 0))
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Space::Core::CLI
4
+ class Status < BaseCommand
5
+ desc "Set a space status: active, paused, done, archived"
6
+ argument :rest, type: :array, required: false, desc: "[SPACE] STATUS"
7
+
8
+ def call(rest: [], **opts)
9
+ setup_terminal(**opts.slice(:color, :colors))
10
+ 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))
16
+ end
17
+ end
18
+ end
19
+
20
+ private
21
+
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
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Space::Core::CLI
4
+ class Use < BaseCommand
5
+ desc "Remember a space in recent state and print its path"
6
+ argument :identifier, required: true, desc: "Space ID or title slug"
7
+
8
+ def call(identifier:, **opts)
9
+ setup_terminal(**opts.slice(:color, :colors))
10
+ render(store.use(identifier)) do |space|
11
+ terminal.success "Recent space: #{space.id}"
12
+ terminal.say terminal.path(space.path)
13
+ CLI.record_outcome(Outcome.new(exit_code: 0))
14
+ end
15
+ end
16
+ end
17
+ end