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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE.txt +21 -0
- data/README.md +279 -0
- data/exe/space +13 -0
- data/lib/space_core/atomic_write.rb +21 -0
- data/lib/space_core/cli/base_command.rb +19 -0
- data/lib/space_core/cli/build.rb +27 -0
- data/lib/space_core/cli/config.rb +49 -0
- data/lib/space_core/cli/current.rb +16 -0
- data/lib/space_core/cli/help.rb +195 -0
- data/lib/space_core/cli/helpers.rb +115 -0
- data/lib/space_core/cli/init.rb +29 -0
- data/lib/space_core/cli/list.rb +24 -0
- data/lib/space_core/cli/loop_status.rb +47 -0
- data/lib/space_core/cli/new.rb +38 -0
- data/lib/space_core/cli/pack.rb +29 -0
- data/lib/space_core/cli/path.rb +16 -0
- data/lib/space_core/cli/repeatable_options.rb +84 -0
- data/lib/space_core/cli/repo.rb +76 -0
- data/lib/space_core/cli/run.rb +44 -0
- data/lib/space_core/cli/shell.rb +125 -0
- data/lib/space_core/cli/show.rb +21 -0
- data/lib/space_core/cli/status.rb +74 -0
- data/lib/space_core/cli/use.rb +17 -0
- data/lib/space_core/cli.rb +177 -0
- data/lib/space_core/cloner.rb +77 -0
- data/lib/space_core/commands.rb +51 -0
- data/lib/space_core/config.rb +152 -0
- data/lib/space_core/errors.rb +14 -0
- data/lib/space_core/git_client.rb +49 -0
- data/lib/space_core/mise_client.rb +37 -0
- data/lib/space_core/oci_builder.rb +56 -0
- data/lib/space_core/oci_packer.rb +103 -0
- data/lib/space_core/oci_runner.rb +89 -0
- data/lib/space_core/paths.rb +58 -0
- data/lib/space_core/repo_reference.rb +19 -0
- data/lib/space_core/repo_resolver.rb +167 -0
- data/lib/space_core/scm/client.rb +87 -0
- data/lib/space_core/scm/git.rb +232 -0
- data/lib/space_core/scm/status.rb +24 -0
- data/lib/space_core/shell.rb +90 -0
- data/lib/space_core/shell_integration.rb +438 -0
- data/lib/space_core/slugger.rb +16 -0
- data/lib/space_core/space.rb +174 -0
- data/lib/space_core/space_store.rb +319 -0
- data/lib/space_core/state.rb +86 -0
- data/lib/space_core/templates/oci/dockerfile.erb +73 -0
- data/lib/space_core/templates/oci/dockerignore.erb +17 -0
- data/lib/space_core/templates/oci/entrypoint.sh.erb +16 -0
- data/lib/space_core/terminal.rb +161 -0
- data/lib/space_core/version.rb +7 -0
- data/lib/space_core/warnings.rb +13 -0
- data/lib/space_core/xdg.rb +33 -0
- data/lib/space_core.rb +26 -0
- metadata +199 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "async"
|
|
5
|
+
require "async/semaphore"
|
|
6
|
+
require "pathname"
|
|
7
|
+
require "time"
|
|
8
|
+
require "dry/monads"
|
|
9
|
+
require "space_core/scm/git"
|
|
10
|
+
require "space_core/cloner"
|
|
11
|
+
|
|
12
|
+
module Space::Core
|
|
13
|
+
class SpaceStore
|
|
14
|
+
include Dry::Monads[:result, :maybe]
|
|
15
|
+
|
|
16
|
+
MAX_CONCURRENT_CLONES = 5
|
|
17
|
+
|
|
18
|
+
attr_reader :config, :state, :now
|
|
19
|
+
|
|
20
|
+
def initialize(config:, state:, now: -> { Time.now })
|
|
21
|
+
@config = config
|
|
22
|
+
@state = state
|
|
23
|
+
@now = now
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def spaces_dir
|
|
27
|
+
config.spaces_dir
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def create(title, git: true, git_client: GitClient.new)
|
|
31
|
+
FileUtils.mkdir_p(spaces_dir)
|
|
32
|
+
timestamp = now.call
|
|
33
|
+
id = unique_id("#{timestamp.strftime('%Y%m%d')}-#{Slugger.slug(title)}")
|
|
34
|
+
path = spaces_dir.join(id)
|
|
35
|
+
|
|
36
|
+
FileUtils.mkdir_p(path.join("repos"))
|
|
37
|
+
FileUtils.mkdir_p(path.join("notes"))
|
|
38
|
+
FileUtils.mkdir_p(path.join("architecture"))
|
|
39
|
+
FileUtils.mkdir_p(path.join("tmp"))
|
|
40
|
+
FileUtils.mkdir_p(path.join("build"))
|
|
41
|
+
File.write(path.join("build", ".keep"), "")
|
|
42
|
+
|
|
43
|
+
space = Space.new(path, metadata_for(id:, title:, timestamp:))
|
|
44
|
+
space.save
|
|
45
|
+
write_readme(path:, title:, id:, timestamp:)
|
|
46
|
+
init_git(path:, id:, git_client:) if git
|
|
47
|
+
state.touch_recent(id)
|
|
48
|
+
Success(space)
|
|
49
|
+
rescue Error => e
|
|
50
|
+
Failure(e)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def list
|
|
54
|
+
return [] unless spaces_dir.directory?
|
|
55
|
+
|
|
56
|
+
Paths.layout_children(spaces_dir).select(&:directory?).filter_map do |child|
|
|
57
|
+
Space.load(child)
|
|
58
|
+
rescue NotFoundError, Error
|
|
59
|
+
nil
|
|
60
|
+
end.sort_by(&:id)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def find(identifier = nil, from: Dir.pwd)
|
|
64
|
+
value = identifier.to_s.strip
|
|
65
|
+
return current(from:) if value.empty?
|
|
66
|
+
|
|
67
|
+
if looks_like_path?(value)
|
|
68
|
+
begin
|
|
69
|
+
return Success(Space.load(File.expand_path(value)))
|
|
70
|
+
rescue Error => e
|
|
71
|
+
return Failure(e)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
matches = matching_spaces(value)
|
|
76
|
+
return Success(matches.first) if matches.length == 1
|
|
77
|
+
|
|
78
|
+
if matches.empty?
|
|
79
|
+
return Failure(NotFoundError.new("Could not find space matching '#{value}' in #{spaces_dir}"))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
Failure(AmbiguousSpaceError.new("Space '#{value}' is ambiguous: #{matches.map(&:id).join(', ')}"))
|
|
83
|
+
rescue Error => e
|
|
84
|
+
Failure(e)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def current(from: Dir.pwd)
|
|
88
|
+
current_from_pwd(from:).to_result(CurrentSpaceMissingError.new("No current space found from #{from}. Run this inside a space or pass a space id."))
|
|
89
|
+
rescue Error => e
|
|
90
|
+
Failure(e)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def current_from_pwd(from: Dir.pwd)
|
|
94
|
+
path = Pathname.new(File.expand_path(from.to_s))
|
|
95
|
+
path = path.dirname if path.file?
|
|
96
|
+
|
|
97
|
+
loop do
|
|
98
|
+
return Some(Space.load(path)) if path.join(Space::METADATA_FILE).exist?
|
|
99
|
+
break if path.root?
|
|
100
|
+
|
|
101
|
+
path = path.parent
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
None()
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def path_for(identifier = nil)
|
|
108
|
+
find(identifier).fmap(&:path)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def use(identifier)
|
|
112
|
+
find(identifier).fmap { |space| state.touch_recent(space.id); space }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def add_repo(spec, from: Dir.pwd, scm: SCM::Git.new, cloner: nil, mise_client: MiseClient.new)
|
|
116
|
+
add_repos([spec], from:, scm:, cloner:, mise_client:).fmap(&:first)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def add_repos(specs, from: Dir.pwd, scm: SCM::Git.new, cloner: nil, mise_client: MiseClient.new, reporter: nil)
|
|
120
|
+
current(from:).bind { |space| add_repos_to(space, specs, scm:, cloner:, mise_client:, reporter:) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def add_repos_to(space, specs, scm: SCM::Git.new, cloner: nil, mise_client: MiseClient.new, reporter: nil)
|
|
124
|
+
additions = prepare_repo_additions(space, specs)
|
|
125
|
+
first_error = nil
|
|
126
|
+
|
|
127
|
+
Async do |task|
|
|
128
|
+
semaphore = Async::Semaphore.new(MAX_CONCURRENT_CLONES, parent: task)
|
|
129
|
+
|
|
130
|
+
clone_tasks = additions.map do |addition|
|
|
131
|
+
semaphore.async(finished: false) do
|
|
132
|
+
clone_addition(addition, scm:, cloner:, mise_client:, reporter:)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Collect results without raising inside the reactor so the outer task
|
|
137
|
+
# succeeds and async does not log "Task may have ended" for our errors.
|
|
138
|
+
clone_tasks.each do |ct|
|
|
139
|
+
ct.wait
|
|
140
|
+
rescue StandardError => e
|
|
141
|
+
first_error ||= e
|
|
142
|
+
end
|
|
143
|
+
end.wait
|
|
144
|
+
|
|
145
|
+
return Failure(first_error) if first_error
|
|
146
|
+
|
|
147
|
+
Success(additions.map do |addition|
|
|
148
|
+
repo_data = space.add_repo(addition.fetch(:reference), relative_path: addition.fetch(:relative_path), now: now.call)
|
|
149
|
+
{ space: space, repo: repo_data, reference: addition.fetch(:reference), path: addition.fetch(:path) }
|
|
150
|
+
end)
|
|
151
|
+
rescue Error => e
|
|
152
|
+
Failure(e)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def repos(from: Dir.pwd)
|
|
156
|
+
current(from:).fmap(&:repos)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def clone_addition(addition, scm:, cloner:, mise_client:, reporter: nil)
|
|
162
|
+
reporter&.start(addition)
|
|
163
|
+
fetch_addition(addition, scm:, cloner:)
|
|
164
|
+
reporter&.trust(addition)
|
|
165
|
+
mise_client.trust(addition.fetch(:path))
|
|
166
|
+
reporter&.finish(addition)
|
|
167
|
+
addition
|
|
168
|
+
rescue StandardError
|
|
169
|
+
reporter&.fail(addition)
|
|
170
|
+
raise
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Prefer a fast local src copy; fall back to a network clone only when no
|
|
174
|
+
# src copy is available.
|
|
175
|
+
def fetch_addition(addition, scm:, cloner:)
|
|
176
|
+
reference = addition.fetch(:reference)
|
|
177
|
+
destination = addition.fetch(:path)
|
|
178
|
+
source = addition.fetch(:src_source)
|
|
179
|
+
|
|
180
|
+
if source&.directory?
|
|
181
|
+
actual_cloner = cloner || Cloner.new(base_dir: config.src_dir)
|
|
182
|
+
result = actual_cloner.call(name: reference.full_name, into: destination.dirname.to_s)
|
|
183
|
+
raise GitError, "clone failed (copy): #{result.failure}" if result.failure?
|
|
184
|
+
else
|
|
185
|
+
result = scm.clone(reference.clone_url, destination.to_s)
|
|
186
|
+
raise GitError, "clone failed: #{result.failure[:stderr]}" if result.failure?
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def prepare_repo_additions(space, specs)
|
|
191
|
+
src_dir = config.src_dir
|
|
192
|
+
additions = specs.map do |spec|
|
|
193
|
+
reference = RepoResolver.new(config).resolve(spec)
|
|
194
|
+
relative_path = Pathname.new("repos").join(reference.directory_name)
|
|
195
|
+
destination = space.path.join(relative_path)
|
|
196
|
+
|
|
197
|
+
ensure_repo_can_be_added!(space, reference, relative_path, destination)
|
|
198
|
+
|
|
199
|
+
{
|
|
200
|
+
reference: reference,
|
|
201
|
+
relative_path: relative_path,
|
|
202
|
+
path: destination,
|
|
203
|
+
src_source: src_dir && reference.src_path(src_dir)
|
|
204
|
+
}
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
duplicate_paths = additions
|
|
208
|
+
.map { |addition| addition.fetch(:path).to_s }
|
|
209
|
+
.tally
|
|
210
|
+
.select { |_path, count| count > 1 }
|
|
211
|
+
.keys
|
|
212
|
+
unless duplicate_paths.empty?
|
|
213
|
+
raise RepoExistsError, "Multiple repos resolve to the same destination: #{duplicate_paths.join(', ')}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
additions
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def ensure_repo_can_be_added!(space, reference, relative_path, destination)
|
|
220
|
+
raise RepoExistsError, "Repo destination already exists: #{destination}" if destination.exist?
|
|
221
|
+
|
|
222
|
+
existing = space.repos.find do |repo|
|
|
223
|
+
repo["full_name"] == reference.full_name ||
|
|
224
|
+
repo["path"] == relative_path.to_s ||
|
|
225
|
+
repo["name"] == reference.name
|
|
226
|
+
end
|
|
227
|
+
return unless existing
|
|
228
|
+
|
|
229
|
+
raise RepoExistsError, "Repo '#{reference.full_name}' already exists in #{space.id}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def metadata_for(id:, title:, timestamp:)
|
|
233
|
+
iso_timestamp = timestamp.iso8601
|
|
234
|
+
{
|
|
235
|
+
"version" => 2,
|
|
236
|
+
"id" => id,
|
|
237
|
+
"title" => title,
|
|
238
|
+
"status" => "active",
|
|
239
|
+
"created_at" => iso_timestamp,
|
|
240
|
+
"updated_at" => iso_timestamp,
|
|
241
|
+
"repos" => [],
|
|
242
|
+
"notes" => [],
|
|
243
|
+
"tickets" => [],
|
|
244
|
+
"tags" => []
|
|
245
|
+
}
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def write_readme(path:, title:, id:, timestamp:)
|
|
249
|
+
AtomicWrite.write(path.join("README.md"), <<~README)
|
|
250
|
+
# #{title}
|
|
251
|
+
|
|
252
|
+
Space: `#{id}`
|
|
253
|
+
Created: #{timestamp.iso8601}
|
|
254
|
+
|
|
255
|
+
## Organization
|
|
256
|
+
|
|
257
|
+
- `space.yaml` tracks the space identity, status, and associated metadata.
|
|
258
|
+
- `repos/` contains cloned Git repositories for this work.
|
|
259
|
+
- `notes/` is for task notes, scratch docs, and thinking-in-progress.
|
|
260
|
+
- `architecture/` holds the architect project memory (ARCHITECT.md and the per-iteration files).
|
|
261
|
+
- `tmp/` is the workspace-local scratch directory. Use it instead of `/tmp` or
|
|
262
|
+
`/var/tmp`; when using `mktemp`, use `tmp/` as the base directory.
|
|
263
|
+
- `build/` holds the architect loop's per-lane worktrees and scratch
|
|
264
|
+
(gitignored except `.keep`).
|
|
265
|
+
- The space is a Git repository so notes and architecture are versioned.
|
|
266
|
+
`repos/`, `tmp/`, and `build/` are gitignored, keeping the cloned repos and scratch
|
|
267
|
+
out of the space's history (each clone keeps its own Git repo).
|
|
268
|
+
README
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Make the space itself a Git repo so its notes/architecture are versioned.
|
|
272
|
+
# `repos/` and `tmp/` are ignored: the clones keep their own `.git`, and a
|
|
273
|
+
# space-level `git add` must never pull them in as embedded-repo gitlinks.
|
|
274
|
+
def init_git(path:, id:, git_client:)
|
|
275
|
+
write_gitignore(path)
|
|
276
|
+
git_client.init(path)
|
|
277
|
+
git_client.commit_all(path, "Initialize space #{id}")
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def write_gitignore(path)
|
|
281
|
+
AtomicWrite.write(path.join(".gitignore"), <<~GITIGNORE)
|
|
282
|
+
repos/
|
|
283
|
+
tmp/
|
|
284
|
+
build/
|
|
285
|
+
!build/.keep
|
|
286
|
+
GITIGNORE
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def unique_id(base_id)
|
|
290
|
+
candidate = base_id
|
|
291
|
+
counter = 2
|
|
292
|
+
|
|
293
|
+
while spaces_dir.join(candidate).exist?
|
|
294
|
+
candidate = "#{base_id}-#{counter}"
|
|
295
|
+
counter += 1
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
candidate
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def looks_like_path?(value)
|
|
302
|
+
value.include?(File::SEPARATOR) || value.start_with?("~") || value.start_with?(".")
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def matching_spaces(value)
|
|
306
|
+
all = list
|
|
307
|
+
exact = all.select { |space| space.id == value }
|
|
308
|
+
return exact unless exact.empty?
|
|
309
|
+
|
|
310
|
+
suffix = all.select { |space| space.id.end_with?("-#{value}") }
|
|
311
|
+
return suffix unless suffix.empty?
|
|
312
|
+
|
|
313
|
+
prefix = all.select { |space| space.id.start_with?(value) }
|
|
314
|
+
return prefix unless prefix.empty?
|
|
315
|
+
|
|
316
|
+
all.select { |space| space.id.include?(value) }
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Space::Core
|
|
7
|
+
class State
|
|
8
|
+
DEFAULT_DATA = {
|
|
9
|
+
"version" => 1,
|
|
10
|
+
"current_space" => nil,
|
|
11
|
+
"recent" => []
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :path, :data, :env
|
|
15
|
+
|
|
16
|
+
def self.default_path(env: ENV)
|
|
17
|
+
XDG.state_home(env: env).join("space-cadet", "state.yml")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.load(env: ENV, path: default_path(env: env))
|
|
21
|
+
new(env:, path:).load
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(env: ENV, path: self.class.default_path(env: env), data: nil)
|
|
25
|
+
@path = Pathname.new(path)
|
|
26
|
+
@env = env
|
|
27
|
+
@data = data ? default_data.merge(stringify_keys(data)) : default_data
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def load
|
|
31
|
+
@data = if path.exist?
|
|
32
|
+
parsed = YAML.safe_load(path.read, aliases: false) || {}
|
|
33
|
+
unless parsed.is_a?(Hash)
|
|
34
|
+
raise Error, "State file must contain a YAML mapping: #{path}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
default_data.merge(stringify_keys(parsed))
|
|
38
|
+
else
|
|
39
|
+
default_data
|
|
40
|
+
end
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def ensure_exists!
|
|
45
|
+
save unless path.exist?
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def save
|
|
50
|
+
AtomicWrite.write(path, YAML.dump(data))
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def current_space
|
|
55
|
+
data["current_space"]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def current_space=(space_id)
|
|
59
|
+
data["current_space"] = space_id
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def recent
|
|
63
|
+
Array(data["recent"])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def touch_current(space_id)
|
|
67
|
+
self.current_space = space_id
|
|
68
|
+
touch_recent(space_id)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def touch_recent(space_id)
|
|
72
|
+
data["recent"] = ([space_id] + recent).compact.uniq.first(20)
|
|
73
|
+
save
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def default_data
|
|
79
|
+
DEFAULT_DATA.merge("recent" => [])
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def stringify_keys(hash)
|
|
83
|
+
hash.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Generated by `space pack` for space: <%= space_id %>
|
|
2
|
+
# Build (run from the space root):
|
|
3
|
+
# docker build -f <output_dir>/Dockerfile -t <%= space_id %>:latest .
|
|
4
|
+
#
|
|
5
|
+
# Run (auth injected at runtime — never baked into layers):
|
|
6
|
+
# container run --rm -it \
|
|
7
|
+
# -e ANTHROPIC_API_KEY=<key> \
|
|
8
|
+
<% persist_paths.each do |guest_path| -%>
|
|
9
|
+
# -v .state<%= guest_path %>:<%= guest_path %> \
|
|
10
|
+
<% end -%>
|
|
11
|
+
# <%= space_id %>:latest
|
|
12
|
+
<% if persist_paths.any? -%>
|
|
13
|
+
# (Persist: host paths are relative to space root; create them before first run.)
|
|
14
|
+
<% end -%>
|
|
15
|
+
#
|
|
16
|
+
# Available env vars at runtime:
|
|
17
|
+
# ANTHROPIC_API_KEY — API key
|
|
18
|
+
# CLAUDE_CODE_OAUTH_TOKEN — alternative to API key
|
|
19
|
+
# ANTHROPIC_BASE_URL — optional proxy endpoint
|
|
20
|
+
|
|
21
|
+
FROM ruby:4.0.5
|
|
22
|
+
|
|
23
|
+
RUN apt-get update -qq \
|
|
24
|
+
&& apt-get install -y --no-install-recommends \
|
|
25
|
+
git \
|
|
26
|
+
curl \
|
|
27
|
+
ca-certificates \
|
|
28
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
29
|
+
|
|
30
|
+
# Claude Code CLI (https://claude.ai/install.sh)
|
|
31
|
+
RUN curl -fsSL https://claude.ai/install.sh | bash
|
|
32
|
+
|
|
33
|
+
# Write entrypoint: sets git safe.directory, execs requested command or a login shell
|
|
34
|
+
RUN echo '<%= entrypoint_b64 %>' | base64 -d > /entrypoint.sh \
|
|
35
|
+
&& chmod +x /entrypoint.sh
|
|
36
|
+
|
|
37
|
+
# Prepend gem-exec and payload bin dirs so login shells (bash --login) resolve space/claude
|
|
38
|
+
RUN printf 'export PATH="/usr/local/bundle/bin:/root/.local/bin:$PATH"\n' \
|
|
39
|
+
> /etc/profile.d/space-cadet.sh
|
|
40
|
+
<% if provision_scripts.any? -%>
|
|
41
|
+
|
|
42
|
+
# Space-declared build-time provisioning (pack.provision)
|
|
43
|
+
# Each script is COPY'd individually — a space-content edit elsewhere does not bust its cache layer.
|
|
44
|
+
<% provision_scripts.each do |script| -%>
|
|
45
|
+
COPY <%= script %> /space/<%= script %>
|
|
46
|
+
RUN /space/<%= script %>
|
|
47
|
+
<% end -%>
|
|
48
|
+
<% end -%>
|
|
49
|
+
|
|
50
|
+
# Install space-cadet gem
|
|
51
|
+
<% if local_space_cadet? -%>
|
|
52
|
+
COPY repos/space-cadet /space/repos/space-cadet
|
|
53
|
+
RUN cd /space/repos/space-cadet \
|
|
54
|
+
&& rake build \
|
|
55
|
+
&& gem install --no-document pkg/*.gem
|
|
56
|
+
<% else -%>
|
|
57
|
+
RUN gem install --no-document space-cadet
|
|
58
|
+
<% end -%>
|
|
59
|
+
|
|
60
|
+
# Copy the space tree into the image (filtered by Dockerfile.dockerignore alongside this Dockerfile)
|
|
61
|
+
COPY . /space
|
|
62
|
+
<% if persist_paths.any? -%>
|
|
63
|
+
|
|
64
|
+
# Seed snapshot: capture baked content of persisted dirs so a first-run (empty) mount is seeded from it
|
|
65
|
+
<% persist_paths.each do |guest_path| -%>
|
|
66
|
+
RUN mkdir -p "/opt/space-seed<%= guest_path %>" && cp -a "<%= guest_path %>/." "/opt/space-seed<%= guest_path %>/" 2>/dev/null || true
|
|
67
|
+
<% end -%>
|
|
68
|
+
<% end -%>
|
|
69
|
+
|
|
70
|
+
WORKDIR /space
|
|
71
|
+
|
|
72
|
+
ENTRYPOINT ["/entrypoint.sh"]
|
|
73
|
+
CMD ["bash", "--login"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Build scratch: worktrees, harness output, builder artefacts
|
|
2
|
+
build/
|
|
3
|
+
|
|
4
|
+
# Temp workspace
|
|
5
|
+
tmp/
|
|
6
|
+
notes/tmp/
|
|
7
|
+
|
|
8
|
+
# Secrets — NEVER bake credentials in image layers
|
|
9
|
+
.env
|
|
10
|
+
**/.env
|
|
11
|
+
*.key
|
|
12
|
+
*.pem
|
|
13
|
+
*.p12
|
|
14
|
+
*.pfx
|
|
15
|
+
id_rsa
|
|
16
|
+
id_ecdsa
|
|
17
|
+
id_ed25519
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -e
|
|
3
|
+
git config --global --add safe.directory '*'
|
|
4
|
+
git config --global --get user.name >/dev/null 2>&1 || git config --global user.name 'space-cadet'
|
|
5
|
+
git config --global --get user.email >/dev/null 2>&1 || git config --global user.email 'architect@localhost'
|
|
6
|
+
<% persist_paths.each do |guest_path| -%>
|
|
7
|
+
if [ -d "/opt/space-seed<%= guest_path %>" ] && [ -z "$(ls -A "<%= guest_path %>" 2>/dev/null)" ]; then
|
|
8
|
+
mkdir -p "<%= guest_path %>"
|
|
9
|
+
cp -a "/opt/space-seed<%= guest_path %>/." "<%= guest_path %>/" 2>/dev/null || true
|
|
10
|
+
fi
|
|
11
|
+
<% end -%>
|
|
12
|
+
if [ "$#" -eq 0 ]; then
|
|
13
|
+
exec bash --login
|
|
14
|
+
else
|
|
15
|
+
exec "$@"
|
|
16
|
+
fi
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "pastel"
|
|
5
|
+
|
|
6
|
+
module Space::Core
|
|
7
|
+
class Terminal
|
|
8
|
+
SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :stdout, :stderr, :config
|
|
11
|
+
|
|
12
|
+
def initialize(config:, stdout: $stdout, stderr: $stderr, color_mode: "auto")
|
|
13
|
+
@config = config
|
|
14
|
+
@stdout = stdout
|
|
15
|
+
@stderr = stderr
|
|
16
|
+
@color_mode = color_mode.to_s.downcase
|
|
17
|
+
color_mode
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def interactive?
|
|
21
|
+
stderr.tty?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def pastel
|
|
25
|
+
@pastel ||= Pastel.new(enabled: colors_enabled?)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def say(message = "")
|
|
29
|
+
stdout.puts(message)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def error(message)
|
|
33
|
+
stderr.puts(colors_enabled? ? pastel.red(message) : message)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def success(message)
|
|
37
|
+
say pastel.green(message)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def table(headers, rows)
|
|
41
|
+
column_widths = headers.each_index.map do |index|
|
|
42
|
+
([headers[index]] + rows.map { |row| row[index].to_s }).map(&:length).max
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
([headers] + rows).each_with_index.map do |row, row_index|
|
|
46
|
+
table_row(headers, row, column_widths, header: row_index.zero?)
|
|
47
|
+
end.join("\n")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def path(path)
|
|
51
|
+
Paths.contract(path, env: config.env)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def with_spinner(message)
|
|
55
|
+
return yield unless interactive?
|
|
56
|
+
|
|
57
|
+
Async do |task|
|
|
58
|
+
spinner_task = start_spinner(task, message)
|
|
59
|
+
yield
|
|
60
|
+
ensure
|
|
61
|
+
spinner_task&.stop
|
|
62
|
+
begin
|
|
63
|
+
spinner_task&.wait
|
|
64
|
+
rescue StandardError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
clear_spinner
|
|
68
|
+
end.wait
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def style_skill_action(action)
|
|
72
|
+
case action.to_s
|
|
73
|
+
when "installed", "updated"
|
|
74
|
+
pastel.green(action)
|
|
75
|
+
when "would_install", "would_update"
|
|
76
|
+
pastel.cyan(action)
|
|
77
|
+
when "unchanged"
|
|
78
|
+
pastel.bright_black(action)
|
|
79
|
+
when "conflict"
|
|
80
|
+
pastel.yellow(action)
|
|
81
|
+
else
|
|
82
|
+
action.to_s
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def color_mode
|
|
89
|
+
return @color_mode if %w[auto always never].include?(@color_mode)
|
|
90
|
+
|
|
91
|
+
raise Error, "Invalid color mode '#{@color_mode}'. Expected one of: auto, always, never"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def colors_enabled?
|
|
95
|
+
case color_mode
|
|
96
|
+
when "always"
|
|
97
|
+
true
|
|
98
|
+
when "never"
|
|
99
|
+
false
|
|
100
|
+
else
|
|
101
|
+
stdout.tty?
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def table_row(headers, row, column_widths, header: false)
|
|
106
|
+
row.each_with_index.map do |cell, index|
|
|
107
|
+
raw = cell.to_s
|
|
108
|
+
styled = header ? pastel.bold(raw) : style_table_cell(headers[index], raw)
|
|
109
|
+
"#{styled}#{' ' * (column_widths[index] - raw.length)}"
|
|
110
|
+
end.join(" ").rstrip
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def style_table_cell(header, value)
|
|
114
|
+
case header
|
|
115
|
+
when "Status"
|
|
116
|
+
style_status(value)
|
|
117
|
+
when "Date"
|
|
118
|
+
pastel.dim(value)
|
|
119
|
+
when "Path"
|
|
120
|
+
pastel.cyan(value)
|
|
121
|
+
else
|
|
122
|
+
value
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def style_status(status)
|
|
127
|
+
case status
|
|
128
|
+
when "active"
|
|
129
|
+
pastel.green(status)
|
|
130
|
+
when "paused"
|
|
131
|
+
pastel.yellow(status)
|
|
132
|
+
when "done"
|
|
133
|
+
pastel.blue(status)
|
|
134
|
+
when "archived"
|
|
135
|
+
pastel.bright_black(status)
|
|
136
|
+
else
|
|
137
|
+
status
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def start_spinner(task, message)
|
|
142
|
+
task.async do |spinner|
|
|
143
|
+
frame_index = 0
|
|
144
|
+
|
|
145
|
+
loop do
|
|
146
|
+
text = message.respond_to?(:call) ? message.call : message.to_s
|
|
147
|
+
frame = SPINNER_FRAMES[frame_index % SPINNER_FRAMES.length]
|
|
148
|
+
stderr.print "\r\e[2K#{pastel.cyan(frame)} #{text}"
|
|
149
|
+
stderr.flush
|
|
150
|
+
frame_index += 1
|
|
151
|
+
spinner.sleep(0.1)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def clear_spinner
|
|
157
|
+
stderr.print "\r\e[2K"
|
|
158
|
+
stderr.flush
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|