space-architect 1.3.0 → 2.0.0.rc1

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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +103 -0
  3. data/README.md +248 -155
  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 +84 -53
  8. data/lib/space_architect/cli/architect.rb +92 -132
  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/harness.rb +123 -16
  15. data/lib/space_architect/research/mux.rb +127 -0
  16. data/lib/space_architect/research/registry.rb +70 -0
  17. data/lib/space_architect/research/renderer.rb +101 -0
  18. data/lib/space_architect/research/run.rb +7 -0
  19. data/lib/space_architect/research/supervisor.rb +108 -0
  20. data/lib/space_architect/research.rb +13 -0
  21. data/lib/space_architect/run_creator.rb +53 -0
  22. data/lib/space_architect/skill_installer.rb +81 -79
  23. data/lib/space_architect.rb +5 -20
  24. data/lib/{space_architect → space_core}/atomic_write.rb +1 -1
  25. data/lib/space_core/cli/base_command.rb +19 -0
  26. data/lib/space_core/cli/config.rb +49 -0
  27. data/lib/space_core/cli/current.rb +16 -0
  28. data/lib/space_core/cli/help.rb +110 -0
  29. data/lib/space_core/cli/helpers.rb +115 -0
  30. data/lib/space_core/cli/init.rb +29 -0
  31. data/lib/space_core/cli/list.rb +24 -0
  32. data/lib/space_core/cli/new.rb +38 -0
  33. data/lib/space_core/cli/path.rb +16 -0
  34. data/lib/space_core/cli/repeatable_options.rb +75 -0
  35. data/lib/space_core/cli/repo.rb +76 -0
  36. data/lib/space_core/cli/shell.rb +125 -0
  37. data/lib/space_core/cli/show.rb +21 -0
  38. data/lib/space_core/cli/status.rb +33 -0
  39. data/lib/space_core/cli/use.rb +17 -0
  40. data/lib/space_core/cli.rb +171 -0
  41. data/lib/{space_architect → space_core}/config.rb +1 -1
  42. data/lib/{space_architect → space_core}/errors.rb +1 -1
  43. data/lib/{space_architect → space_core}/git_client.rb +1 -1
  44. data/lib/{space_architect → space_core}/mise_client.rb +1 -1
  45. data/lib/{space_architect → space_core}/repo_reference.rb +1 -1
  46. data/lib/{space_architect → space_core}/repo_resolver.rb +1 -1
  47. data/lib/{space_architect → space_core}/shell_integration.rb +1 -1
  48. data/lib/{space_architect → space_core}/slugger.rb +1 -1
  49. data/lib/{space_architect → space_core}/space.rb +1 -1
  50. data/lib/{space_architect → space_core}/space_store.rb +12 -12
  51. data/lib/{space_architect → space_core}/state.rb +1 -1
  52. data/lib/{space_architect → space_core}/terminal.rb +1 -1
  53. data/lib/space_core/version.rb +7 -0
  54. data/lib/{space_architect → space_core}/warnings.rb +1 -1
  55. data/lib/{space_architect → space_core}/xdg.rb +1 -1
  56. data/lib/space_core.rb +24 -0
  57. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/clone.rb +5 -5
  58. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/config.rb +7 -7
  59. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/daemon.rb +46 -30
  60. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/options.rb +1 -1
  61. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/org.rb +9 -9
  62. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/repo.rb +9 -9
  63. data/lib/space_src/cli/shell.rb +122 -0
  64. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/status.rb +7 -7
  65. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/sync.rb +17 -17
  66. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli.rb +42 -11
  67. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cloner.rb +3 -3
  68. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/contract.rb +1 -1
  69. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/duration.rb +1 -1
  70. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/model.rb +1 -1
  71. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/store.rb +5 -5
  72. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/forge/client.rb +2 -2
  73. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/forge/github.rb +4 -4
  74. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/launchd/agent.rb +5 -5
  75. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/launchd/plist.rb +3 -3
  76. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/log_rotator.rb +1 -1
  77. data/lib/space_src/migration.rb +43 -0
  78. data/lib/space_src/nav.rb +98 -0
  79. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/paths.rb +2 -2
  80. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/client.rb +1 -1
  81. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/git.rb +4 -4
  82. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/status.rb +1 -1
  83. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/shell.rb +1 -1
  84. data/lib/space_src/shell_integration.rb +321 -0
  85. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/state/lock.rb +1 -1
  86. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/state/store.rb +2 -2
  87. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/sync/engine.rb +12 -12
  88. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/sync/repo_plan.rb +3 -3
  89. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/interactive_reporter.rb +1 -1
  90. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/json_reporter.rb +1 -1
  91. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/mode.rb +1 -1
  92. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/plain_reporter.rb +1 -1
  93. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/reporter.rb +1 -1
  94. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/version.rb +2 -2
  95. data/lib/space_src.rb +37 -0
  96. data/skill/architect/SKILL.md +2 -2
  97. data/skill/architect/research.md +46 -37
  98. metadata +115 -67
  99. data/lib/space_architect/cli/config.rb +0 -61
  100. data/lib/space_architect/cli/current.rb +0 -22
  101. data/lib/space_architect/cli/helpers.rb +0 -117
  102. data/lib/space_architect/cli/init.rb +0 -35
  103. data/lib/space_architect/cli/list.rb +0 -30
  104. data/lib/space_architect/cli/new.rb +0 -43
  105. data/lib/space_architect/cli/options.rb +0 -12
  106. data/lib/space_architect/cli/path.rb +0 -22
  107. data/lib/space_architect/cli/repo.rb +0 -88
  108. data/lib/space_architect/cli/shell.rb +0 -137
  109. data/lib/space_architect/cli/show.rb +0 -27
  110. data/lib/space_architect/cli/status.rb +0 -39
  111. data/lib/space_architect/cli/use.rb +0 -23
  112. data/lib/space_architect/version.rb +0 -5
  113. data/vendor/repo-tender/lib/space_architect/pristine.rb +0 -44
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async/http/client"
4
+ require "async/http/endpoint"
5
+ require "json"
6
+
7
+ module Space::Architect
8
+ class RunCreator
9
+ def initialize(host, token, client: nil)
10
+ @host = host.chomp("/")
11
+ @token = token
12
+ @client = client
13
+ end
14
+
15
+ # POSTs to /runs and returns the integer run id.
16
+ # Raises Space::Core::Error on any failure — never returns nil.
17
+ def create
18
+ Sync do
19
+ if @client
20
+ response = @client.post("/runs", headers: headers, body: nil)
21
+ parse_response(response)
22
+ else
23
+ Async::HTTP::Client.open(Async::HTTP::Endpoint.parse(@host)) do |c|
24
+ response = c.post("/runs", headers: headers, body: nil)
25
+ parse_response(response)
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def headers
34
+ [
35
+ ["authorization", "Bearer #{@token}"],
36
+ ["content-type", "application/json"]
37
+ ]
38
+ end
39
+
40
+ def parse_response(response)
41
+ status = response.status
42
+ body = response.read || ""
43
+ raise Space::Core::Error, "POST /runs failed (#{status}): #{body[0, 200]}" unless status == 201
44
+
45
+ parsed = JSON.parse(body)
46
+ id = parsed["id"]
47
+ raise Space::Core::Error, "POST /runs: missing or non-integer id in response: #{body[0, 200]}" \
48
+ unless id.is_a?(Integer)
49
+
50
+ id
51
+ end
52
+ end
53
+ end
@@ -3,103 +3,105 @@
3
3
  require "fileutils"
4
4
  require "pathname"
5
5
 
6
- module SpaceArchitect
7
- module SkillInstaller
8
- PROVIDERS = %w[claude codex opencode pi].freeze
9
-
10
- class << self
11
- def source_root
12
- Pathname.new(__dir__).parent.parent.join("skill")
13
- end
14
-
15
- def dest_root(provider, project:, env:, cwd: Dir.pwd)
16
- case provider.to_s
17
- when "claude"
18
- base = project ? Pathname.new(cwd) : Pathname.new(XDG.home(env: env))
19
- base.join(".claude", "skills")
20
- when "codex"
21
- base = project ? Pathname.new(cwd) : Pathname.new(XDG.home(env: env))
22
- base.join(".agents", "skills")
23
- when "opencode"
24
- project ? Pathname.new(cwd).join(".opencode", "skills") : XDG.config_home(env: env).join("skills")
25
- when "pi"
26
- base = project ? Pathname.new(cwd) : Pathname.new(pi_agent_dir(env: env))
27
- base.join("skills")
28
- else
29
- raise Error, "Unknown provider '#{provider}'. Expected one of: #{PROVIDERS.join(', ')}"
6
+ module Space
7
+ module Architect
8
+ module SkillInstaller
9
+ PROVIDERS = %w[claude codex opencode pi].freeze
10
+
11
+ class << self
12
+ def source_root
13
+ Pathname.new(__dir__).parent.parent.join("skill")
30
14
  end
31
- end
32
-
33
- def install(provider, project:, force:, env:, cwd: Dir.pwd, dry_run: false)
34
- validate_provider!(provider)
35
- dest = dest_root(provider, project: project, env: env, cwd: cwd)
36
- results = []
37
15
 
38
- source_skills.each do |skill_dir|
39
- name = skill_dir.basename.to_s
40
- skill_dest = dest.join(name)
41
- results << install_skill(skill_dir, skill_dest, force: force, dry_run: dry_run)
16
+ def dest_root(provider, project:, env:, cwd: Dir.pwd)
17
+ case provider.to_s
18
+ when "claude"
19
+ base = project ? Pathname.new(cwd) : Pathname.new(Space::Core::XDG.home(env: env))
20
+ base.join(".claude", "skills")
21
+ when "codex"
22
+ base = project ? Pathname.new(cwd) : Pathname.new(Space::Core::XDG.home(env: env))
23
+ base.join(".agents", "skills")
24
+ when "opencode"
25
+ project ? Pathname.new(cwd).join(".opencode", "skills") : Space::Core::XDG.config_home(env: env).join("skills")
26
+ when "pi"
27
+ base = project ? Pathname.new(cwd) : Pathname.new(pi_agent_dir(env: env))
28
+ base.join("skills")
29
+ else
30
+ raise Space::Core::Error, "Unknown provider '#{provider}'. Expected one of: #{PROVIDERS.join(', ')}"
31
+ end
42
32
  end
43
33
 
44
- { dest_root: dest, skills: results, dry_run: dry_run }
45
- end
46
-
47
- def source_skills
48
- source_root.children.select(&:directory?)
49
- end
34
+ def install(provider, project:, force:, env:, cwd: Dir.pwd, dry_run: false)
35
+ validate_provider!(provider)
36
+ dest = dest_root(provider, project: project, env: env, cwd: cwd)
37
+ results = []
50
38
 
51
- private
52
-
53
- def validate_provider!(provider)
54
- return if PROVIDERS.include?(provider.to_s)
39
+ source_skills.each do |skill_dir|
40
+ name = skill_dir.basename.to_s
41
+ skill_dest = dest.join(name)
42
+ results << install_skill(skill_dir, skill_dest, force: force, dry_run: dry_run)
43
+ end
55
44
 
56
- raise Error, "Unknown provider '#{provider}'. Expected one of: #{PROVIDERS.join(', ')}"
57
- end
45
+ { dest_root: dest, skills: results, dry_run: dry_run }
46
+ end
58
47
 
59
- def pi_agent_dir(env:)
60
- Pathname.new(env.fetch("PI_CODING_AGENT_DIR", File.join(XDG.home(env: env), ".pi", "agent")))
61
- end
48
+ def source_skills
49
+ source_root.children.select(&:directory?)
50
+ end
62
51
 
63
- def install_skill(source, dest, force:, dry_run:)
64
- name = source.basename.to_s
52
+ private
65
53
 
66
- if dest.exist?
67
- if same_content?(source, dest)
68
- return { name: name, action: :unchanged, path: dest }
69
- end
54
+ def validate_provider!(provider)
55
+ return if PROVIDERS.include?(provider.to_s)
70
56
 
71
- unless force
72
- return { name: name, action: :conflict, path: dest } if dry_run
57
+ raise Space::Core::Error, "Unknown provider '#{provider}'. Expected one of: #{PROVIDERS.join(', ')}"
58
+ end
73
59
 
74
- raise Error,
75
- "Refusing to overwrite existing skill at #{dest}. Re-run with --force."
76
- end
60
+ def pi_agent_dir(env:)
61
+ Pathname.new(env.fetch("PI_CODING_AGENT_DIR", File.join(Space::Core::XDG.home(env: env), ".pi", "agent")))
62
+ end
77
63
 
78
- unless dry_run
79
- FileUtils.rm_rf(dest)
80
- FileUtils.cp_r(source, dest)
81
- end
82
- { name: name, action: dry_run ? :would_update : :updated, path: dest }
83
- else
84
- unless dry_run
85
- FileUtils.mkdir_p(dest.parent)
86
- FileUtils.cp_r(source, dest)
64
+ def install_skill(source, dest, force:, dry_run:)
65
+ name = source.basename.to_s
66
+
67
+ if dest.exist?
68
+ if same_content?(source, dest)
69
+ return { name: name, action: :unchanged, path: dest }
70
+ end
71
+
72
+ unless force
73
+ return { name: name, action: :conflict, path: dest } if dry_run
74
+
75
+ raise Space::Core::Error,
76
+ "Refusing to overwrite existing skill at #{dest}. Re-run with --force."
77
+ end
78
+
79
+ unless dry_run
80
+ FileUtils.rm_rf(dest)
81
+ FileUtils.cp_r(source, dest)
82
+ end
83
+ { name: name, action: dry_run ? :would_update : :updated, path: dest }
84
+ else
85
+ unless dry_run
86
+ FileUtils.mkdir_p(dest.parent)
87
+ FileUtils.cp_r(source, dest)
88
+ end
89
+ { name: name, action: dry_run ? :would_install : :installed, path: dest }
87
90
  end
88
- { name: name, action: dry_run ? :would_install : :installed, path: dest }
89
91
  end
90
- end
91
92
 
92
- def same_content?(source, dest)
93
- return false unless dest.directory?
93
+ def same_content?(source, dest)
94
+ return false unless dest.directory?
94
95
 
95
- source_files = Dir.glob("#{source}/**/*").reject { |f| File.directory?(f) }
96
- dest_files = Dir.glob("#{dest}/**/*").reject { |f| File.directory?(f) }
96
+ source_files = Dir.glob("#{source}/**/*").reject { |f| File.directory?(f) }
97
+ dest_files = Dir.glob("#{dest}/**/*").reject { |f| File.directory?(f) }
97
98
 
98
- return false if source_files.length != dest_files.length
99
+ return false if source_files.length != dest_files.length
99
100
 
100
- source_files.sort.zip(dest_files.sort).all? do |sf, df|
101
- rel = sf.sub("#{source}/", "")
102
- df.end_with?(rel) && File.read(sf) == File.read(df)
101
+ source_files.sort.zip(dest_files.sort).all? do |sf, df|
102
+ rel = sf.sub("#{source}/", "")
103
+ df.end_with?(rel) && File.read(sf) == File.read(df)
104
+ end
103
105
  end
104
106
  end
105
107
  end
@@ -1,27 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- $LOAD_PATH.unshift File.expand_path("../vendor/repo-tender/lib", __dir__)
4
- require "space_architect/pristine"
3
+ require "space_core"
4
+ require "space_src"
5
5
 
6
- require_relative "space_architect/version"
7
- require_relative "space_architect/errors"
8
- require_relative "space_architect/warnings"
9
- SpaceArchitect::Warnings.disable_experimental!
10
- require_relative "space_architect/atomic_write"
11
- require_relative "space_architect/xdg"
12
- require_relative "space_architect/config"
13
- require_relative "space_architect/state"
14
- require_relative "space_architect/slugger"
15
- require_relative "space_architect/space"
16
- require_relative "space_architect/repo_reference"
17
- require_relative "space_architect/repo_resolver"
18
- require_relative "space_architect/git_client"
19
- require_relative "space_architect/mise_client"
20
- require_relative "space_architect/space_store"
21
- require_relative "space_architect/shell_integration"
22
- require_relative "space_architect/skill_installer"
23
- require_relative "space_architect/terminal"
24
6
  require_relative "space_architect/harness"
7
+ require_relative "space_architect/run_creator"
25
8
  require_relative "space_architect/dispatcher"
26
9
  require_relative "space_architect/architect_mission"
10
+ require_relative "space_architect/skill_installer"
11
+ require_relative "space_architect/research"
27
12
  require_relative "space_architect/cli"
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
 
5
- module SpaceArchitect
5
+ module Space::Core
6
6
  module AtomicWrite
7
7
  module_function
8
8
 
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+
5
+ module Space::Core::CLI
6
+ # Base for every space/architect command. dry-cli (>= 0.7.0) copies a
7
+ # superclass's options to its subclasses, so the global colour options are
8
+ # declared once here and inherited everywhere instead of being mixed in per
9
+ # command. Helpers (terminal/store/render) ride along by inheritance too.
10
+ #
11
+ # The `src` binary has its own output-mode system (--plain/--json) and does
12
+ # NOT inherit from this base.
13
+ class BaseCommand < Dry::CLI::Command
14
+ include Helpers
15
+
16
+ option :color, type: :string, default: "auto", desc: "Color output: auto, always, never"
17
+ option :colors, type: :string, desc: "Alias for --color"
18
+ end
19
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Space::Core::CLI
4
+ module Config
5
+ class Show < BaseCommand
6
+ desc "Show current config"
7
+
8
+ def call(**opts)
9
+ setup_terminal(**opts.slice(:color, :colors))
10
+ handle_errors do
11
+ rows = Space::Core::Config::EDITABLE_KEYS.map do |key|
12
+ value = project_config.data[key]
13
+ [key, value.nil? ? "" : value.to_s]
14
+ end
15
+ terminal.say terminal.table(%w[Key Value], rows)
16
+ CLI.record_outcome(Outcome.new(exit_code: 0))
17
+ end
18
+ end
19
+ end
20
+
21
+ class ConfigPath < BaseCommand
22
+ desc "Print the config file path"
23
+
24
+ def call(**opts)
25
+ setup_terminal(**opts.slice(:color, :colors))
26
+ handle_errors do
27
+ terminal.say terminal.path(project_config.path)
28
+ CLI.record_outcome(Outcome.new(exit_code: 0))
29
+ end
30
+ end
31
+ end
32
+
33
+ class Set < BaseCommand
34
+ desc "Set a config key"
35
+ argument :key, required: true, desc: "Config key"
36
+ argument :value, required: true, desc: "Config value"
37
+
38
+ def call(key:, value:, **opts)
39
+ setup_terminal(**opts.slice(:color, :colors))
40
+ handle_errors do
41
+ project_config.set(key, value)
42
+ stored = project_config.data[key]
43
+ terminal.success "Set #{key}=#{stored.nil? ? '' : stored.to_s}"
44
+ CLI.record_outcome(Outcome.new(exit_code: 0))
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Space::Core::CLI
4
+ class Current < BaseCommand
5
+ desc "Show the current space"
6
+
7
+ def call(**opts)
8
+ setup_terminal(**opts.slice(:color, :colors))
9
+ render(store.find) do |space|
10
+ terminal.say space.id.to_s
11
+ terminal.say terminal.path(space.path)
12
+ CLI.record_outcome(Outcome.new(exit_code: 0))
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require "pastel"
5
+
6
+ module Space::Core::CLI
7
+ # Colourful replacement for dry-cli's plain `Usage` listing — the "global
8
+ # help" shown by `space`, `architect`, and every bare namespace (`space repo`,
9
+ # `worktree`, ...). Per-command help still flows through dry-cli's Banner,
10
+ # whose content we like; only the listing is reskinned.
11
+ #
12
+ # We reopen Dry::CLI::Usage.call (below) to delegate here, so BOTH the
13
+ # intercepted top-level help and dry-cli's own bare-namespace path get the
14
+ # same treatment from one place. Colour follows CLI.help_pastel, set once per
15
+ # invocation from the output stream's tty-ness and --color, so piped and test
16
+ # output stay plain. The `src` binary never loads space_core, so its own plain
17
+ # Usage is untouched.
18
+ module Help
19
+ TAGLINE = "date-prefixed workspaces; repos provisioned on fibers at copy-on-write speed"
20
+
21
+ module_function
22
+
23
+ def call(result, pastel: CLI.help_pastel)
24
+ rows = listing(result)
25
+ width = rows.map { |label, _| label.length }.max || 0
26
+
27
+ lines = rows.map do |label, description|
28
+ painted = pastel.cyan(label.ljust(width))
29
+ description ? " #{painted} #{pastel.dim("# #{description}")}" : " #{painted}"
30
+ end
31
+
32
+ [header(result, pastel), pastel.bold("Commands:"), *lines, footer(result, pastel)]
33
+ .compact.join("\n")
34
+ end
35
+
36
+ # The richer header only makes sense at the true root (`space` / `architect`),
37
+ # not on every sub-namespace listing.
38
+ def header(result, pastel)
39
+ return unless result.names.empty?
40
+
41
+ "#{pastel.bold.cyan("space-architect")} #{pastel.dim(Space::Core::VERSION)} " \
42
+ "#{pastel.dim("— #{TAGLINE}")}\n"
43
+ end
44
+
45
+ def footer(result, pastel)
46
+ "\n#{pastel.dim("Run `#{program_prefix(result)} <command> --help` for details on a command.")}"
47
+ end
48
+
49
+ # "space" at the root, "space repo" inside a namespace. The `space`/`architect`
50
+ # binaries inject their name into ARGV, so $PROGRAM_NAME and the leading
51
+ # namespace segment can collide ("space space ..."); drop the duplicate.
52
+ def program_prefix(result)
53
+ prog = File.basename($PROGRAM_NAME)
54
+ names = result.names.dup
55
+ names.shift if names.first == prog
56
+ [prog, *names].join(" ")
57
+ end
58
+
59
+ # [[label_with_banner, description_or_nil], ...] sorted by command name.
60
+ def listing(result)
61
+ result.children.sort_by { |name, _| name }.filter_map do |name, node|
62
+ next if node.hidden
63
+
64
+ [label(result, name, node), description(node)]
65
+ end
66
+ end
67
+
68
+ def label(result, name, node)
69
+ "#{program_prefix(result)} #{name}#{banner(node)}"
70
+ end
71
+
72
+ def banner(node)
73
+ if node.command && node.leaf? && node.children?
74
+ " [ARGUMENT|SUBCOMMAND]"
75
+ elsif node.leaf?
76
+ arguments(node.command)
77
+ else
78
+ " [SUBCOMMAND]"
79
+ end
80
+ end
81
+
82
+ def arguments(command)
83
+ return "" unless command.respond_to?(:required_arguments)
84
+
85
+ names = command.required_arguments.map { |arg| arg.name.to_s.upcase }
86
+ names += command.optional_arguments.map { |arg| "[#{arg.name.to_s.upcase}]" }
87
+ names.empty? ? "" : " #{names.join(" ")}"
88
+ end
89
+
90
+ def description(node)
91
+ return unless node.leaf? && node.command.respond_to?(:description)
92
+
93
+ node.command.description
94
+ end
95
+ end
96
+ end
97
+
98
+ # Route dry-cli's plain namespace/root listing through our colourful renderer.
99
+ # We replace Usage.call wholesale and depend only on the LookupResult/Node API
100
+ # (children, command, leaf?/children?/hidden, names) rather than copying Usage's
101
+ # internals — see notes/ruby-cli-gems-report.md.
102
+ module Dry
103
+ class CLI
104
+ module Usage
105
+ def self.call(result)
106
+ Space::Core::CLI::Help.call(result)
107
+ end
108
+ end
109
+ end
110
+ 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