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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +284 -0
- data/exe/architect +13 -0
- data/exe/space +13 -0
- data/lib/space_architect/architect_mission.rb +436 -0
- data/lib/space_architect/atomic_write.rb +21 -0
- data/lib/space_architect/cli/architect.rb +388 -0
- data/lib/space_architect/cli/config.rb +61 -0
- data/lib/space_architect/cli/current.rb +22 -0
- data/lib/space_architect/cli/helpers.rb +117 -0
- data/lib/space_architect/cli/init.rb +35 -0
- data/lib/space_architect/cli/list.rb +30 -0
- data/lib/space_architect/cli/new.rb +43 -0
- data/lib/space_architect/cli/options.rb +12 -0
- data/lib/space_architect/cli/path.rb +22 -0
- data/lib/space_architect/cli/repo.rb +88 -0
- data/lib/space_architect/cli/shell.rb +137 -0
- data/lib/space_architect/cli/show.rb +27 -0
- data/lib/space_architect/cli/space.rb +35 -0
- data/lib/space_architect/cli/src.rb +32 -0
- data/lib/space_architect/cli/status.rb +39 -0
- data/lib/space_architect/cli/use.rb +23 -0
- data/lib/space_architect/cli.rb +102 -0
- data/lib/space_architect/config.rb +152 -0
- data/lib/space_architect/dispatcher.rb +21 -0
- data/lib/space_architect/errors.rb +14 -0
- data/lib/space_architect/git_client.rb +49 -0
- data/lib/space_architect/harness.rb +168 -0
- data/lib/space_architect/mise_client.rb +37 -0
- data/lib/space_architect/repo_reference.rb +19 -0
- data/lib/space_architect/repo_resolver.rb +167 -0
- data/lib/space_architect/shell_integration.rb +438 -0
- data/lib/space_architect/slugger.rb +16 -0
- data/lib/space_architect/space.rb +110 -0
- data/lib/space_architect/space_store.rb +319 -0
- data/lib/space_architect/state.rb +86 -0
- data/lib/space_architect/templates/architect.md.erb +48 -0
- data/lib/space_architect/templates/iteration.md.erb +66 -0
- data/lib/space_architect/terminal.rb +163 -0
- data/lib/space_architect/version.rb +5 -0
- data/lib/space_architect/warnings.rb +13 -0
- data/lib/space_architect/xdg.rb +33 -0
- data/lib/space_architect.rb +26 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/clone.rb +55 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/config.rb +66 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/daemon.rb +347 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/options.rb +21 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/org.rb +200 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/repo.rb +170 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/status.rb +76 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/sync.rb +149 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli.rb +137 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cloner.rb +75 -0
- data/vendor/repo-tender/lib/space_architect/pristine/config/contract.rb +54 -0
- data/vendor/repo-tender/lib/space_architect/pristine/config/duration.rb +79 -0
- data/vendor/repo-tender/lib/space_architect/pristine/config/model.rb +49 -0
- data/vendor/repo-tender/lib/space_architect/pristine/config/store.rb +156 -0
- data/vendor/repo-tender/lib/space_architect/pristine/forge/client.rb +31 -0
- data/vendor/repo-tender/lib/space_architect/pristine/forge/github.rb +98 -0
- data/vendor/repo-tender/lib/space_architect/pristine/launchd/agent.rb +195 -0
- data/vendor/repo-tender/lib/space_architect/pristine/launchd/plist.rb +129 -0
- data/vendor/repo-tender/lib/space_architect/pristine/log_rotator.rb +46 -0
- data/vendor/repo-tender/lib/space_architect/pristine/paths.rb +72 -0
- data/vendor/repo-tender/lib/space_architect/pristine/scm/client.rb +87 -0
- data/vendor/repo-tender/lib/space_architect/pristine/scm/git.rb +232 -0
- data/vendor/repo-tender/lib/space_architect/pristine/scm/status.rb +24 -0
- data/vendor/repo-tender/lib/space_architect/pristine/shell.rb +90 -0
- data/vendor/repo-tender/lib/space_architect/pristine/state/lock.rb +59 -0
- data/vendor/repo-tender/lib/space_architect/pristine/state/store.rb +140 -0
- data/vendor/repo-tender/lib/space_architect/pristine/sync/engine.rb +464 -0
- data/vendor/repo-tender/lib/space_architect/pristine/sync/repo_plan.rb +215 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/interactive_reporter.rb +280 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/json_reporter.rb +39 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/mode.rb +68 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/plain_reporter.rb +53 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/reporter.rb +48 -0
- data/vendor/repo-tender/lib/space_architect/pristine/version.rb +7 -0
- data/vendor/repo-tender/lib/space_architect/pristine.rb +37 -0
- 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
|