space-architect 1.2.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 (117) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +103 -0
  3. data/README.md +248 -146
  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 +107 -121
  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 +109 -0
  23. data/lib/space_architect.rb +5 -19
  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 +16 -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 +40 -18
  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 +329 -0
  97. data/skill/architect/dispatch.md +308 -0
  98. data/skill/architect/research.md +98 -0
  99. data/skill/architect-research/SKILL.md +165 -0
  100. data/skill/architect-research/lanes.md +191 -0
  101. data/skill/architect-vocabulary/SKILL.md +141 -0
  102. metadata +122 -67
  103. data/lib/space_architect/cli/config.rb +0 -61
  104. data/lib/space_architect/cli/current.rb +0 -22
  105. data/lib/space_architect/cli/helpers.rb +0 -117
  106. data/lib/space_architect/cli/init.rb +0 -35
  107. data/lib/space_architect/cli/list.rb +0 -30
  108. data/lib/space_architect/cli/new.rb +0 -43
  109. data/lib/space_architect/cli/options.rb +0 -12
  110. data/lib/space_architect/cli/path.rb +0 -22
  111. data/lib/space_architect/cli/repo.rb +0 -88
  112. data/lib/space_architect/cli/shell.rb +0 -137
  113. data/lib/space_architect/cli/show.rb +0 -27
  114. data/lib/space_architect/cli/status.rb +0 -39
  115. data/lib/space_architect/cli/use.rb +0 -23
  116. data/lib/space_architect/version.rb +0 -5
  117. data/vendor/repo-tender/lib/space_architect/pristine.rb +0 -37
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+
6
+ module Space::Architect
7
+ module Research
8
+ class Supervisor
9
+ DEFAULT_MODEL = Harness::CLAUDE_DEFAULT_MODEL
10
+ DEFAULT_MAX_TURNS = 40
11
+
12
+ def initialize(space:, bin: nil)
13
+ @space = space
14
+ @bin = bin
15
+ @registry = Registry.new(space.path.join("build", "research", "registry.yaml"))
16
+ end
17
+
18
+ # Dispatch each prompt file as a detached read-only claude -p child.
19
+ # Returns array of Run objects (non-blocking).
20
+ def dispatch(prompts, model: DEFAULT_MODEL, max_turns: DEFAULT_MAX_TURNS)
21
+ prompts.map { |path| dispatch_one(Pathname.new(path), model: model, max_turns: max_turns) }
22
+ end
23
+
24
+ # Classify each registered run and return per-run state hashes.
25
+ def status
26
+ @registry.all.map do |run|
27
+ state = classify(run)
28
+ tail = tail_lines(run.run_log_path, 5)
29
+ { run: run, state: state, tail: tail }
30
+ end
31
+ end
32
+
33
+ # Async mux: tail all runs to terminal. Returns :ok or :failed.
34
+ def wait(quiet: false, level: 1, thinking: false, jsonl: false, out: $stdout)
35
+ effective_level = quiet ? 0 : level
36
+ renderer = Renderer.new(level: effective_level, thinking: thinking, jsonl: jsonl)
37
+ runs = @registry.all
38
+ return :ok if runs.empty?
39
+
40
+ Mux.new(runs, renderer: renderer, out: out).run
41
+ end
42
+
43
+ private
44
+
45
+ def dispatch_one(path, model:, max_turns:)
46
+ id = derive_id(path)
47
+ topic = id.sub(/\A\d+-/, "")
48
+ dir = @space.path.join("build", "research", id)
49
+ FileUtils.mkdir_p(dir)
50
+
51
+ prompt_path = dir.join("prompt.md")
52
+ run_log_path = dir.join("run.jsonl")
53
+ report_path = dir.join("report.md")
54
+
55
+ FileUtils.cp(path.to_s, prompt_path.to_s)
56
+
57
+ harness = Harness::ClaudeCodeHarness.new(
58
+ model: model,
59
+ max_turns: max_turns,
60
+ bin: @bin,
61
+ allowed_tools: READONLY_TOOLS,
62
+ disallowed_tools: ""
63
+ )
64
+
65
+ pid = harness.run_detached(
66
+ prompt_path: prompt_path,
67
+ run_log_path: run_log_path,
68
+ chdir: @space.path
69
+ )
70
+
71
+ run = Run.new(
72
+ id: id,
73
+ topic: topic,
74
+ pid: pid,
75
+ dir: dir,
76
+ prompt_path: prompt_path,
77
+ run_log_path: run_log_path,
78
+ report_path: report_path,
79
+ model: model,
80
+ dispatched_at: Time.now
81
+ )
82
+ @registry.add(run)
83
+ run
84
+ end
85
+
86
+ def derive_id(path)
87
+ File.basename(path.to_s).sub(/\.prompt\.md\z/, "").sub(/\.md\z/, "")
88
+ end
89
+
90
+ def classify(run)
91
+ content = File.exist?(run.run_log_path.to_s) ? File.read(run.run_log_path.to_s) : ""
92
+ events = content.lines.filter_map { |l| JSON.parse(l.chomp) rescue nil }
93
+ terminal = events.find { |e| e["type"] == "result" }
94
+
95
+ return :complete if terminal && !terminal["is_error"]
96
+ return :failed if terminal && terminal["is_error"]
97
+
98
+ pid_alive = begin; Process.kill(0, run.pid); true; rescue Errno::ESRCH, Errno::EPERM; false; end
99
+ pid_alive ? :running : :failed
100
+ end
101
+
102
+ def tail_lines(path, n)
103
+ return [] unless File.exist?(path.to_s)
104
+ File.readlines(path.to_s).last(n).map(&:chomp)
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Space::Architect
4
+ module Research
5
+ READONLY_TOOLS = "Read,Grep,Glob,WebSearch,WebFetch"
6
+ end
7
+ end
8
+
9
+ require_relative "research/run"
10
+ require_relative "research/registry"
11
+ require_relative "research/renderer"
12
+ require_relative "research/mux"
13
+ require_relative "research/supervisor"
@@ -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
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
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")
14
+ end
15
+
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
32
+ end
33
+
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 = []
38
+
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
44
+
45
+ { dest_root: dest, skills: results, dry_run: dry_run }
46
+ end
47
+
48
+ def source_skills
49
+ source_root.children.select(&:directory?)
50
+ end
51
+
52
+ private
53
+
54
+ def validate_provider!(provider)
55
+ return if PROVIDERS.include?(provider.to_s)
56
+
57
+ raise Space::Core::Error, "Unknown provider '#{provider}'. Expected one of: #{PROVIDERS.join(', ')}"
58
+ end
59
+
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
63
+
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 }
90
+ end
91
+ end
92
+
93
+ def same_content?(source, dest)
94
+ return false unless dest.directory?
95
+
96
+ source_files = Dir.glob("#{source}/**/*").reject { |f| File.directory?(f) }
97
+ dest_files = Dir.glob("#{dest}/**/*").reject { |f| File.directory?(f) }
98
+
99
+ return false if source_files.length != dest_files.length
100
+
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
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -1,26 +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/terminal"
23
6
  require_relative "space_architect/harness"
7
+ require_relative "space_architect/run_creator"
24
8
  require_relative "space_architect/dispatcher"
25
9
  require_relative "space_architect/architect_mission"
10
+ require_relative "space_architect/skill_installer"
11
+ require_relative "space_architect/research"
26
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