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,388 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpaceArchitect
4
+ module CLI
5
+ module Architect
6
+ class Init < Dry::CLI::Command
7
+ include GlobalOptions
8
+ include Helpers
9
+
10
+ desc "Scaffold architect mission memory in the current space"
11
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
12
+
13
+ def call(space: nil, **opts)
14
+ setup_terminal(**opts.slice(:color, :colors))
15
+ handle_errors do
16
+ render(store.find(space)) do |sp|
17
+ mission = ArchitectMission.new(space: sp)
18
+ path = mission.init!
19
+ terminal.say "Mission ready: #{terminal.path(path)}"
20
+ CLI.record_outcome(Outcome.new(exit_code: 0))
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ class New < Dry::CLI::Command
27
+ include GlobalOptions
28
+ include Helpers
29
+
30
+ desc "Scaffold the next iteration file (architecture/I<NN>-<iteration>.md)"
31
+ argument :iteration, required: true, desc: "Iteration name (kebab-case)"
32
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
33
+
34
+ def call(iteration:, space: nil, **opts)
35
+ setup_terminal(**opts.slice(:color, :colors))
36
+ handle_errors do
37
+ render(store.find(space)) do |sp|
38
+ mission = ArchitectMission.new(space: sp)
39
+ path = mission.new_iteration!(iteration)
40
+ terminal.say "Iteration scaffolded: #{terminal.path(path)}"
41
+ CLI.record_outcome(Outcome.new(exit_code: 0))
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ class Status < Dry::CLI::Command
48
+ include GlobalOptions
49
+ include Helpers
50
+
51
+ desc "Show architect mission state (read-only)"
52
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
53
+
54
+ def call(space: nil, **opts)
55
+ setup_terminal(**opts.slice(:color, :colors))
56
+ handle_errors do
57
+ render(store.find(space)) do |sp|
58
+ mission = ArchitectMission.new(space: sp)
59
+ info = mission.status
60
+ block = info[:block]
61
+
62
+ terminal.say "Mission status: #{block['status'] || '(none)'}"
63
+ terminal.say "Current iteration: #{block['current_iteration'] || '(none)'}"
64
+
65
+ iterations = block["iterations"] || []
66
+ if iterations.empty?
67
+ terminal.say "Iterations: (none)"
68
+ else
69
+ rows = iterations.map do |s|
70
+ nn = s["ordinal"] ? format("%02d", s["ordinal"]) : "-"
71
+ lane_list = s["lanes"] || []
72
+ lanes_str = lane_list.map do |l|
73
+ h = l["harness"] || "claude-code"
74
+ m = l["model"] || Harness::CLAUDE_DEFAULT_MODEL
75
+ eff = l["effort"] ? "·#{l['effort']}" : ""
76
+ "#{l['name']}(#{l['repo']}·#{h}·#{m}#{eff})"
77
+ end.join(", ")
78
+ lanes = lane_list.any? { |l| l["variant"] } ? "variant: #{lanes_str}" : lanes_str
79
+ lanes = "#{lanes} → winner: #{s['winner']}" if s["winner"]
80
+ [nn, s["name"], s["freeze_sha"]&.[](0, 8) || "-", lanes, s["verdict"] || "-"]
81
+ end
82
+ terminal.say terminal.table(%w[II Iteration FreezeSHA Lanes Verdict], rows)
83
+ end
84
+
85
+ unless info[:iteration_files].empty?
86
+ terminal.say "Iteration files: #{info[:iteration_files].join(', ')}"
87
+ end
88
+
89
+ CLI.record_outcome(Outcome.new(exit_code: 0))
90
+ end
91
+ end
92
+ end
93
+ end
94
+
95
+ class Freeze < Dry::CLI::Command
96
+ include GlobalOptions
97
+ include Helpers
98
+
99
+ desc "Freeze gates for an iteration"
100
+ argument :iteration, required: true, desc: "Iteration name"
101
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
102
+
103
+ def call(iteration:, space: nil, **opts)
104
+ setup_terminal(**opts.slice(:color, :colors))
105
+ handle_errors do
106
+ render(store.find(space)) do |sp|
107
+ mission = ArchitectMission.new(space: sp)
108
+ sha = mission.freeze!(iteration)
109
+ terminal.say "Frozen #{iteration} at #{sha}"
110
+ CLI.record_outcome(Outcome.new(exit_code: 0))
111
+ end
112
+ end
113
+ end
114
+ end
115
+
116
+ class Verify < Dry::CLI::Command
117
+ include GlobalOptions
118
+ include Helpers
119
+
120
+ desc "Post-flight mechanical checks for an iteration (reports only, no judgment)"
121
+ argument :iteration, required: true, desc: "Iteration name"
122
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
123
+
124
+ def call(iteration:, space: nil, **opts)
125
+ setup_terminal(**opts.slice(:color, :colors))
126
+ handle_errors do
127
+ render(store.find(space)) do |sp|
128
+ mission = ArchitectMission.new(space: sp)
129
+ results = mission.verify(iteration)
130
+
131
+ if results.empty?
132
+ terminal.say "No lanes recorded for iteration '#{iteration}'"
133
+ CLI.record_outcome(Outcome.new(exit_code: 0))
134
+ next
135
+ end
136
+
137
+ rows = results.flat_map do |r|
138
+ lane = r[:lane]
139
+ c = r[:checks]
140
+ [
141
+ [lane, "(a) frozen sections untouched", pass_fail(c[:frozen_untouched])],
142
+ [lane, "(b) no builder commits", pass_fail(c[:no_builder_commits])],
143
+ [lane, "(c) scratch report exists", pass_fail(c[:report_exists])],
144
+ [lane, "(d) in-bounds", pass_fail(c[:in_bounds])]
145
+ ]
146
+ end
147
+
148
+ terminal.say terminal.table(%w[Lane Check Result], rows)
149
+ CLI.record_outcome(Outcome.new(exit_code: 0))
150
+ end
151
+ end
152
+ end
153
+
154
+ private
155
+
156
+ def pass_fail(val)
157
+ case val
158
+ when true then "PASS"
159
+ when false then "FAIL"
160
+ else "N/A"
161
+ end
162
+ end
163
+ end
164
+
165
+ class Dispatch < Dry::CLI::Command
166
+ include GlobalOptions
167
+ include Helpers
168
+
169
+ desc "Dispatch a builder for a lane (streams to build/<id>-<lane>/run.jsonl)"
170
+ argument :iteration, required: true, desc: "Iteration name"
171
+ argument :lane, required: true, desc: "Lane name"
172
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
173
+ option :model, default: nil, desc: "Model to use (default: lane entry or claude-sonnet-4-6)"
174
+ option :max_turns, default: "200", desc: "Max turns for the builder"
175
+ option :harness, default: nil, desc: "Harness override (claude-code, opencode)"
176
+ option :effort, default: nil, desc: "Reasoning effort override (opencode only; sets reasoningEffort in the model config)"
177
+
178
+ def call(iteration:, lane:, space: nil, model: nil,
179
+ max_turns: "200", harness: nil, effort: nil, **opts)
180
+ setup_terminal(**opts.slice(:color, :colors))
181
+ handle_errors do
182
+ render(store.find(space)) do |sp|
183
+ mission = ArchitectMission.new(space: sp)
184
+ kwargs = { max_turns: max_turns.to_i }
185
+ kwargs[:model] = model if model
186
+ kwargs[:harness] = harness if harness
187
+ kwargs[:effort] = effort if effort
188
+ res = mission.dispatch(iteration, lane, **kwargs)
189
+ terminal.say "Run log: #{terminal.path(res[:run_log])}"
190
+ terminal.say "Report: #{terminal.path(res[:report])}"
191
+ terminal.say "Builder exited with status #{res[:exit_code]}"
192
+ CLI.record_outcome(Outcome.new(exit_code: res[:exit_code]))
193
+ end
194
+ end
195
+ end
196
+ end
197
+
198
+ module Worktree
199
+ class Add < Dry::CLI::Command
200
+ include GlobalOptions
201
+ include Helpers
202
+
203
+ desc "Create a worktree for a lane"
204
+ argument :repo, required: true, desc: "Repo name (under repos/)"
205
+ argument :iteration, required: true, desc: "Iteration name"
206
+ argument :lane, required: true, desc: "Lane name"
207
+ option :base, default: nil, desc: "Base ref (default: HEAD of repo)"
208
+ option :harness, default: "claude-code", desc: "Harness (claude-code, opencode)"
209
+ option :model, default: nil, desc: "Model (required for opencode)"
210
+ option :effort, default: nil, desc: "Reasoning effort (opencode only; sets reasoningEffort in the model config)"
211
+
212
+ def call(repo:, iteration:, lane:, base: nil, harness: "claude-code", model: nil, effort: nil, **opts)
213
+ setup_terminal(**opts.slice(:color, :colors))
214
+ handle_errors do
215
+ render(store.find) do |sp|
216
+ mission = ArchitectMission.new(space: sp)
217
+ result = mission.worktree_add(repo, iteration, lane, base: base,
218
+ harness: harness, model: model, effort: effort)
219
+ terminal.say "Worktree: #{terminal.path(result[:worktree])}"
220
+ terminal.say "Base SHA: #{result[:base_sha]}"
221
+ CLI.record_outcome(Outcome.new(exit_code: 0))
222
+ end
223
+ end
224
+ end
225
+ end
226
+
227
+ class Remove < Dry::CLI::Command
228
+ include GlobalOptions
229
+ include Helpers
230
+
231
+ desc "Remove a lane worktree"
232
+ argument :iteration, required: true, desc: "Iteration name"
233
+ argument :lane, required: true, desc: "Lane name"
234
+
235
+ def call(iteration:, lane:, **opts)
236
+ setup_terminal(**opts.slice(:color, :colors))
237
+ handle_errors do
238
+ render(store.find) do |sp|
239
+ mission = ArchitectMission.new(space: sp)
240
+ mission.worktree_remove(iteration, lane)
241
+ terminal.say "Removed worktree for #{iteration}/#{lane}"
242
+ CLI.record_outcome(Outcome.new(exit_code: 0))
243
+ end
244
+ end
245
+ end
246
+ end
247
+
248
+ class List < Dry::CLI::Command
249
+ include GlobalOptions
250
+ include Helpers
251
+
252
+ desc "List active architect worktrees"
253
+
254
+ def call(**opts)
255
+ setup_terminal(**opts.slice(:color, :colors))
256
+ handle_errors do
257
+ render(store.find) do |sp|
258
+ mission = ArchitectMission.new(space: sp)
259
+ worktrees = mission.worktree_list
260
+ if worktrees.empty?
261
+ terminal.say "No active architect worktrees"
262
+ else
263
+ worktrees.each { |wt| terminal.say wt }
264
+ end
265
+ CLI.record_outcome(Outcome.new(exit_code: 0))
266
+ end
267
+ end
268
+ end
269
+ end
270
+ end
271
+
272
+ module Variant
273
+ class Add < Dry::CLI::Command
274
+ include GlobalOptions
275
+ include Helpers
276
+
277
+ desc "Create a variant set (competing lanes over one frozen spec)"
278
+ argument :repo, required: true, desc: "Repo name (under repos/)"
279
+ argument :iteration, required: true, desc: "Iteration name"
280
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
281
+ option :pairs, required: true, desc: "Comma-separated harness[:model] pairs (e.g. claude-code,opencode:fireworks-ai/accounts/fireworks/models/glm-5p2)"
282
+ option :base, default: nil, desc: "Base ref (default: HEAD of repo)"
283
+ option :prompt, default: nil, desc: "Prompt file to fan-out byte-identical to each variant"
284
+
285
+ def call(repo:, iteration:, space: nil, pairs:, base: nil, prompt: nil, **opts)
286
+ setup_terminal(**opts.slice(:color, :colors))
287
+ handle_errors do
288
+ render(store.find(space)) do |sp|
289
+ parsed_pairs = pairs.to_s.split(",").map do |spec|
290
+ harness, model = spec.split(":", 2)
291
+ model = nil if model.nil? || model.empty?
292
+ [harness, model]
293
+ end
294
+
295
+ mission = ArchitectMission.new(space: sp)
296
+ variants = mission.variant_add(repo, iteration, parsed_pairs, base: base, prompt: prompt)
297
+ variants.each do |v|
298
+ terminal.say "#{v[:name]} · #{v[:harness]} · #{v[:model] || "(default)"} · #{terminal.path(v[:worktree])}"
299
+ end
300
+ CLI.record_outcome(Outcome.new(exit_code: 0))
301
+ end
302
+ end
303
+ end
304
+ end
305
+
306
+ class Promote < Dry::CLI::Command
307
+ include GlobalOptions
308
+ include Helpers
309
+
310
+ desc "Promote one variant of a variant set as the winner"
311
+ argument :iteration, required: true, desc: "Iteration name"
312
+ argument :winner, required: true, desc: "Variant lane name to promote (e.g. v02)"
313
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
314
+
315
+ def call(iteration:, winner:, space: nil, **opts)
316
+ setup_terminal(**opts.slice(:color, :colors))
317
+ handle_errors do
318
+ render(store.find(space)) do |sp|
319
+ mission = ArchitectMission.new(space: sp)
320
+ result = mission.variant_promote(iteration, winner)
321
+ if result[:discarded].any?
322
+ terminal.say "Promoted #{result[:winner]} (discarded: #{result[:discarded].join(', ')})"
323
+ else
324
+ terminal.say "Promoted #{result[:winner]}"
325
+ end
326
+ CLI.record_outcome(Outcome.new(exit_code: 0))
327
+ end
328
+ end
329
+ end
330
+ end
331
+
332
+ class Compare < Dry::CLI::Command
333
+ include GlobalOptions
334
+ include Helpers
335
+
336
+ desc "Compare variants of an iteration's variant set (read-only)"
337
+ argument :iteration, required: true, desc: "Iteration name"
338
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
339
+
340
+ def call(iteration:, space: nil, **opts)
341
+ setup_terminal(**opts.slice(:color, :colors))
342
+ handle_errors do
343
+ render(store.find(space)) do |sp|
344
+ mission = ArchitectMission.new(space: sp)
345
+ info = mission.variant_compare(iteration)
346
+
347
+ terminal.say "Variant comparison: #{iteration} (freeze #{info[:freeze_sha]&.[](0, 8) || "-"})"
348
+ terminal.say "Winner: #{info[:winner] || '(none)'}"
349
+ terminal.say ""
350
+ rows = info[:variants].map do |v|
351
+ [
352
+ v[:name],
353
+ v[:harness],
354
+ v[:model] || "(default)",
355
+ v[:effort] || "-",
356
+ v[:status] == "winner" ? "WINNER" : v[:status],
357
+ v[:integration_branch] || "-",
358
+ v[:base_sha]&.[](0, 8) || "-"
359
+ ]
360
+ end
361
+ terminal.say terminal.table(%w[Variant Harness Model Effort Status Integration Base], rows)
362
+
363
+ CLI.record_outcome(Outcome.new(exit_code: 0))
364
+ end
365
+ end
366
+ end
367
+ end
368
+ end
369
+ end
370
+ end
371
+ end
372
+
373
+ SpaceArchitect::CLI::Registry.register "init", SpaceArchitect::CLI::Architect::Init
374
+ SpaceArchitect::CLI::Registry.register "new", SpaceArchitect::CLI::Architect::New
375
+ SpaceArchitect::CLI::Registry.register "status", SpaceArchitect::CLI::Architect::Status
376
+ SpaceArchitect::CLI::Registry.register "freeze", SpaceArchitect::CLI::Architect::Freeze
377
+ SpaceArchitect::CLI::Registry.register "verify", SpaceArchitect::CLI::Architect::Verify
378
+ SpaceArchitect::CLI::Registry.register "dispatch", SpaceArchitect::CLI::Architect::Dispatch
379
+ SpaceArchitect::CLI::Registry.register "worktree" do |wt|
380
+ wt.register "add", SpaceArchitect::CLI::Architect::Worktree::Add
381
+ wt.register "remove", SpaceArchitect::CLI::Architect::Worktree::Remove
382
+ wt.register "list", SpaceArchitect::CLI::Architect::Worktree::List
383
+ end
384
+ SpaceArchitect::CLI::Registry.register "variant" do |v|
385
+ v.register "add", SpaceArchitect::CLI::Architect::Variant::Add
386
+ v.register "promote", SpaceArchitect::CLI::Architect::Variant::Promote
387
+ v.register "compare", SpaceArchitect::CLI::Architect::Variant::Compare
388
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpaceArchitect
4
+ module CLI
5
+ module Config
6
+ class Show < Dry::CLI::Command
7
+ include GlobalOptions
8
+ include Helpers
9
+
10
+ desc "Show current config"
11
+
12
+ def call(**opts)
13
+ setup_terminal(**opts.slice(:color, :colors))
14
+ handle_errors do
15
+ rows = SpaceArchitect::Config::EDITABLE_KEYS.map do |key|
16
+ value = project_config.data[key]
17
+ [key, value.nil? ? "" : value.to_s]
18
+ end
19
+ terminal.say terminal.table(%w[Key Value], rows)
20
+ CLI.record_outcome(Outcome.new(exit_code: 0))
21
+ end
22
+ end
23
+ end
24
+
25
+ class ConfigPath < Dry::CLI::Command
26
+ include GlobalOptions
27
+ include Helpers
28
+
29
+ desc "Print the config file path"
30
+
31
+ def call(**opts)
32
+ setup_terminal(**opts.slice(:color, :colors))
33
+ handle_errors do
34
+ terminal.say terminal.path(project_config.path)
35
+ CLI.record_outcome(Outcome.new(exit_code: 0))
36
+ end
37
+ end
38
+ end
39
+
40
+ class Set < Dry::CLI::Command
41
+ include GlobalOptions
42
+ include Helpers
43
+
44
+ desc "Set a config key"
45
+ argument :key, required: true, desc: "Config key"
46
+ argument :value, required: true, desc: "Config value"
47
+
48
+ def call(key:, value:, **opts)
49
+ setup_terminal(**opts.slice(:color, :colors))
50
+ handle_errors do
51
+ project_config.set(key, value)
52
+ stored = project_config.data[key]
53
+ terminal.success "Set #{key}=#{stored.nil? ? '' : stored.to_s}"
54
+ CLI.record_outcome(Outcome.new(exit_code: 0))
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpaceArchitect
4
+ module CLI
5
+ class Current < Dry::CLI::Command
6
+ include GlobalOptions
7
+ include Helpers
8
+
9
+ desc "Show the current space"
10
+
11
+ def call(**opts)
12
+ setup_terminal(**opts.slice(:color, :colors))
13
+ render(store.find) do |space|
14
+ terminal.say space.id.to_s
15
+ terminal.say terminal.path(space.path)
16
+ CLI.record_outcome(Outcome.new(exit_code: 0))
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/monads"
4
+
5
+ module SpaceArchitect
6
+ module CLI
7
+ module Helpers
8
+ include Dry::Monads[:result]
9
+ def project_config
10
+ @project_config ||= SpaceArchitect::Config.load
11
+ end
12
+
13
+ def state
14
+ @state ||= SpaceArchitect::State.load
15
+ end
16
+
17
+ def store
18
+ @store ||= SpaceArchitect::SpaceStore.new(config: project_config, state: state)
19
+ end
20
+
21
+ def terminal
22
+ @terminal
23
+ end
24
+
25
+ def setup_terminal(color: "auto", colors: nil)
26
+ @terminal = Terminal.new(
27
+ config: project_config,
28
+ stdout: out,
29
+ stderr: err,
30
+ color_mode: colors || color || "auto"
31
+ )
32
+ end
33
+
34
+ def display_date(space)
35
+ id_date = space.id.match(/\A(\d{4})(\d{2})(\d{2})/)
36
+ return "#{id_date[1]}-#{id_date[2]}-#{id_date[3]}" if id_date
37
+
38
+ space.data["created_at"].to_s[0, 10]
39
+ end
40
+
41
+ def handle_errors
42
+ yield
43
+ rescue SpaceArchitect::Error => e
44
+ if terminal
45
+ terminal.error(e.message)
46
+ else
47
+ err.puts e.message
48
+ end
49
+ CLI.record_outcome(Outcome.new(exit_code: 1, message: e.message))
50
+ end
51
+
52
+ def render(result)
53
+ case result
54
+ when Dry::Monads::Result::Success
55
+ yield result.value! if block_given?
56
+ when Dry::Monads::Result::Failure
57
+ error = result.failure
58
+ message = error.respond_to?(:message) ? error.message : error.to_s
59
+ terminal ? terminal.error(message) : err.puts(message)
60
+ CLI.record_outcome(Outcome.new(exit_code: 1, message: message))
61
+ end
62
+ end
63
+ end
64
+
65
+ class RepoProgress
66
+ def initialize(total)
67
+ @total = total
68
+ @statuses = {}
69
+ end
70
+
71
+ def start(addition)
72
+ source = addition[:src_source]
73
+ @statuses[addition.fetch(:reference).full_name] = source&.directory? ? :copying : :cloning
74
+ end
75
+
76
+ def trust(addition)
77
+ @statuses[addition.fetch(:reference).full_name] = :trusting
78
+ end
79
+
80
+ def finish(addition)
81
+ @statuses[addition.fetch(:reference).full_name] = :done
82
+ end
83
+
84
+ def fail(addition)
85
+ @statuses[addition.fetch(:reference).full_name] = :failed
86
+ end
87
+
88
+ def message
89
+ done = @statuses.count { |_repo, status| status == :done }
90
+ failed = @statuses.count { |_repo, status| status == :failed }
91
+ copying = @statuses.select { |_repo, status| status == :copying }.keys
92
+ cloning = @statuses.select { |_repo, status| status == :cloning }.keys
93
+ trusting = @statuses.select { |_repo, status| status == :trusting }.keys
94
+
95
+ if @total == 1
96
+ copying_repo = copying.first
97
+ cloning_repo = cloning.first
98
+ trusting_repo = trusting.first
99
+ return "Copying #{copying_repo}" if copying_repo
100
+ return "Cloning #{cloning_repo}" if cloning_repo
101
+ return "Trusting #{trusting_repo}" if trusting_repo
102
+ return "Fetch failed" if failed.positive?
103
+
104
+ "Preparing repos"
105
+ else
106
+ active = []
107
+ active << "copying #{copying.join(', ')}" unless copying.empty?
108
+ active << "cloning #{cloning.join(', ')}" unless cloning.empty?
109
+ active << "trusting #{trusting.join(', ')}" unless trusting.empty?
110
+ suffix = active.empty? ? nil : ": #{active.join('; ')}"
111
+ failed_text = failed.positive? ? ", #{failed} failed" : ""
112
+ "Fetching repos #{done}/#{@total}#{failed_text}#{suffix}"
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpaceArchitect
4
+ module CLI
5
+ class Init < Dry::CLI::Command
6
+ include GlobalOptions
7
+ include Helpers
8
+
9
+ desc "Create default XDG config and state files"
10
+ option :force, type: :boolean, default: false, desc: "Overwrite existing config and state files"
11
+
12
+ def call(force: false, **opts)
13
+ setup_terminal(**opts.slice(:color, :colors))
14
+ handle_errors do
15
+ if force
16
+ @project_config = SpaceArchitect::Config.new
17
+ @state = SpaceArchitect::State.new
18
+ project_config.save
19
+ state.save
20
+ else
21
+ project_config.ensure_exists!
22
+ state.ensure_exists!
23
+ end
24
+
25
+ FileUtils.mkdir_p(project_config.spaces_dir)
26
+ terminal.success "Config: #{terminal.path(project_config.path)}"
27
+ terminal.success "State: #{terminal.path(state.path)}"
28
+ terminal.success "Spaces: #{terminal.path(project_config.spaces_dir)}"
29
+ CLI.record_outcome(Outcome.new(exit_code: 0))
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpaceArchitect
4
+ module CLI
5
+ class List < Dry::CLI::Command
6
+ include GlobalOptions
7
+ include Helpers
8
+
9
+ desc "List spaces"
10
+
11
+ def call(**opts)
12
+ setup_terminal(**opts.slice(:color, :colors))
13
+ handle_errors do
14
+ spaces = store.list
15
+ if spaces.empty?
16
+ terminal.say "No spaces found in #{terminal.path(project_config.spaces_dir)}"
17
+ next
18
+ end
19
+
20
+ rows = spaces.map do |space|
21
+ [space.status, display_date(space), space.title, terminal.path(space.path)]
22
+ end
23
+ terminal.say terminal.table(%w[Status Date Title Path], rows)
24
+ CLI.record_outcome(Outcome.new(exit_code: 0))
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+