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,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "pathname"
5
+
6
+ module SpaceArchitect
7
+ class Config
8
+ DEFAULT_DATA = {
9
+ "version" => 1,
10
+ "base_dir" => "~/architect",
11
+ "default_provider" => "github.com",
12
+ "default_organization" => nil,
13
+ "git_clone_protocol" => "ssh"
14
+ }.freeze
15
+ EDITABLE_KEYS = %w[
16
+ base_dir
17
+ spaces_dir
18
+ src_dir
19
+ default_provider
20
+ default_organization
21
+ git_clone_protocol
22
+ ].freeze
23
+ VALID_GIT_CLONE_PROTOCOLS = %w[ssh https].freeze
24
+
25
+ attr_reader :path, :data, :env
26
+
27
+ def self.default_path(env: ENV)
28
+ XDG.config_home(env: env).join("space-architect", "config.yml")
29
+ end
30
+
31
+ def self.load(env: ENV, path: default_path(env: env))
32
+ new(env:, path:).load
33
+ end
34
+
35
+ def initialize(env: ENV, path: self.class.default_path(env: env), data: nil)
36
+ @path = Pathname.new(path)
37
+ @env = env
38
+ @data = data ? DEFAULT_DATA.merge(stringify_keys(data)) : DEFAULT_DATA.dup
39
+ end
40
+
41
+ def load
42
+ @data = if path.exist?
43
+ parsed = YAML.safe_load(path.read, aliases: false) || {}
44
+ unless parsed.is_a?(Hash)
45
+ raise Error, "Config file must contain a YAML mapping: #{path}"
46
+ end
47
+
48
+ DEFAULT_DATA.merge(stringify_keys(parsed))
49
+ else
50
+ DEFAULT_DATA.dup
51
+ end
52
+ self
53
+ end
54
+
55
+ def ensure_exists!
56
+ save unless path.exist?
57
+ self
58
+ end
59
+
60
+ def save
61
+ AtomicWrite.write(path, YAML.dump(data))
62
+ self
63
+ end
64
+
65
+ def base_dir
66
+ Pathname.new(XDG.expand_user(data.fetch("base_dir"), env: env))
67
+ end
68
+
69
+ def spaces_dir
70
+ value = normalized_value(data["spaces_dir"])
71
+ return base_dir.join("spaces") unless value
72
+
73
+ Pathname.new(XDG.expand_user(value, env: env))
74
+ end
75
+
76
+ def src_dir
77
+ return base_dir.join("src") unless data.key?("src_dir")
78
+
79
+ value = normalized_value(data["src_dir"])
80
+ return nil unless value
81
+
82
+ Pathname.new(XDG.expand_user(value, env: env))
83
+ end
84
+
85
+ def default_provider
86
+ normalize_provider(data["default_provider"])
87
+ end
88
+
89
+ def default_organization
90
+ normalized_value(data["default_organization"])
91
+ end
92
+
93
+ def git_clone_protocol
94
+ protocol = normalized_value(data["git_clone_protocol"]) || "ssh"
95
+ unless VALID_GIT_CLONE_PROTOCOLS.include?(protocol)
96
+ raise Error, "Invalid git_clone_protocol '#{protocol}'. Expected one of: #{VALID_GIT_CLONE_PROTOCOLS.join(', ')}"
97
+ end
98
+
99
+ protocol
100
+ end
101
+
102
+ def set(key, value)
103
+ normalized_key = key.to_s
104
+ unless EDITABLE_KEYS.include?(normalized_key)
105
+ raise InvalidConfigKeyError, "Unknown config key '#{key}'. Expected one of: #{EDITABLE_KEYS.join(', ')}"
106
+ end
107
+
108
+ data[normalized_key] = normalize_config_value(normalized_key, value)
109
+ save
110
+ end
111
+
112
+ private
113
+
114
+ def normalize_config_value(key, value)
115
+ case key
116
+ when "default_provider"
117
+ normalize_provider(value)
118
+ when "default_organization"
119
+ normalized_value(value)
120
+ when "git_clone_protocol"
121
+ protocol = normalized_value(value)
122
+ unless VALID_GIT_CLONE_PROTOCOLS.include?(protocol)
123
+ raise Error, "Invalid git_clone_protocol '#{value}'. Expected one of: #{VALID_GIT_CLONE_PROTOCOLS.join(', ')}"
124
+ end
125
+
126
+ protocol
127
+ else
128
+ value
129
+ end
130
+ end
131
+
132
+ def normalize_provider(value)
133
+ normalized = normalized_value(value)
134
+ return nil unless normalized
135
+
136
+ normalized
137
+ .delete_prefix("https://")
138
+ .delete_prefix("http://")
139
+ .delete_prefix("ssh://")
140
+ .delete_suffix("/")
141
+ end
142
+
143
+ def normalized_value(value)
144
+ normalized = value.to_s.strip
145
+ normalized.empty? ? nil : normalized
146
+ end
147
+
148
+ def stringify_keys(hash)
149
+ hash.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "harness"
4
+
5
+ module SpaceArchitect
6
+ # Thin backward-compat wrapper around Harness::ClaudeCodeHarness.
7
+ # Existing callers that construct Dispatcher.new(...).run(...) continue to work byte-for-byte.
8
+ class Dispatcher
9
+ # Keep constants here so any code referencing Dispatcher::ALLOWED_TOOLS still works.
10
+ ALLOWED_TOOLS = Harness::ClaudeCodeHarness::ALLOWED_TOOLS
11
+ DISALLOWED_TOOLS = Harness::ClaudeCodeHarness::DISALLOWED_TOOLS
12
+
13
+ def initialize(model: "claude-sonnet-4-6", max_turns: 200, claude_bin: nil)
14
+ @harness = Harness::ClaudeCodeHarness.new(model: model, max_turns: max_turns, bin: claude_bin)
15
+ end
16
+
17
+ def run(prompt_path:, run_log_path:, chdir:)
18
+ @harness.run(prompt_path: prompt_path, run_log_path: run_log_path, chdir: chdir)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpaceArchitect
4
+ class Error < StandardError; end
5
+ class NotFoundError < Error; end
6
+ class AmbiguousSpaceError < Error; end
7
+ class InvalidStatusError < Error; end
8
+ class CurrentSpaceMissingError < Error; end
9
+ class InvalidConfigKeyError < Error; end
10
+ class RepoResolutionError < Error; end
11
+ class RepoExistsError < Error; end
12
+ class GitError < Error; end
13
+ class MiseError < Error; end
14
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "async/process"
5
+ require "pathname"
6
+ require "tempfile"
7
+
8
+ module SpaceArchitect
9
+ class GitClient
10
+ def init(path)
11
+ path = Pathname.new(path)
12
+ stdout, stderr, status = capture("git", "-C", path.to_s, "init")
13
+ return true if status.success?
14
+
15
+ output = [stdout, stderr].reject(&:empty?).join("\n").strip
16
+ message = "git init failed for #{path}"
17
+ message = "#{message}: #{output}" unless output.empty?
18
+ raise GitError, message
19
+ end
20
+
21
+ # Best-effort: a missing git identity (user.name/user.email) must not abort
22
+ # space creation. Returns false on failure, leaving the repo initialized but
23
+ # uncommitted.
24
+ def commit_all(path, message)
25
+ path = Pathname.new(path)
26
+ _, _, add_status = capture("git", "-C", path.to_s, "add", "-A")
27
+ return false unless add_status.success?
28
+
29
+ _, _, commit_status = capture("git", "-C", path.to_s, "commit", "-m", message)
30
+ commit_status.success?
31
+ end
32
+
33
+ private
34
+
35
+ def capture(*command)
36
+ stdout = Tempfile.new("project-spaces-git-stdout")
37
+ stderr = Tempfile.new("project-spaces-git-stderr")
38
+ status = Async::Process.spawn(*command, out: stdout, err: stderr)
39
+
40
+ stdout.rewind
41
+ stderr.rewind
42
+ [stdout.read, stderr.read, status]
43
+ ensure
44
+ stdout&.close!
45
+ stderr&.close!
46
+ end
47
+
48
+ end
49
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async/process"
4
+ require "json"
5
+ require "pathname"
6
+
7
+ module SpaceArchitect
8
+ module Harness
9
+ CLAUDE_DEFAULT_MODEL = "claude-sonnet-4-6"
10
+
11
+ # Factory keyed by harness name.
12
+ # For opencode: config_dir is required (build/<id>-<lane> dir outside the worktree).
13
+ def self.for(name, model:, max_turns:, bin: nil, config_dir: nil, effort: nil)
14
+ case name.to_s
15
+ when "claude-code"
16
+ if effort
17
+ raise Error,
18
+ "effort is opencode-only (sets opencode reasoningEffort) — " \
19
+ "claude-code effort is set via the prompt"
20
+ end
21
+ ClaudeCodeHarness.new(model: model, max_turns: max_turns, bin: bin)
22
+ when "opencode"
23
+ if model == CLAUDE_DEFAULT_MODEL
24
+ raise Error,
25
+ "Pass --model when using --harness opencode (the claude-sonnet-4-6 default " \
26
+ "is a Claude model ID and will not work with opencode — " \
27
+ "try e.g. fireworks-ai/accounts/fireworks/models/glm-5p2)"
28
+ end
29
+ raise Error, "config_dir is required for opencode harness" unless config_dir
30
+ OpenCodeHarness.new(model: model, max_turns: max_turns, bin: bin, config_dir: config_dir, effort: effort)
31
+ else
32
+ raise Error, "Unknown harness '#{name}' — valid values: claude-code, opencode"
33
+ end
34
+ end
35
+
36
+ class ClaudeCodeHarness
37
+ ALLOWED_TOOLS = "Read,Edit,Write,Grep,Glob,Bash,WebSearch,WebFetch"
38
+ DISALLOWED_TOOLS = [
39
+ "Bash(git commit:*)", "Bash(git push:*)", "Bash(git reset:*)",
40
+ "Bash(git merge:*)", "Bash(git rebase:*)", "Bash(git checkout:*)",
41
+ "Bash(git branch:*)"
42
+ ].join(",")
43
+
44
+ def initialize(model:, max_turns:, bin: nil)
45
+ @model = model
46
+ @max_turns = max_turns
47
+ @bin = bin || ENV.fetch("ARCHITECT_CLAUDE_BIN", "claude")
48
+ end
49
+
50
+ def run(prompt_path:, run_log_path:, chdir:)
51
+ prompt_path = Pathname.new(prompt_path)
52
+ run_log_path = Pathname.new(run_log_path)
53
+
54
+ File.open(prompt_path, "r") do |prompt_io|
55
+ File.open(run_log_path, "w") do |log|
56
+ status = Sync do
57
+ Async::Process.spawn(*argv, chdir: chdir.to_s, in: prompt_io, out: log, err: log)
58
+ end
59
+ status.exitstatus
60
+ end
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def argv
67
+ [
68
+ @bin, "-p",
69
+ "--model", @model,
70
+ "--permission-mode", "acceptEdits",
71
+ "--allowedTools", ALLOWED_TOOLS,
72
+ "--disallowedTools", DISALLOWED_TOOLS,
73
+ "--output-format", "stream-json",
74
+ "--verbose",
75
+ "--max-turns", @max_turns.to_s
76
+ ]
77
+ end
78
+ end
79
+
80
+ class OpenCodeHarness
81
+ def initialize(model:, max_turns:, bin: nil, config_dir:, effort: nil)
82
+ @model = model
83
+ @max_turns = max_turns
84
+ @bin = bin || ENV.fetch("ARCHITECT_OPENCODE_BIN", "opencode")
85
+ @config_dir = Pathname.new(config_dir)
86
+ @effort = effort
87
+ end
88
+
89
+ # Returns the agent config hash (deterministic, unit-testable).
90
+ def builder_config
91
+ cfg = {
92
+ "agent" => {
93
+ "builder" => {
94
+ "steps" => @max_turns,
95
+ "permission" => {
96
+ "bash" => {
97
+ "git commit *" => "deny",
98
+ "git push *" => "deny",
99
+ "git reset *" => "deny",
100
+ "git rebase *" => "deny",
101
+ "git checkout *" => "deny",
102
+ "*" => "allow"
103
+ }
104
+ }
105
+ }
106
+ }
107
+ }
108
+ cfg.merge!(reasoning_provider_config) if @effort
109
+ cfg
110
+ end
111
+
112
+ def run(prompt_path:, run_log_path:, chdir:)
113
+ prompt_path = Pathname.new(prompt_path)
114
+ run_log_path = Pathname.new(run_log_path)
115
+ config_path = write_config
116
+
117
+ env = {
118
+ "OPENCODE_CONFIG" => config_path.to_s,
119
+ "OPENCODE_DISABLE_PROJECT_CONFIG" => "1"
120
+ }
121
+
122
+ File.open(prompt_path, "r") do |prompt_io|
123
+ File.open(run_log_path, "w") do |log|
124
+ status = Sync do
125
+ Async::Process.spawn(env, *argv(chdir),
126
+ chdir: chdir.to_s, in: prompt_io, out: log, err: log)
127
+ end
128
+ status.exitstatus
129
+ end
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ def reasoning_provider_config
136
+ provider, model_id = @model.split("/", 2)
137
+ {
138
+ "provider" => {
139
+ provider => {
140
+ "models" => {
141
+ model_id => { "options" => { "reasoningEffort" => @effort } }
142
+ }
143
+ }
144
+ }
145
+ }
146
+ end
147
+
148
+ def write_config
149
+ path = @config_dir.join("opencode.json")
150
+ path.write(JSON.generate(builder_config))
151
+ path
152
+ end
153
+
154
+ # --dir sets the working directory for opencode's tooling layer.
155
+ def argv(chdir)
156
+ args = [
157
+ @bin, "run",
158
+ "--format", "json",
159
+ "--model", @model,
160
+ "--dangerously-skip-permissions",
161
+ "--agent", "builder",
162
+ "--dir", chdir.to_s
163
+ ]
164
+ args
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async/process"
4
+ require "pathname"
5
+ require "tempfile"
6
+
7
+ module SpaceArchitect
8
+ class MiseClient
9
+ def trust(path)
10
+ path = Pathname.new(path)
11
+ stdout, stderr, status = capture("mise", "trust", "--yes", "--quiet", "--cd", path.to_s)
12
+ return true if status.success?
13
+
14
+ output = [stdout, stderr].reject(&:empty?).join("\n").strip
15
+ message = "mise trust failed for #{path}"
16
+ message = "#{message}: #{output}" unless output.empty?
17
+ raise MiseError, message
18
+ rescue Errno::ENOENT
19
+ raise MiseError, "mise executable not found; install mise or make sure it is on PATH"
20
+ end
21
+
22
+ private
23
+
24
+ def capture(*command)
25
+ stdout = Tempfile.new("project-spaces-mise-stdout")
26
+ stderr = Tempfile.new("project-spaces-mise-stderr")
27
+ status = Async::Process.spawn(*command, out: stdout, err: stderr)
28
+
29
+ stdout.rewind
30
+ stderr.rewind
31
+ [stdout.read, stderr.read, status]
32
+ ensure
33
+ stdout&.close!
34
+ stderr&.close!
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module SpaceArchitect
6
+ RepoReference = Data.define(:provider, :owner, :name, :clone_url, :source) do
7
+ def full_name
8
+ "#{provider}/#{owner}/#{name}"
9
+ end
10
+
11
+ def directory_name
12
+ name
13
+ end
14
+
15
+ def src_path(root)
16
+ Pathname.new(root).join(provider, owner, name)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module SpaceArchitect
6
+ class RepoResolver
7
+ SCP_LIKE_PATTERN = /\A(?:[^@\/]+@)?(?<provider>[^:\/]+):(?<path>.+)\z/
8
+ URL_PATTERN = %r{\A[A-Za-z][A-Za-z0-9+\-.]*://}
9
+
10
+ attr_reader :config
11
+
12
+ def initialize(config)
13
+ @config = config
14
+ end
15
+
16
+ def resolve(spec)
17
+ value = spec.to_s.strip
18
+ raise RepoResolutionError, "Repo cannot be blank" if value.empty?
19
+
20
+ if url_like?(value)
21
+ resolve_url(value)
22
+ elsif (match = value.match(SCP_LIKE_PATTERN))
23
+ reference_from_parts(
24
+ provider: match[:provider],
25
+ path_parts: split_repo_path(match[:path]),
26
+ clone_url: value,
27
+ source: value
28
+ )
29
+ else
30
+ resolve_shorthand(value)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def resolve_url(value)
37
+ uri = URI.parse(value)
38
+ provider = uri.host
39
+ path = uri.path.to_s.delete_prefix("/")
40
+ raise RepoResolutionError, "Could not determine provider from '#{value}'" if provider.to_s.empty?
41
+
42
+ reference_from_parts(
43
+ provider: provider,
44
+ path_parts: split_repo_path(path),
45
+ clone_url: value,
46
+ source: value
47
+ )
48
+ rescue URI::InvalidURIError
49
+ raise RepoResolutionError, "Could not parse repo URL '#{value}'"
50
+ end
51
+
52
+ def resolve_shorthand(value)
53
+ parts = split_repo_path(value)
54
+
55
+ if parts.length == 1
56
+ resolve_default_organization_repo(parts.first, value)
57
+ elsif provider_like?(parts.first) && parts.length >= 3
58
+ reference_from_parts(
59
+ provider: parts.first,
60
+ path_parts: parts[1..],
61
+ clone_url: nil,
62
+ source: value
63
+ )
64
+ else
65
+ resolve_default_provider_repo(parts, value)
66
+ end
67
+ end
68
+
69
+ def resolve_default_organization_repo(name, source)
70
+ provider = require_default_provider(source)
71
+ owner = config.default_organization
72
+ unless owner
73
+ raise RepoResolutionError,
74
+ "Repo '#{source}' needs an organization. Set one with: space config set default_organization ORG"
75
+ end
76
+
77
+ reference(provider:, owner:, name:, clone_url: nil, source:)
78
+ end
79
+
80
+ def resolve_default_provider_repo(parts, source)
81
+ provider = require_default_provider(source)
82
+ reference_from_parts(provider:, path_parts: parts, clone_url: nil, source:)
83
+ end
84
+
85
+ def reference_from_parts(provider:, path_parts:, clone_url:, source:)
86
+ if path_parts.length < 2
87
+ raise RepoResolutionError, "Repo '#{source}' must include an organization and repo name"
88
+ end
89
+
90
+ name = path_parts.last
91
+ owner = path_parts[0...-1].join("/")
92
+ reference(provider:, owner:, name:, clone_url:, source:)
93
+ end
94
+
95
+ def reference(provider:, owner:, name:, clone_url:, source:)
96
+ normalized_provider = normalize_provider(provider)
97
+ normalized_owner = normalize_path_part(owner)
98
+ normalized_name = normalize_repo_name(name)
99
+
100
+ RepoReference.new(
101
+ provider: normalized_provider,
102
+ owner: normalized_owner,
103
+ name: normalized_name,
104
+ clone_url: clone_url || clone_url_for(normalized_provider, normalized_owner, normalized_name),
105
+ source: source
106
+ )
107
+ end
108
+
109
+ def clone_url_for(provider, owner, name)
110
+ case config.git_clone_protocol
111
+ when "ssh"
112
+ "git@#{provider}:#{owner}/#{name}.git"
113
+ when "https"
114
+ "https://#{provider}/#{owner}/#{name}.git"
115
+ end
116
+ end
117
+
118
+ def split_repo_path(value)
119
+ normalized = value.to_s.strip.delete_prefix("/").delete_suffix("/")
120
+ normalized = normalized.delete_suffix(".git")
121
+ parts = normalized.split("/").reject(&:empty?)
122
+ raise RepoResolutionError, "Repo '#{value}' must include a repo name" if parts.empty?
123
+
124
+ parts
125
+ end
126
+
127
+ def require_default_provider(source)
128
+ config.default_provider || raise(
129
+ RepoResolutionError,
130
+ "Repo '#{source}' needs a provider. Set one with: space config set default_provider PROVIDER"
131
+ )
132
+ end
133
+
134
+ def normalize_provider(value)
135
+ normalized = value.to_s.strip
136
+ normalized = normalized.delete_prefix("https://")
137
+ normalized = normalized.delete_prefix("http://")
138
+ normalized = normalized.delete_prefix("ssh://")
139
+ normalized = normalized.delete_suffix("/")
140
+ raise RepoResolutionError, "Provider cannot be blank" if normalized.empty?
141
+
142
+ normalized
143
+ end
144
+
145
+ def normalize_path_part(value)
146
+ normalized = value.to_s.strip.delete_prefix("/").delete_suffix("/")
147
+ raise RepoResolutionError, "Organization cannot be blank" if normalized.empty?
148
+
149
+ normalized
150
+ end
151
+
152
+ def normalize_repo_name(value)
153
+ normalized = value.to_s.strip.delete_suffix(".git")
154
+ raise RepoResolutionError, "Repo name cannot be blank" if normalized.empty?
155
+
156
+ normalized
157
+ end
158
+
159
+ def url_like?(value)
160
+ value.match?(URL_PATTERN)
161
+ end
162
+
163
+ def provider_like?(value)
164
+ value.include?(".") || value.include?(":") || value == "localhost"
165
+ end
166
+ end
167
+ end