space-cadet 9.0.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 (56) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +279 -0
  5. data/exe/space +13 -0
  6. data/lib/space_core/atomic_write.rb +21 -0
  7. data/lib/space_core/cli/base_command.rb +19 -0
  8. data/lib/space_core/cli/build.rb +27 -0
  9. data/lib/space_core/cli/config.rb +49 -0
  10. data/lib/space_core/cli/current.rb +16 -0
  11. data/lib/space_core/cli/help.rb +195 -0
  12. data/lib/space_core/cli/helpers.rb +115 -0
  13. data/lib/space_core/cli/init.rb +29 -0
  14. data/lib/space_core/cli/list.rb +24 -0
  15. data/lib/space_core/cli/loop_status.rb +47 -0
  16. data/lib/space_core/cli/new.rb +38 -0
  17. data/lib/space_core/cli/pack.rb +29 -0
  18. data/lib/space_core/cli/path.rb +16 -0
  19. data/lib/space_core/cli/repeatable_options.rb +84 -0
  20. data/lib/space_core/cli/repo.rb +76 -0
  21. data/lib/space_core/cli/run.rb +44 -0
  22. data/lib/space_core/cli/shell.rb +125 -0
  23. data/lib/space_core/cli/show.rb +21 -0
  24. data/lib/space_core/cli/status.rb +74 -0
  25. data/lib/space_core/cli/use.rb +17 -0
  26. data/lib/space_core/cli.rb +177 -0
  27. data/lib/space_core/cloner.rb +77 -0
  28. data/lib/space_core/commands.rb +51 -0
  29. data/lib/space_core/config.rb +152 -0
  30. data/lib/space_core/errors.rb +14 -0
  31. data/lib/space_core/git_client.rb +49 -0
  32. data/lib/space_core/mise_client.rb +37 -0
  33. data/lib/space_core/oci_builder.rb +56 -0
  34. data/lib/space_core/oci_packer.rb +103 -0
  35. data/lib/space_core/oci_runner.rb +89 -0
  36. data/lib/space_core/paths.rb +58 -0
  37. data/lib/space_core/repo_reference.rb +19 -0
  38. data/lib/space_core/repo_resolver.rb +167 -0
  39. data/lib/space_core/scm/client.rb +87 -0
  40. data/lib/space_core/scm/git.rb +232 -0
  41. data/lib/space_core/scm/status.rb +24 -0
  42. data/lib/space_core/shell.rb +90 -0
  43. data/lib/space_core/shell_integration.rb +438 -0
  44. data/lib/space_core/slugger.rb +16 -0
  45. data/lib/space_core/space.rb +174 -0
  46. data/lib/space_core/space_store.rb +319 -0
  47. data/lib/space_core/state.rb +86 -0
  48. data/lib/space_core/templates/oci/dockerfile.erb +73 -0
  49. data/lib/space_core/templates/oci/dockerignore.erb +17 -0
  50. data/lib/space_core/templates/oci/entrypoint.sh.erb +16 -0
  51. data/lib/space_core/terminal.rb +161 -0
  52. data/lib/space_core/version.rb +7 -0
  53. data/lib/space_core/warnings.rb +13 -0
  54. data/lib/space_core/xdg.rb +33 -0
  55. data/lib/space_core.rb +26 -0
  56. metadata +199 -0
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "pathname"
5
+
6
+ module Space::Core
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-cadet", "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,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Space::Core
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 Space::Core
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,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async/process"
4
+ require "pathname"
5
+ require "tempfile"
6
+
7
+ module Space::Core
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,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "pathname"
5
+ require "dry/monads"
6
+
7
+ module Space::Core
8
+ class OciBuilder
9
+ include Dry::Monads[:result]
10
+
11
+ def initialize(space:, output_dir:)
12
+ @space = space
13
+ @output_dir = Pathname.new(output_dir)
14
+ end
15
+
16
+ def version
17
+ compute_version.value!
18
+ end
19
+
20
+ def image
21
+ "#{space.id}:#{version}"
22
+ end
23
+
24
+ def latest
25
+ "#{space.id}:latest"
26
+ end
27
+
28
+ def command
29
+ compute_version.fmap do |ver|
30
+ [
31
+ "container", "build",
32
+ "-f", @output_dir.join("Dockerfile").to_s,
33
+ "-t", "#{space.id}:#{ver}",
34
+ "-t", latest,
35
+ space.path.to_s
36
+ ]
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :space
43
+
44
+ def compute_version
45
+ sha_out, _, sha_status = Open3.capture3("git", "-C", space.path.to_s, "rev-parse", "--short=12", "HEAD")
46
+ unless sha_status.success?
47
+ return Failure("space is not a git repository with a commit; cannot compute a version tag")
48
+ end
49
+
50
+ sha = sha_out.strip
51
+ status_out, _, _ = Open3.capture3("git", "-C", space.path.to_s, "status", "--porcelain")
52
+ dirty = !status_out.strip.empty?
53
+ Success(dirty ? "#{sha}-dirty" : sha)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "fileutils"
5
+ require "pathname"
6
+ require "dry/monads"
7
+
8
+ module Space::Core
9
+ class OciPacker
10
+ include Dry::Monads[:result]
11
+
12
+ TEMPLATE_DIR = Pathname.new(__dir__).join("templates", "oci").freeze
13
+
14
+ def initialize(space:, output_dir:)
15
+ @space = space
16
+ @output_dir = Pathname.new(output_dir)
17
+ end
18
+
19
+ def generate
20
+ FileUtils.mkdir_p(@output_dir)
21
+ validated = validate_provision_scripts(space.provision_scripts)
22
+ return validated if validated.failure?
23
+ validated = validate_persist_paths(space.persist_paths)
24
+ return validated if validated.failure?
25
+ write("Dockerfile", render_template("dockerfile.erb"))
26
+ write("entrypoint.sh", entrypoint_content, mode: 0o755)
27
+ write("Dockerfile.dockerignore", render_template("dockerignore.erb"))
28
+ Success(@output_dir)
29
+ rescue StandardError => e
30
+ Failure(e)
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :space, :output_dir
36
+
37
+ def space_id
38
+ @space.id
39
+ end
40
+
41
+ def repos
42
+ @space.repos
43
+ end
44
+
45
+ def provision_scripts
46
+ @space.provision_scripts
47
+ end
48
+
49
+ def persist_paths
50
+ @space.persist_paths
51
+ end
52
+
53
+ def validate_provision_scripts(scripts)
54
+ scripts.each do |rel_path|
55
+ if Pathname.new(rel_path).absolute?
56
+ return Failure("provision script '#{rel_path}' must not be an absolute path")
57
+ end
58
+
59
+ resolved = space.path.join(rel_path).cleanpath
60
+ space_root = space.path.cleanpath
61
+ unless resolved.to_s.start_with?("#{space_root}/") || resolved == space_root
62
+ return Failure("provision script '#{rel_path}' escapes the space root")
63
+ end
64
+
65
+ return Failure("provision script '#{rel_path}' does not exist under the space root") unless resolved.exist?
66
+ end
67
+ Success(scripts)
68
+ end
69
+
70
+ def validate_persist_paths(paths)
71
+ paths.each do |path|
72
+ unless Pathname.new(path).absolute?
73
+ return Failure("persist path '#{path}' must be an absolute path")
74
+ end
75
+ end
76
+ Success(paths)
77
+ end
78
+
79
+ def entrypoint_content
80
+ @entrypoint_content ||= render_template("entrypoint.sh.erb")
81
+ end
82
+
83
+ def entrypoint_b64
84
+ [entrypoint_content].pack("m0")
85
+ end
86
+
87
+ def local_space_cadet?
88
+ space.path.join("repos/space-cadet").directory?
89
+ end
90
+
91
+ def render_template(name)
92
+ template_path = TEMPLATE_DIR.join(name)
93
+ ERB.new(template_path.read, trim_mode: "-").result(binding)
94
+ end
95
+
96
+ def write(filename, content, mode: 0o644)
97
+ path = @output_dir.join(filename)
98
+ File.write(path, content)
99
+ File.chmod(mode, path)
100
+ path
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "dry/monads"
5
+
6
+ module Space::Core
7
+ class OciRunner
8
+ include Dry::Monads[:result]
9
+
10
+ # Always-forwarded substrate auth (the in-image architect/claude reach for these).
11
+ # Payload-specific vars are declared per-space via `run.env:` or the --env flag.
12
+ AUTH_ENV = %w[ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_BASE_URL].freeze
13
+
14
+ def initialize(space:, env: ENV.to_h, interactive: true, env_vars: [])
15
+ @space = space
16
+ @env = env
17
+ @interactive = interactive
18
+ @env_vars = (space.run_env + Array(env_vars)).map(&:to_s)
19
+ end
20
+
21
+ def image
22
+ "#{space.id}:latest"
23
+ end
24
+
25
+ def mounts
26
+ space.persist_paths.map do |guest|
27
+ [space.path.join(".state" + guest), guest]
28
+ end
29
+ end
30
+
31
+ def host_dirs
32
+ mounts.map(&:first)
33
+ end
34
+
35
+ def command(extra = [])
36
+ validated = validate_persist_paths(space.persist_paths)
37
+ return validated if validated.failure?
38
+
39
+ argv = [
40
+ "container", "run", "--rm",
41
+ *(@interactive ? ["-i", "-t"] : []),
42
+ *env_flags,
43
+ *mount_flags,
44
+ image,
45
+ *extra
46
+ ]
47
+ Success(argv)
48
+ end
49
+
50
+ # Explicitly-requested vars (run.env: + --env) that are unset/empty in the host
51
+ # env, so the caller can warn — silence here would let a missing key fail opaquely
52
+ # inside the guest. AUTH_ENV is opportunistic and intentionally excluded.
53
+ def missing_env
54
+ @env_vars.uniq.reject { |var| present?(var) }
55
+ end
56
+
57
+ private
58
+
59
+ attr_reader :space, :env, :interactive
60
+
61
+ # Bare `-e VAR` passthrough (no =value on the command line, so the secret never
62
+ # lands in argv/ps): substrate auth first, then the space's declared payload vars.
63
+ def env_flags
64
+ (AUTH_ENV + @env_vars).uniq.each_with_object([]) do |var, flags|
65
+ flags.push("-e", var) if present?(var)
66
+ end
67
+ end
68
+
69
+ def present?(var)
70
+ val = env[var]
71
+ val && !val.empty?
72
+ end
73
+
74
+ def mount_flags
75
+ mounts.each_with_object([]) do |(host, guest), flags|
76
+ flags.push("-v", "#{host}:#{guest}")
77
+ end
78
+ end
79
+
80
+ def validate_persist_paths(paths)
81
+ paths.each do |path|
82
+ unless Pathname.new(path).absolute?
83
+ return Failure("persist path '#{path}' must be an absolute path")
84
+ end
85
+ end
86
+ Success(paths)
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Space::Core
6
+ module Paths
7
+ # paths:exempt-file - the shared module itself — this is the one home the guard defends
8
+ module_function
9
+
10
+ # Flags for matching a changed path against a lane's touch_set globs.
11
+ # PATHNAME keeps a single `*` from crossing `/`; EXTGLOB enables `{a,b}`;
12
+ # DOTMATCH lets a glob reach dotfile segments, so a `dir/**` touch set covers
13
+ # `dir/.github/workflows/ci.yml` — the standard deliverable for a lane preparing
14
+ # a directory to become a repo root.
15
+ TOUCH_FNM = File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH
16
+
17
+ # Every path beneath root, at every depth: files, directories, dotfiles, and
18
+ # dot-directory contents all included. `File::FNM_DOTMATCH` is what makes
19
+ # dotfiles visible to `Dir.glob`, but it also emits a bogus `root/.`
20
+ # self-entry — that trap is absorbed here so no callsite has to know about it.
21
+ # Returns Array<String> — Dir.glob's native return shape.
22
+ def content_tree(root)
23
+ Dir.glob(File.join(root.to_s, "**", "*"), File::FNM_DOTMATCH).reject { |p| File.basename(p) == "." }
24
+ end
25
+
26
+ # The direct children of a fixed-depth structured directory layout (one
27
+ # entry per iteration/skill/lane/space) where a leading dot never names a
28
+ # real layout member — only tooling junk (.git, .DS_Store). Excludes them.
29
+ # Returns Array<Pathname> — Pathname#children's native return shape.
30
+ def layout_children(dir)
31
+ Pathname.new(dir).children.reject { |c| c.basename.to_s.start_with?(".") }
32
+ end
33
+
34
+ # Does a single touch_set glob match path? A trailing `dir/**` is matched
35
+ # twice: bare (PATHNAME stops it at direct children) and as `dir/**/*`,
36
+ # whose whole-component `**/` does cross `/`.
37
+ def touch_match?(glob, path)
38
+ File.fnmatch(glob, path, TOUCH_FNM) ||
39
+ (glob.end_with?("/**") && File.fnmatch("#{glob}/*", path, TOUCH_FNM))
40
+ end
41
+
42
+ def contract(path, env: ENV)
43
+ value = path.to_s
44
+ home = XDG.home(env: env)
45
+ [home, realpath_or_nil(home)].compact.uniq.each do |h|
46
+ return "~" if value == h
47
+ return "~#{value.delete_prefix(h)}" if value.start_with?("#{h}/")
48
+ end
49
+ value
50
+ end
51
+
52
+ def realpath_or_nil(path)
53
+ File.realpath(path)
54
+ rescue SystemCallError
55
+ nil
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Space::Core
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