samagotchi 0.5.1 → 0.6.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 +4 -4
- data/CHANGELOG.md +64 -1
- data/bin/chi +32 -891
- data/docs/architecture.md +8 -1
- data/docs/cli.md +12 -13
- data/docs/configuration.md +4 -1
- data/docs/desktop.md +3 -1
- data/docs/hooks.md +69 -4
- data/docs/plugins.md +98 -3
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge/card_store.rb +30 -5
- data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
- data/lib/samagotchi/bridge/turn_notice.rb +37 -0
- data/lib/samagotchi/bundle_command.rb +652 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/cancellation_controller.rb +42 -1
- data/lib/samagotchi/config.rb +2 -2
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +20 -5
- data/lib/samagotchi/desktop/macos/Panel.swift +15 -0
- data/lib/samagotchi/engine.rb +138 -618
- data/lib/samagotchi/guardrail_wiring.rb +238 -0
- data/lib/samagotchi/hooks/registry.rb +11 -3
- data/lib/samagotchi/hooks/stream_watch.rb +93 -0
- data/lib/samagotchi/hooks.rb +3 -0
- data/lib/samagotchi/kernel_loop.rb +65 -6
- data/lib/samagotchi/llm/chat_loop.rb +73 -11
- data/lib/samagotchi/llm/openai_chat.rb +4 -2
- data/lib/samagotchi/log_subscriber.rb +10 -4
- data/lib/samagotchi/plugin/api.rb +8 -0
- data/lib/samagotchi/plugin/context.rb +12 -3
- data/lib/samagotchi/plugin_tasks.rb +229 -0
- data/lib/samagotchi/question_desk.rb +280 -0
- data/lib/samagotchi/recap_setup.rb +128 -0
- data/lib/samagotchi/session.rb +9 -1
- data/lib/samagotchi/sessions_command.rb +255 -0
- data/lib/samagotchi/steer.rb +19 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +4 -1
- data/lib/samagotchi/turn_note.rb +20 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -8
- data/lib/samagotchi/web/public/app.js +94 -52
- data/lib/samagotchi/web/public/card.js +8 -1
- data/lib/samagotchi/web/public/format.js +1 -1
- data/lib/samagotchi/web/public/index.html +2 -5
- data/lib/samagotchi/web/public/stage_model.js +44 -0
- data/lib/samagotchi/web/public/stage_view.js +26 -17
- data/lib/samagotchi/web/public/timing.js +9 -3
- data/lib/samagotchi/web/public/turn_events.js +18 -5
- data/lib/samagotchi/web/public/turn_view.js +68 -16
- data/lib/samagotchi/web/server.rb +4 -3
- metadata +9 -2
- data/lib/samagotchi/web/public/chat_view.js +0 -376
data/bin/chi
CHANGED
|
@@ -21,878 +21,42 @@ require "samagotchi"
|
|
|
21
21
|
# bundles (read-only, offline) — lets the agent find its own code and state.
|
|
22
22
|
if ARGV[0] == "self"
|
|
23
23
|
if %w[-h --help help].include?(ARGV[1])
|
|
24
|
-
puts "Usage: chi self"
|
|
24
|
+
puts "Usage: chi self [--model]"
|
|
25
25
|
puts " Print version, source dir, config/memory/session paths, model/host and bundles."
|
|
26
|
+
puts " --model print only the model a new session starts on (exit 1 when none is configured)"
|
|
26
27
|
exit 0
|
|
27
28
|
end
|
|
28
29
|
require "samagotchi/self_report"
|
|
30
|
+
# The desktop helper's hint for its "New session" row: offline and quick.
|
|
31
|
+
if ARGV[1] == "--model"
|
|
32
|
+
model = Samagotchi::SelfReport.model_name
|
|
33
|
+
puts model if model
|
|
34
|
+
exit(model ? 0 : 1)
|
|
35
|
+
end
|
|
29
36
|
puts Samagotchi::SelfReport.text
|
|
30
37
|
exit 0
|
|
31
38
|
end
|
|
32
39
|
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
puts "Defaults: days=14 keep=500 keep_status=none (config: session.retention_days, session.max_count, session.keep_status)"
|
|
54
|
-
exit 0
|
|
55
|
-
end
|
|
56
|
-
dry_run = sessions_args.include?("--dry-run")
|
|
57
|
-
test_only = sessions_args.include?("--test-only") || sessions_args.include?("--test")
|
|
58
|
-
days = nil
|
|
59
|
-
keep = nil
|
|
60
|
-
keep_status = nil
|
|
61
|
-
sort = nil
|
|
62
|
-
order = nil
|
|
63
|
-
limit = nil
|
|
64
|
-
live = sessions_args.include?("--live")
|
|
65
|
-
include_archived = sessions_args.include?("--archived")
|
|
66
|
-
cwd = nil
|
|
67
|
-
format = nil
|
|
68
|
-
scope = nil
|
|
69
|
-
sessions_args.each_with_index do |arg, i|
|
|
70
|
-
nxt = sessions_args[i+1]
|
|
71
|
-
if arg == "--days" && nxt then days = nxt.to_i
|
|
72
|
-
elsif arg.start_with?("--days=") then days = arg.split("=",2).last.to_i
|
|
73
|
-
elsif arg == "--keep" && nxt then keep = nxt.to_i
|
|
74
|
-
elsif arg.start_with?("--keep=") then keep = arg.split("=",2).last.to_i
|
|
75
|
-
elsif arg == "--keep-status" && nxt then keep_status = nxt
|
|
76
|
-
elsif arg.start_with?("--keep-status=") then keep_status = arg.split("=",2).last
|
|
77
|
-
elsif arg == "--sort" && nxt then sort = nxt
|
|
78
|
-
elsif arg.start_with?("--sort=") then sort = arg.split("=",2).last
|
|
79
|
-
elsif arg == "--order" && nxt then order = nxt
|
|
80
|
-
elsif arg.start_with?("--order=") then order = arg.split("=",2).last
|
|
81
|
-
elsif arg == "--limit" && nxt then limit = nxt.to_i
|
|
82
|
-
elsif arg.start_with?("--limit=") then limit = arg.split("=",2).last.to_i
|
|
83
|
-
elsif arg == "--cwd" && nxt then cwd = nxt
|
|
84
|
-
elsif arg.start_with?("--cwd=") then cwd = arg.split("=",2).last
|
|
85
|
-
elsif arg == "--format" && nxt then format = nxt
|
|
86
|
-
elsif arg.start_with?("--format=") then format = arg.split("=",2).last
|
|
87
|
-
elsif arg == "--scope" && nxt then scope = nxt
|
|
88
|
-
elsif arg.start_with?("--scope=") then scope = arg.split("=",2).last
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
case sub
|
|
92
|
-
when "delete"
|
|
93
|
-
require "samagotchi/session_delete_command"
|
|
94
|
-
exit Samagotchi::SessionDeleteCommand.new(sessions_args[1..]).run
|
|
95
|
-
when "archive", "unarchive"
|
|
96
|
-
require "samagotchi/session_archive_command"
|
|
97
|
-
exit Samagotchi::SessionArchiveCommand.new(sub, sessions_args[1..]).run
|
|
98
|
-
when "list"
|
|
99
|
-
unless format.nil? || %w[text json tsv].include?(format)
|
|
100
|
-
warn "Unknown format #{format.inspect}: use --format text|json|tsv"
|
|
101
|
-
exit 1
|
|
102
|
-
end
|
|
103
|
-
unless scope.nil? || %w[project all].include?(scope)
|
|
104
|
-
warn "Unknown scope #{scope.inspect}: use --scope=project|all"
|
|
105
|
-
exit 1
|
|
106
|
-
end
|
|
107
|
-
# The current git project's sessions (Samagotchi::ProjectScope), like
|
|
108
|
-
# chi web's; --scope=all, a folder in no repo or an explicit --cwd: every
|
|
109
|
-
# project's.
|
|
110
|
-
project = scope == "all" || cwd ? nil : Samagotchi::ProjectScope.root_for(Dir.pwd)
|
|
111
|
-
scope_note = ->(count) { "#{count} session(s) in #{File.basename(project)} (--scope=all: every project)" }
|
|
112
|
-
# The picker (chi note from a script): filters apply before the limit,
|
|
113
|
-
# and test runs stay out, unless this is one (SAMAGOTCHI_ENV=test, CI):
|
|
114
|
-
# then its own sessions are what it looks for.
|
|
115
|
-
if live || cwd || (format && format != "text")
|
|
116
|
-
summaries = Samagotchi::SessionManager.session_summaries(
|
|
117
|
-
live: live, cwd: cwd, limit: limit || (live ? 10 : nil), include_tests: Samagotchi::Session.test_session_env?,
|
|
118
|
-
project_root: project,
|
|
119
|
-
include_archived: include_archived
|
|
120
|
-
)
|
|
121
|
-
case format
|
|
122
|
-
when "json"
|
|
123
|
-
# owner: "worker", "tui" (a chi REPL: it takes no notes or messages)
|
|
124
|
-
# or nil; recap: the saved recap's first sentence, or nil; project:
|
|
125
|
-
# its git project's root, or nil
|
|
126
|
-
# parent_id: the session that delegated it (the delegate tool), or nil
|
|
127
|
-
# archived: hidden from the lists (only with --archived can it be true)
|
|
128
|
-
# scratch: a `chi scratch` session (deleted when its REPL ends, a
|
|
129
|
-
# leftover one at the next sweep)
|
|
130
|
-
# ctx_pct: how full the context was after the last turn, or nil
|
|
131
|
-
keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id archived scratch ctx_pct]
|
|
132
|
-
puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
|
|
133
|
-
when "tsv"
|
|
134
|
-
summaries.each { |summary| puts "#{summary[:id]}\t#{summary[:desc]}" }
|
|
135
|
-
else
|
|
136
|
-
summaries.each do |summary|
|
|
137
|
-
state = summary[:busy] ? "running" : (summary[:live] ? "live" : summary[:status].to_s)
|
|
138
|
-
# The saved recap's first sentence says more than the last prompt
|
|
139
|
-
# (text only: tsv and json keep desc for pickers).
|
|
140
|
-
desc = summary[:desc]
|
|
141
|
-
if summary[:recap]
|
|
142
|
-
desc = [File.basename(summary[:cwd].to_s), summary[:recap]].reject(&:empty?).join(" · ")
|
|
143
|
-
desc = "#{desc[0, 59]}…" if desc.length > 60
|
|
144
|
-
end
|
|
145
|
-
# A delegated session points at its parent.
|
|
146
|
-
child = summary[:parent_short_id] ? " ↳ #{summary[:parent_short_id]}" : ""
|
|
147
|
-
flag = summary[:scratch] ? " [scratch]" : (summary[:test_run] ? " [test]" : "")
|
|
148
|
-
flag += " [archived]" if summary[:archived]
|
|
149
|
-
ctx = Samagotchi::SessionMetrics.context_label(summary[:ctx_pct])
|
|
150
|
-
puts "#{summary[:id]} #{state.ljust(8)} #{ctx.ljust(8)} #{summary[:updated_at]} #{desc}#{flag}#{child}"
|
|
151
|
-
end
|
|
152
|
-
if project
|
|
153
|
-
puts "#{summaries.empty? ? "" : "\n"}#{scope_note.call(summaries.size)}"
|
|
154
|
-
else
|
|
155
|
-
puts summaries.empty? ? "No sessions." : "\n#{summaries.size} session(s)"
|
|
156
|
-
end
|
|
157
|
-
end
|
|
158
|
-
exit 0
|
|
159
|
-
end
|
|
160
|
-
sort ||= "updated_at"
|
|
161
|
-
order ||= "desc"
|
|
162
|
-
sessions = Samagotchi::SessionManager.list_sessions(sort: sort, order: order, limit: limit, project_root: project,
|
|
163
|
-
include_archived: include_archived)
|
|
164
|
-
# The saved recap's first sentence, else the last prompt, cut as before.
|
|
165
|
-
state_dir = Samagotchi::Session.default_state_dir
|
|
166
|
-
list_text = lambda do |s|
|
|
167
|
-
recap = Samagotchi::RecapStore.preview(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
|
|
168
|
-
(recap || Samagotchi::SessionManager.one_line(s.last_prompt))[0, 60]
|
|
169
|
-
end
|
|
170
|
-
# A delegated session points at its parent: ↳ <parent's short id>.
|
|
171
|
-
row = lambda do |s|
|
|
172
|
-
flag = s.scratch ? " [scratch]" : (s.test_run ? " [test]" : "")
|
|
173
|
-
flag += " [archived]" if s.archived
|
|
174
|
-
child = s.parent_id ? " ↳ #{s.parent_id[0, 8]}" : ""
|
|
175
|
-
# How full the context was after the last turn: "ctx 12%", blank when unknown.
|
|
176
|
-
ctx = Samagotchi::SessionMetrics.context_label(
|
|
177
|
-
Samagotchi::SessionMetrics.saved_context_pct(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
|
|
178
|
-
)
|
|
179
|
-
"#{s.id} #{s.status.ljust(8)} #{ctx.ljust(8)} #{s.updated_at} #{list_text.call(s)}#{flag}#{child}"
|
|
180
|
-
end
|
|
181
|
-
if project
|
|
182
|
-
sessions.each { |s| puts row.call(s) }
|
|
183
|
-
puts "#{sessions.empty? ? "" : "\n"}#{scope_note.call(sessions.size)}"
|
|
184
|
-
elsif sessions.empty?
|
|
185
|
-
puts "No sessions."
|
|
186
|
-
else
|
|
187
|
-
sessions.each { |s| puts row.call(s) }
|
|
188
|
-
puts "\n#{sessions.size} session(s) (sort=#{sort} order=#{order})"
|
|
189
|
-
end
|
|
190
|
-
exit 0
|
|
191
|
-
when "stop"
|
|
192
|
-
ids = sessions_args[1..]
|
|
193
|
-
if ids.empty? || ids.any? { |arg| arg.start_with?("-") }
|
|
194
|
-
warn "Usage: chi sessions stop ID..."
|
|
195
|
-
exit 1
|
|
196
|
-
end
|
|
197
|
-
# Each id in turn, like chi sessions delete: stdout is flushed before an
|
|
198
|
-
# error line, so the output keeps the order of the ids given.
|
|
199
|
-
ok = ids.uniq.map do |given|
|
|
200
|
-
begin
|
|
201
|
-
id = Samagotchi::Session.resolve_id(given)
|
|
202
|
-
released = Samagotchi::SessionManager.stop_session(id, wait: 10)
|
|
203
|
-
rescue Samagotchi::SessionManager::OwnedByTUI
|
|
204
|
-
$stdout.flush
|
|
205
|
-
warn "session #{id} is open in a chi REPL; close it there first"
|
|
206
|
-
next false
|
|
207
|
-
rescue ArgumentError => e
|
|
208
|
-
$stdout.flush
|
|
209
|
-
warn e.message
|
|
210
|
-
next false
|
|
211
|
-
end
|
|
212
|
-
if released
|
|
213
|
-
puts "Stopped session #{id}. chi --resume #{id} starts a fresh worker."
|
|
214
|
-
else
|
|
215
|
-
$stdout.flush
|
|
216
|
-
warn "Stopped session #{id}, but its worker is still shutting down; try chi --resume #{id} in a moment."
|
|
217
|
-
end
|
|
218
|
-
true
|
|
219
|
-
end
|
|
220
|
-
exit(ok.all? ? 0 : 1)
|
|
221
|
-
when "prune", "clean"
|
|
222
|
-
test_only = true if sub == "clean" && !sessions_args.include?("--all")
|
|
223
|
-
# Test sessions are throwaway: clean takes them whatever their age,
|
|
224
|
-
# unless --days asks for the older ones only. Live workers and
|
|
225
|
-
# keep_status still protect a session.
|
|
226
|
-
any_age = sub == "clean" && test_only && days.nil?
|
|
227
|
-
result = Samagotchi::SessionManager.prune_sessions(days: days, max_count: keep, keep_status: keep_status, dry_run: dry_run,
|
|
228
|
-
test_only: test_only, any_age: any_age)
|
|
229
|
-
mode = dry_run ? "Would delete" : "Deleted"
|
|
230
|
-
puts "#{mode} #{result[:deleted].size} sessions (kept #{result[:kept].size}, skipped #{result[:skipped].size})"
|
|
231
|
-
if result[:deleted].any?
|
|
232
|
-
result[:deleted].each { |id| puts " #{id}" }
|
|
233
|
-
end
|
|
234
|
-
if dry_run && result[:deleted].any?
|
|
235
|
-
puts "\nRun without --dry-run to delete."
|
|
236
|
-
end
|
|
237
|
-
exit 0
|
|
238
|
-
else
|
|
239
|
-
warn "Unknown sessions subcommand: #{sub}. Use: list, stop, archive, unarchive, delete, prune, clean"
|
|
240
|
-
exit 1
|
|
241
|
-
end
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
# `chi note`: a context note for sessions, before OptionParser (-m, --source, --all are its own)
|
|
245
|
-
if ARGV[0] == "note"
|
|
246
|
-
require "samagotchi/note_command"
|
|
247
|
-
exit Samagotchi::NoteCommand.new(ARGV[1..] || []).run
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
# `chi send`: a message into sessions as the user's, before OptionParser (-m is its own)
|
|
251
|
-
if ARGV[0] == "send"
|
|
252
|
-
require "samagotchi/send_command"
|
|
253
|
-
exit Samagotchi::SendCommand.new(ARGV[1..] || []).run
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
# `chi bootstrap`: find a model server and write config.yml, before OptionParser (--model, --name are its own)
|
|
257
|
-
if ARGV[0] == "bootstrap"
|
|
258
|
-
require "samagotchi/bootstrap_command"
|
|
259
|
-
exit Samagotchi::BootstrapCommand.new(ARGV[1..] || []).run
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
# `chi update`: bring an installed chi up to date, before OptionParser (its own flags)
|
|
263
|
-
if ARGV[0] == "update"
|
|
264
|
-
require "samagotchi/update_command"
|
|
265
|
-
exit Samagotchi::UpdateCommand.new(ARGV[1..] || []).run
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
# `chi desktop`: the native "Send to chi" helper, before OptionParser (its own flags)
|
|
269
|
-
if ARGV[0] == "desktop"
|
|
270
|
-
require "samagotchi/desktop_command"
|
|
271
|
-
exit Samagotchi::DesktopCommand.new(ARGV[1..] || []).run
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
# Handle `bundle` subcommand before OptionParser (has its own flags like --scope, --force)
|
|
275
|
-
if ARGV[0] == "bundle"
|
|
276
|
-
require "samagotchi/memory_bundle"
|
|
277
|
-
bundle_args = ARGV[1..] || []
|
|
278
|
-
sub = bundle_args[0]
|
|
279
|
-
if sub.nil? || %w[-h --help help].include?(sub)
|
|
280
|
-
puts "Usage: chi bundle <install|upgrade|uninstall|status|diff|list|build> [options]"
|
|
281
|
-
puts ""
|
|
282
|
-
puts " install <source> [--scope system|project] [--force]"
|
|
283
|
-
puts " Install a memory bundle from a directory, zip, tar archive, or git URL."
|
|
284
|
-
puts " Source is the path/URL to the bundle (dir/zip/tar.gz/git)."
|
|
285
|
-
puts " --force overwrites existing entries; default skips them."
|
|
286
|
-
puts ""
|
|
287
|
-
puts " upgrade <source> [--scope system|project] [--force] [--dry-run] [--agent|--no-agent]"
|
|
288
|
-
puts " Upgrade a bundle (3-way merge: auto-merge if not edited, conflict otherwise)."
|
|
289
|
-
puts " --dry-run shows what would change. --agent launches interactive session on conflict."
|
|
290
|
-
puts ""
|
|
291
|
-
puts " uninstall <bundle> [--scope system|project] [--force]"
|
|
292
|
-
puts " Remove a bundle and its index entries (--force if locally edited)."
|
|
293
|
-
puts ""
|
|
294
|
-
puts " status [<bundle>]"
|
|
295
|
-
puts " Show provenance + modification status for bundles."
|
|
296
|
-
puts ""
|
|
297
|
-
puts " diff <bundle> [file]"
|
|
298
|
-
puts " Show diffs between base/current/incoming for a bundle."
|
|
299
|
-
puts ""
|
|
300
|
-
puts " list"
|
|
301
|
-
puts " List installed bundles and the ones shipped with chi that aren't installed."
|
|
302
|
-
puts ""
|
|
303
|
-
puts " build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]"
|
|
304
|
-
puts " Build local memories and installed hooks (and plugin) into a shareable bundle (dir or zip)."
|
|
305
|
-
puts " --scope selects source dir (default: system). --out inferred from extension; default <name>.zip."
|
|
306
|
-
puts " FILES... optional allowlist of *.md basenames to include (default: all)."
|
|
307
|
-
exit 0
|
|
308
|
-
end
|
|
309
|
-
|
|
310
|
-
# helpers for bundle_name derivation
|
|
311
|
-
def chi_expand_source(src)
|
|
312
|
-
return src if Samagotchi::MemoryBundle::SourceNormalizer.git_url?(src)
|
|
313
|
-
Samagotchi::MemoryBundle::SourceNormalizer.shipped_bundle_dir(src) || File.expand_path(src)
|
|
314
|
-
end
|
|
315
|
-
|
|
316
|
-
def chi_bundle_name_for(expanded_src)
|
|
317
|
-
# Try to derive from manifest if possible (skip git clone for speed if possible)
|
|
318
|
-
if Samagotchi::MemoryBundle::SourceNormalizer.git_url?(expanded_src)
|
|
319
|
-
# For git URLs, avoid double clone: derive name from URL basename if no manifest yet
|
|
320
|
-
# Let Installer do the real manifest read; fallback to URL basename
|
|
321
|
-
base = expanded_src.split("/").last.to_s.split("#").first.to_s
|
|
322
|
-
base = base.sub(/\.git\z/i, "")
|
|
323
|
-
return base.empty? ? "bundle" : base
|
|
324
|
-
end
|
|
325
|
-
require "yaml"
|
|
326
|
-
nd = nil
|
|
327
|
-
owned = false
|
|
328
|
-
begin
|
|
329
|
-
nd, owned = Samagotchi::MemoryBundle::SourceNormalizer.normalize(expanded_src)
|
|
330
|
-
m = Samagotchi::MemoryBundle::Manifest.read(dir: nd)
|
|
331
|
-
return m.name
|
|
332
|
-
rescue Samagotchi::MemoryBundle::Manifest::ValidationError
|
|
333
|
-
# Fallback to source basename without archive extensions
|
|
334
|
-
src = expanded_src
|
|
335
|
-
name = File.basename(src)
|
|
336
|
-
name = name.sub(/\.tar\.gz\z/i, "").sub(/\.tgz\z/i, "").sub(/\.zip\z/i, "").sub(/\.tar\z/i, "")
|
|
337
|
-
return name.empty? ? File.basename(nd || src) : name
|
|
338
|
-
ensure
|
|
339
|
-
Samagotchi::MemoryBundle::SourceNormalizer.cleanup(nd) if owned
|
|
340
|
-
end
|
|
341
|
-
end
|
|
342
|
-
|
|
343
|
-
def chi_build_conflict_prompt(bundle_name, conflicts, expanded_source)
|
|
344
|
-
lines = []
|
|
345
|
-
lines << "Memory bundle upgrade has conflicts that need interactive resolution."
|
|
346
|
-
lines << "Bundle: #{bundle_name}"
|
|
347
|
-
lines << "Source: #{expanded_source}"
|
|
348
|
-
lines << "Conflicts (#{conflicts.size} file(s)):"
|
|
349
|
-
conflicts.each do |file_key, info|
|
|
350
|
-
lines << " - #{file_key}"
|
|
351
|
-
lines << " base: #{info[:base]} (provenance snapshot)"
|
|
352
|
-
lines << " current: #{info[:current]} (on-disk, has local edits)"
|
|
353
|
-
lines << " incoming: #{info[:incoming]} (new bundle version)"
|
|
354
|
-
begin
|
|
355
|
-
if File.exist?(info[:current]) && File.exist?(info[:incoming])
|
|
356
|
-
base_content = File.exist?(info[:base]) ? File.read(info[:base]) : ""
|
|
357
|
-
cur = File.read(info[:current])
|
|
358
|
-
inc = File.read(info[:incoming])
|
|
359
|
-
lines << " --- current vs incoming preview ---"
|
|
360
|
-
# simple line diff preview (first 40 lines)
|
|
361
|
-
cur_lines = cur.lines
|
|
362
|
-
inc_lines = inc.lines
|
|
363
|
-
max = [cur_lines.size, inc_lines.size].max
|
|
364
|
-
preview = []
|
|
365
|
-
max.times do |i|
|
|
366
|
-
break if preview.size >= 30
|
|
367
|
-
c = cur_lines[i]
|
|
368
|
-
n = inc_lines[i]
|
|
369
|
-
if c != n
|
|
370
|
-
preview << "- #{c.chomp}" if c
|
|
371
|
-
preview << "+ #{n.chomp}" if n
|
|
372
|
-
end
|
|
373
|
-
end
|
|
374
|
-
lines.concat(preview)
|
|
375
|
-
lines << " --- end preview ---"
|
|
376
|
-
end
|
|
377
|
-
rescue => _e
|
|
378
|
-
end
|
|
379
|
-
end
|
|
380
|
-
lines << ""
|
|
381
|
-
lines << "Your task: resolve each conflict by editing the file in place using the memory_write tool."
|
|
382
|
-
lines << "Use memory_read to inspect current content if needed. The correct scope for memory_write is the bundle's scope."
|
|
383
|
-
lines << "When all conflicts are resolved, type /exit to finish. If you stop early, the files stay as they are (the rest of the upgrade is already applied)."
|
|
384
|
-
lines.join("\n")
|
|
385
|
-
end
|
|
386
|
-
|
|
387
|
-
case sub
|
|
388
|
-
when "install"
|
|
389
|
-
rest = bundle_args[1..]
|
|
390
|
-
source = nil
|
|
391
|
-
scope = nil
|
|
392
|
-
force = false
|
|
393
|
-
i = 0
|
|
394
|
-
while i < rest.size
|
|
395
|
-
arg = rest[i]
|
|
396
|
-
if arg.start_with?("--")
|
|
397
|
-
nxt = rest[i + 1]
|
|
398
|
-
if %w[-h --help help].include?(arg)
|
|
399
|
-
puts "Usage: chi bundle install <source> [--scope system|project] [--force]"
|
|
400
|
-
puts ""
|
|
401
|
-
puts " install <source> [--scope system|project] [--force]"
|
|
402
|
-
puts " Install a memory bundle from a directory, zip, tar archive, or git URL,"
|
|
403
|
-
puts " or a bundle shipped with chi by name (see: chi bundle list)."
|
|
404
|
-
puts " --force overwrites existing entries; default skips them."
|
|
405
|
-
exit 0
|
|
406
|
-
elsif arg == "--scope" && nxt
|
|
407
|
-
scope = nxt
|
|
408
|
-
i += 2
|
|
409
|
-
elsif arg.start_with?("--scope=")
|
|
410
|
-
scope = arg.split("=", 2).last
|
|
411
|
-
i += 1
|
|
412
|
-
elsif arg == "--force"
|
|
413
|
-
force = true
|
|
414
|
-
i += 1
|
|
415
|
-
else
|
|
416
|
-
warn "Unknown bundle install flag: #{arg}"
|
|
417
|
-
exit 1
|
|
418
|
-
end
|
|
419
|
-
else
|
|
420
|
-
source = arg
|
|
421
|
-
i += 1
|
|
422
|
-
end
|
|
423
|
-
end
|
|
424
|
-
if source.nil? || source.empty?
|
|
425
|
-
warn "Usage: chi bundle install <source> [--scope system|project] [--force]"
|
|
426
|
-
exit 1
|
|
427
|
-
end
|
|
428
|
-
expanded_source = chi_expand_source(source)
|
|
429
|
-
bundle_name = chi_bundle_name_for(expanded_source)
|
|
430
|
-
installer = Samagotchi::MemoryBundle::Installer.new(
|
|
431
|
-
source: expanded_source,
|
|
432
|
-
name: bundle_name,
|
|
433
|
-
scope: scope,
|
|
434
|
-
force: force,
|
|
435
|
-
strict: true
|
|
436
|
-
)
|
|
437
|
-
begin
|
|
438
|
-
_nd, manifest = installer.run
|
|
439
|
-
puts installer.summary
|
|
440
|
-
if manifest && manifest.hooks && !manifest.hooks.empty?
|
|
441
|
-
hook_cnt = manifest.hooks.size
|
|
442
|
-
puts "Hooks: #{hook_cnt} hook(s) (#{manifest.hooks.keys.join(', ')})"
|
|
443
|
-
end
|
|
444
|
-
puts "Plugin: #{manifest.plugin[:file]} (loads at the next chi start)" if manifest&.plugin
|
|
445
|
-
puts "Provenance written to: #{Samagotchi::MemoryBundle::Provenance.bundles_dir}/#{bundle_name}/" if manifest
|
|
446
|
-
exit 0
|
|
447
|
-
rescue Samagotchi::MemoryBundle::Installer::InstallError => e
|
|
448
|
-
warn "Install failed: #{e.message}"
|
|
449
|
-
exit 1
|
|
450
|
-
end
|
|
451
|
-
|
|
452
|
-
when "upgrade"
|
|
453
|
-
rest = bundle_args[1..]
|
|
454
|
-
source = nil
|
|
455
|
-
scope = nil
|
|
456
|
-
force = false
|
|
457
|
-
dry_run = false
|
|
458
|
-
agent = nil
|
|
459
|
-
i = 0
|
|
460
|
-
while i < rest.size
|
|
461
|
-
arg = rest[i]
|
|
462
|
-
if arg.start_with?("--")
|
|
463
|
-
nxt = rest[i + 1]
|
|
464
|
-
if %w[-h --help help].include?(arg)
|
|
465
|
-
puts "Usage: chi bundle upgrade <source> [--scope system|project] [--force] [--dry-run] [--agent|--no-agent]"
|
|
466
|
-
exit 0
|
|
467
|
-
elsif arg == "--scope" && nxt
|
|
468
|
-
scope = nxt; i += 2
|
|
469
|
-
elsif arg.start_with?("--scope=")
|
|
470
|
-
scope = arg.split("=", 2).last; i += 1
|
|
471
|
-
elsif arg == "--force"
|
|
472
|
-
force = true; i += 1
|
|
473
|
-
elsif arg == "--dry-run"
|
|
474
|
-
dry_run = true; i += 1
|
|
475
|
-
elsif arg == "--agent"
|
|
476
|
-
agent = true; i += 1
|
|
477
|
-
elsif arg == "--no-agent"
|
|
478
|
-
agent = false; i += 1
|
|
479
|
-
else
|
|
480
|
-
warn "Unknown bundle upgrade flag: #{arg}"
|
|
481
|
-
exit 1
|
|
482
|
-
end
|
|
483
|
-
else
|
|
484
|
-
source = arg; i += 1
|
|
485
|
-
end
|
|
486
|
-
end
|
|
487
|
-
if source.nil? || source.empty?
|
|
488
|
-
warn "Usage: chi bundle upgrade <source> [--scope system|project] [--force] [--dry-run]"
|
|
489
|
-
exit 1
|
|
490
|
-
end
|
|
491
|
-
expanded_source = chi_expand_source(source)
|
|
492
|
-
bundle_name = chi_bundle_name_for(expanded_source)
|
|
493
|
-
provenance = Samagotchi::MemoryBundle::Provenance.new(name: bundle_name)
|
|
494
|
-
unless provenance.installed?
|
|
495
|
-
warn "Bundle '#{bundle_name}' not installed — falling back to install"
|
|
496
|
-
installer = Samagotchi::MemoryBundle::Installer.new(source: expanded_source, name: bundle_name, scope: scope, force: force, strict: true)
|
|
497
|
-
begin
|
|
498
|
-
_nd, manifest = installer.run
|
|
499
|
-
puts installer.summary
|
|
500
|
-
puts "Provenance written to: #{Samagotchi::MemoryBundle::Provenance.bundles_dir}/#{bundle_name}/" if manifest
|
|
501
|
-
exit 0
|
|
502
|
-
rescue Samagotchi::MemoryBundle::Installer::InstallError => e
|
|
503
|
-
warn "Install failed: #{e.message}"; exit 1
|
|
504
|
-
end
|
|
505
|
-
end
|
|
506
|
-
installer = Samagotchi::MemoryBundle::Installer.new(
|
|
507
|
-
source: expanded_source,
|
|
508
|
-
name: bundle_name,
|
|
509
|
-
scope: scope,
|
|
510
|
-
force: force,
|
|
511
|
-
strict: true,
|
|
512
|
-
upgrade: true,
|
|
513
|
-
dry_run: dry_run
|
|
514
|
-
)
|
|
515
|
-
begin
|
|
516
|
-
_nd, manifest = installer.run
|
|
517
|
-
puts installer.summary
|
|
518
|
-
if manifest && manifest.hooks && !manifest.hooks.empty?
|
|
519
|
-
puts "Hooks: #{manifest.hooks.size} hook(s) (#{manifest.hooks.keys.join(', ')})"
|
|
520
|
-
end
|
|
521
|
-
puts "Plugin: #{manifest.plugin[:file]} (loads at the next chi start)" if manifest&.plugin && !dry_run
|
|
522
|
-
if dry_run
|
|
523
|
-
puts "(dry-run: no changes written)"
|
|
524
|
-
exit 0
|
|
525
|
-
end
|
|
526
|
-
if installer.conflicts.any? && !force
|
|
527
|
-
puts "\n#{installer.conflicts.size} conflict(s) need resolution."
|
|
528
|
-
installer.conflicts.each { |k, _| puts " conflict: #{k}" }
|
|
529
|
-
# Decide whether to launch agent
|
|
530
|
-
launch = false
|
|
531
|
-
if agent == false
|
|
532
|
-
launch = false
|
|
533
|
-
elsif agent == true
|
|
534
|
-
launch = true
|
|
535
|
-
elsif STDIN.tty?
|
|
536
|
-
print "Conflicts detected — launch interactive agent to resolve? [y/N] "
|
|
537
|
-
ans = begin; $stdin.gets; rescue => _e; nil; end
|
|
538
|
-
launch = ans && ans.strip.downcase.start_with?("y")
|
|
539
|
-
else
|
|
540
|
-
puts "Non-interactive terminal: kept your edits in the file(s) above; the rest is upgraded. Re-run with --force to take the bundle's version, or --agent in a TTY to merge."
|
|
541
|
-
exit 2
|
|
542
|
-
end
|
|
543
|
-
if launch
|
|
544
|
-
prompt = chi_build_conflict_prompt(bundle_name, installer.conflicts, expanded_source)
|
|
545
|
-
puts "Launching interactive session for conflict resolution… (/exit when done)"
|
|
546
|
-
require "samagotchi/terminal_ui"
|
|
547
|
-
Samagotchi::TerminalUI.new(prompt: prompt).run
|
|
548
|
-
# The installer already recorded the upgrade (conflicted files
|
|
549
|
-
# kept their old base); the resolved files now start from the
|
|
550
|
-
# bundle's version.
|
|
551
|
-
Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).resolve_conflicts(installer.conflicts)
|
|
552
|
-
puts "Provenance updated after interactive resolution."
|
|
553
|
-
puts "Upgrade resolved interactively."
|
|
554
|
-
exit 0
|
|
555
|
-
else
|
|
556
|
-
puts "Kept your edits in the file(s) above; the rest is upgraded. chi bundle diff #{bundle_name} FILE shows the base; re-run with --force to take the bundle's version."
|
|
557
|
-
exit 2
|
|
558
|
-
end
|
|
559
|
-
end
|
|
560
|
-
puts "Provenance written to: #{Samagotchi::MemoryBundle::Provenance.bundles_dir}/#{bundle_name}/" if manifest
|
|
561
|
-
exit 0
|
|
562
|
-
rescue Samagotchi::MemoryBundle::Installer::InstallError => e
|
|
563
|
-
warn "Upgrade failed: #{e.message}"; exit 1
|
|
564
|
-
end
|
|
565
|
-
|
|
566
|
-
when "uninstall"
|
|
567
|
-
rest = bundle_args[1..]
|
|
568
|
-
bundle_name = nil
|
|
569
|
-
scope = nil
|
|
570
|
-
force = false
|
|
571
|
-
i = 0
|
|
572
|
-
while i < rest.size
|
|
573
|
-
arg = rest[i]
|
|
574
|
-
if arg.start_with?("--")
|
|
575
|
-
nxt = rest[i + 1]
|
|
576
|
-
if %w[-h --help help].include?(arg)
|
|
577
|
-
puts "Usage: chi bundle uninstall <bundle> [--scope system|project] [--force]"
|
|
578
|
-
exit 0
|
|
579
|
-
elsif arg == "--scope" && nxt
|
|
580
|
-
scope = nxt; i += 2
|
|
581
|
-
elsif arg.start_with?("--scope=")
|
|
582
|
-
scope = arg.split("=", 2).last; i += 1
|
|
583
|
-
elsif arg == "--force"
|
|
584
|
-
force = true; i += 1
|
|
585
|
-
else
|
|
586
|
-
warn "Unknown bundle uninstall flag: #{arg}"; exit 1
|
|
587
|
-
end
|
|
588
|
-
else
|
|
589
|
-
bundle_name = arg; i += 1
|
|
590
|
-
end
|
|
591
|
-
end
|
|
592
|
-
if bundle_name.nil? || bundle_name.empty?
|
|
593
|
-
warn "Usage: chi bundle uninstall <bundle> [--scope system|project] [--force]"
|
|
594
|
-
exit 1
|
|
595
|
-
end
|
|
596
|
-
# Propagate overrides
|
|
597
|
-
Samagotchi::MemoryBundle::Uninstaller.system_dir_override = Samagotchi::MemoryBundle::Installer.system_dir_override
|
|
598
|
-
Samagotchi::MemoryBundle::Uninstaller.project_dir_base_override = Samagotchi::MemoryBundle::Installer.project_dir_base_override
|
|
599
|
-
uninstaller = Samagotchi::MemoryBundle::Uninstaller.new(name: bundle_name, scope: scope, force: force)
|
|
600
|
-
begin
|
|
601
|
-
uninstaller.run
|
|
602
|
-
puts "Uninstalled bundle '#{bundle_name}'"
|
|
603
|
-
puts "Removed: #{uninstaller.removed_files.join(', ')}" unless uninstaller.removed_files.empty?
|
|
604
|
-
hook_removed = uninstaller.removed_files.count { |f| f.start_with?("hooks/") }
|
|
605
|
-
puts "Hooks removed: #{hook_removed}" if hook_removed > 0
|
|
606
|
-
uninstaller.warnings.each { |w| puts w }
|
|
607
|
-
exit 0
|
|
608
|
-
rescue Samagotchi::MemoryBundle::Uninstaller::UninstallError => e
|
|
609
|
-
warn "Uninstall failed: #{e.message}"; exit 1
|
|
610
|
-
end
|
|
611
|
-
|
|
612
|
-
when "status"
|
|
613
|
-
rest = bundle_args[1..]
|
|
614
|
-
name = rest.find { |a| !a.start_with?("--") }
|
|
615
|
-
require "json"
|
|
616
|
-
if name
|
|
617
|
-
st = Samagotchi::MemoryBundle::Status.bundle_status(name)
|
|
618
|
-
unless st
|
|
619
|
-
puts "Bundle '#{name}' not installed."
|
|
620
|
-
exit 0
|
|
621
|
-
end
|
|
622
|
-
puts "Bundle: #{name} v#{st[:provenance][:version]} scope=#{st[:scope]} installed=#{st[:provenance][:installed_at]}"
|
|
623
|
-
puts "Target: #{st[:target_dir]}"
|
|
624
|
-
st[:files].each do |k, info|
|
|
625
|
-
mods = []
|
|
626
|
-
mods << "conflict (kept your edits over v#{st[:provenance][:version]}: chi bundle diff #{name} #{k})" if info[:conflict]
|
|
627
|
-
mods << "modified" if info[:modified]
|
|
628
|
-
mods << "missing" if info[:missing]
|
|
629
|
-
mods << "no-index" unless info[:index_present]
|
|
630
|
-
label = mods.empty? ? "ok" : mods.join(",")
|
|
631
|
-
puts " #{k}: #{label}"
|
|
632
|
-
end
|
|
633
|
-
# Hooks
|
|
634
|
-
hooks = st[:provenance][:hooks] || {}
|
|
635
|
-
trust = st[:provenance][:trust_level] || "experimental"
|
|
636
|
-
commit = st[:provenance][:source_commit]
|
|
637
|
-
puts " trust_level: #{trust}" if hooks.any? || trust
|
|
638
|
-
puts " source_commit: #{commit}" if commit
|
|
639
|
-
if hooks.any?
|
|
640
|
-
puts " Hooks (#{hooks.size}):"
|
|
641
|
-
hooks.each do |hk, meta|
|
|
642
|
-
m = meta.is_a?(Hash) ? meta.transform_keys(&:to_s) : {}
|
|
643
|
-
event = m["event"] || ""
|
|
644
|
-
on_error = m["on_error"] || "skip"
|
|
645
|
-
prio = m["priority"] || 100
|
|
646
|
-
hook_path = File.join(Samagotchi::MemoryBundle::Provenance.new(name: name).hooks_dir, hk.to_s)
|
|
647
|
-
exists = File.exist?(hook_path) ? "ok" : "missing"
|
|
648
|
-
puts " #{hk}: event=#{event} on_error=#{on_error} priority=#{prio} [#{exists}]"
|
|
649
|
-
end
|
|
650
|
-
end
|
|
651
|
-
if (plugin = st[:plugin])
|
|
652
|
-
note = plugin[:state] == "modified" ? " (edited after install: not loaded; reinstall the bundle)" : ""
|
|
653
|
-
puts " Plugin: #{plugin[:file]} [#{plugin[:state]}]#{note}"
|
|
654
|
-
puts " requires_chi: #{plugin[:requires_chi]}" if plugin[:requires_chi]
|
|
655
|
-
puts " not loaded: #{plugin[:requires_failure]}" if plugin[:requires_failure]
|
|
656
|
-
end
|
|
657
|
-
st[:needs].each { |need| puts " #{Samagotchi::MemoryBundle::Status.need_line(need)}" }
|
|
658
|
-
else
|
|
659
|
-
bundles_dir = Samagotchi::MemoryBundle::Provenance.bundles_dir
|
|
660
|
-
unless File.directory?(bundles_dir)
|
|
661
|
-
puts "No installed bundles."; exit 0
|
|
662
|
-
end
|
|
663
|
-
entries = Dir.entries(bundles_dir).reject { |e| e.start_with?(".") }
|
|
664
|
-
if entries.empty?
|
|
665
|
-
puts "No installed bundles."
|
|
666
|
-
else
|
|
667
|
-
entries.sort.each do |bname|
|
|
668
|
-
st = Samagotchi::MemoryBundle::Status.bundle_status(bname)
|
|
669
|
-
next unless st
|
|
670
|
-
mods = st[:files].values.count { |v| v[:modified] || v[:missing] }
|
|
671
|
-
hooks_count = (st[:provenance][:hooks] || {}).size
|
|
672
|
-
hook_info = hooks_count > 0 ? " hooks=#{hooks_count}" : ""
|
|
673
|
-
plugin = st[:plugin]
|
|
674
|
-
mods += 1 if plugin && (plugin[:state] != "ok" || plugin[:requires_failure])
|
|
675
|
-
plugin_info = plugin ? " plugin=#{plugin[:file]}" : ""
|
|
676
|
-
puts " #{bname} v#{st[:provenance][:version]} scope=#{st[:scope]} files=#{st[:files].size}#{hook_info}#{plugin_info} issues=#{mods}"
|
|
677
|
-
end
|
|
678
|
-
end
|
|
679
|
-
end
|
|
680
|
-
exit 0
|
|
681
|
-
|
|
682
|
-
when "diff"
|
|
683
|
-
rest = bundle_args[1..]
|
|
684
|
-
args = rest.reject { |a| a.start_with?("--") }
|
|
685
|
-
bname = args[0]
|
|
686
|
-
file_arg = args[1]
|
|
687
|
-
if bname.nil? || bname.empty?
|
|
688
|
-
warn "Usage: chi bundle diff <bundle> [file]"; exit 1
|
|
689
|
-
end
|
|
690
|
-
prov = Samagotchi::MemoryBundle::Provenance.new(name: bname)
|
|
691
|
-
data = prov.read
|
|
692
|
-
unless data
|
|
693
|
-
warn "Bundle '#{bname}' not installed"; exit 1
|
|
694
|
-
end
|
|
695
|
-
scope = data[:scope]&.to_s || "system"
|
|
696
|
-
target_dir = Samagotchi::MemoryBundle::Installer.new(source: ".", name: bname, scope: scope).send(:resolve_target_dir, scope)
|
|
697
|
-
files = data[:files] || {}
|
|
698
|
-
hooks = data[:hooks] || {}
|
|
699
|
-
plugin_path = prov.plugin_path(data)
|
|
700
|
-
plugin_file = plugin_path && File.basename(plugin_path)
|
|
701
|
-
show_plugin = lambda do
|
|
702
|
-
puts "=== plugin/#{plugin_file} ==="
|
|
703
|
-
puts "--- base (provenance) ---"
|
|
704
|
-
base = prov.plugin_base_path(plugin_file)
|
|
705
|
-
puts File.exist?(base) ? File.read(base) : "(no base)"
|
|
706
|
-
puts "--- current (on-disk) ---"
|
|
707
|
-
puts File.exist?(plugin_path) ? File.read(plugin_path) : "(missing)"
|
|
708
|
-
puts "--- metadata: sha256=#{data[:plugin][:sha256]}#{data[:requires_chi] ? " requires_chi=#{data[:requires_chi]}" : ""}"
|
|
709
|
-
end
|
|
710
|
-
if file_arg
|
|
711
|
-
# Try file first, then hook
|
|
712
|
-
if files.key?(file_arg.to_sym) || files.key?(file_arg)
|
|
713
|
-
base = prov.base_path(file_arg)
|
|
714
|
-
cur = File.join(target_dir, file_arg)
|
|
715
|
-
puts "=== #{file_arg} ==="
|
|
716
|
-
puts "--- base (provenance) ---"
|
|
717
|
-
puts File.exist?(base) ? File.read(base) : "(no base)"
|
|
718
|
-
puts "--- current (on-disk) ---"
|
|
719
|
-
puts File.exist?(cur) ? File.read(cur) : "(missing)"
|
|
720
|
-
elsif hooks.key?(file_arg.to_sym) || hooks.key?(file_arg)
|
|
721
|
-
base = prov.base_path(file_arg)
|
|
722
|
-
cur = File.join(prov.hooks_dir, file_arg)
|
|
723
|
-
puts "=== hooks/#{file_arg} ==="
|
|
724
|
-
puts "--- base (provenance) ---"
|
|
725
|
-
puts File.exist?(base) ? File.read(base) : "(no base)"
|
|
726
|
-
puts "--- current (on-disk) ---"
|
|
727
|
-
puts File.exist?(cur) ? File.read(cur) : "(missing)"
|
|
728
|
-
# Show hook metadata
|
|
729
|
-
meta = hooks[file_arg.to_sym] || hooks[file_arg]
|
|
730
|
-
if meta
|
|
731
|
-
m = meta.transform_keys(&:to_s)
|
|
732
|
-
puts "--- metadata: event=#{m['event']} on_error=#{m['on_error']} priority=#{m['priority']} sha256=#{m['sha256']}"
|
|
733
|
-
end
|
|
734
|
-
elsif plugin_file && [plugin_file, "plugin/#{plugin_file}"].include?(file_arg)
|
|
735
|
-
show_plugin.call
|
|
736
|
-
else
|
|
737
|
-
puts "=== #{file_arg} ==="
|
|
738
|
-
puts "(not found in bundle)"
|
|
739
|
-
end
|
|
740
|
-
puts ""
|
|
741
|
-
else
|
|
742
|
-
keys = files.keys.map(&:to_s)
|
|
743
|
-
keys.each do |k|
|
|
744
|
-
base = prov.base_path(k)
|
|
745
|
-
cur = File.join(target_dir, k)
|
|
746
|
-
puts "=== #{k} ==="
|
|
747
|
-
puts "--- base (provenance) ---"
|
|
748
|
-
puts File.exist?(base) ? File.read(base) : "(no base)"
|
|
749
|
-
puts "--- current (on-disk) ---"
|
|
750
|
-
puts File.exist?(cur) ? File.read(cur) : "(missing)"
|
|
751
|
-
puts ""
|
|
752
|
-
end
|
|
753
|
-
# Hooks diff
|
|
754
|
-
hooks.keys.map(&:to_s).each do |k|
|
|
755
|
-
base = prov.base_path(k)
|
|
756
|
-
cur = File.join(prov.hooks_dir, k)
|
|
757
|
-
puts "=== hooks/#{k} ==="
|
|
758
|
-
puts "--- base (provenance) ---"
|
|
759
|
-
puts File.exist?(base) ? File.read(base) : "(no base)"
|
|
760
|
-
puts "--- current (on-disk) ---"
|
|
761
|
-
puts File.exist?(cur) ? File.read(cur) : "(missing)"
|
|
762
|
-
meta = hooks[k.to_sym] || hooks[k]
|
|
763
|
-
if meta
|
|
764
|
-
m = meta.transform_keys(&:to_s)
|
|
765
|
-
puts "--- metadata: event=#{m['event']} on_error=#{m['on_error']} priority=#{m['priority']} sha256=#{m['sha256']}"
|
|
766
|
-
end
|
|
767
|
-
puts ""
|
|
768
|
-
end
|
|
769
|
-
if plugin_file
|
|
770
|
-
show_plugin.call
|
|
771
|
-
puts ""
|
|
772
|
-
end
|
|
773
|
-
end
|
|
774
|
-
exit 0
|
|
775
|
-
|
|
776
|
-
when "list"
|
|
777
|
-
listing = Samagotchi::MemoryBundle::Listing
|
|
778
|
-
shipped = listing.shipped
|
|
779
|
-
installed = listing.installed(shipped: shipped)
|
|
780
|
-
available = listing.available(shipped: shipped, installed: installed)
|
|
781
|
-
|
|
782
|
-
# One column width for both sections, so the names line up.
|
|
783
|
-
name_w = (installed.map { |b| b.name.length } + available.map { |s| s.source.length }).max.to_i
|
|
784
|
-
ver_w = (installed.map { |b| (b.version || "?").to_s.length } + available.map { |s| s.version.length }).max.to_i + 1
|
|
785
|
-
|
|
786
|
-
if installed.empty?
|
|
787
|
-
puts "No installed bundles."
|
|
788
|
-
else
|
|
789
|
-
puts "Installed:"
|
|
790
|
-
installed.each do |b|
|
|
791
|
-
next puts(" #{b.name.ljust(name_w)} (#{b.error})") if b.error
|
|
792
|
-
line = " #{b.name.ljust(name_w)} #{"v#{b.version || "?"}".ljust(ver_w)} scope=#{b.scope || "?"} files=#{b.files} installed=#{b.installed_at || "?"}"
|
|
793
|
-
line += " (shipped v#{b.upgrade.version}: chi bundle upgrade #{b.upgrade.source})" if b.upgrade
|
|
794
|
-
puts line
|
|
795
|
-
end
|
|
796
|
-
puts " (or all at once: chi update)" if installed.any?(&:upgrade)
|
|
797
|
-
end
|
|
798
|
-
unless available.empty?
|
|
799
|
-
puts ""
|
|
800
|
-
puts "Available (shipped with chi, install with: chi bundle install <name>):"
|
|
801
|
-
available.each do |s|
|
|
802
|
-
puts " #{s.source.ljust(name_w)} #{"v#{s.version}".ljust(ver_w)} #{s.description}".rstrip
|
|
803
|
-
end
|
|
804
|
-
end
|
|
805
|
-
|
|
806
|
-
when "build"
|
|
807
|
-
rest = bundle_args[1..]
|
|
808
|
-
scope = nil
|
|
809
|
-
name = nil
|
|
810
|
-
version = nil
|
|
811
|
-
description = ""
|
|
812
|
-
out = nil
|
|
813
|
-
filter_files = []
|
|
814
|
-
i = 0
|
|
815
|
-
while i < rest.size
|
|
816
|
-
arg = rest[i]
|
|
817
|
-
if arg.start_with?("--")
|
|
818
|
-
nxt = rest[i + 1]
|
|
819
|
-
if %w[-h --help help].include?(arg)
|
|
820
|
-
puts "Usage: chi bundle build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]"
|
|
821
|
-
puts ""
|
|
822
|
-
puts " build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]"
|
|
823
|
-
puts " Build local memories and installed hooks into a shareable bundle (dir or zip)."
|
|
824
|
-
puts " --scope selects source dir (default: system). --out inferred from extension; default <name>.zip."
|
|
825
|
-
puts " FILES... optional allowlist of *.md basenames to include (default: all)."
|
|
826
|
-
puts ""
|
|
827
|
-
puts " Examples:"
|
|
828
|
-
puts " chi bundle build --scope system"
|
|
829
|
-
puts " chi bundle build --scope project --name my-bundle --version 1.0.0 --out bundle.zip"
|
|
830
|
-
puts " chi bundle build --scope system --out ./my-bundle/ identity.md work.md"
|
|
831
|
-
exit 0
|
|
832
|
-
elsif arg == "--scope" && nxt && !nxt.start_with?("--")
|
|
833
|
-
scope = nxt; i += 2
|
|
834
|
-
elsif arg.start_with?("--scope=")
|
|
835
|
-
scope = arg.split("=", 2).last; i += 1
|
|
836
|
-
elsif arg == "--name" && nxt && !nxt.start_with?("--")
|
|
837
|
-
name = nxt; i += 2
|
|
838
|
-
elsif arg.start_with?("--name=")
|
|
839
|
-
name = arg.split("=", 2).last; i += 1
|
|
840
|
-
elsif arg == "--version" && nxt && !nxt.start_with?("--")
|
|
841
|
-
version = nxt; i += 2
|
|
842
|
-
elsif arg.start_with?("--version=")
|
|
843
|
-
version = arg.split("=", 2).last; i += 1
|
|
844
|
-
elsif arg == "--description" && nxt && !nxt.start_with?("--")
|
|
845
|
-
description = nxt; i += 2
|
|
846
|
-
elsif arg.start_with?("--description=")
|
|
847
|
-
description = arg.split("=", 2).last; i += 1
|
|
848
|
-
elsif arg == "--out" && nxt && !nxt.start_with?("--")
|
|
849
|
-
out = nxt; i += 2
|
|
850
|
-
elsif arg.start_with?("--out=")
|
|
851
|
-
out = arg.split("=", 2).last; i += 1
|
|
852
|
-
else
|
|
853
|
-
warn "Unknown bundle build flag: #{arg}"
|
|
854
|
-
exit 1
|
|
855
|
-
end
|
|
856
|
-
else
|
|
857
|
-
filter_files << arg
|
|
858
|
-
i += 1
|
|
859
|
-
end
|
|
860
|
-
end
|
|
861
|
-
# Validate scope if given
|
|
862
|
-
if scope && !%w[system project].include?(scope.to_s.strip.downcase)
|
|
863
|
-
warn "Invalid scope '#{scope}', expected system or project"
|
|
864
|
-
exit 1
|
|
865
|
-
end
|
|
866
|
-
filter_files = nil if filter_files.empty?
|
|
867
|
-
begin
|
|
868
|
-
builder = Samagotchi::MemoryBundle::Builder.new(
|
|
869
|
-
scope: scope,
|
|
870
|
-
name: name,
|
|
871
|
-
version: version,
|
|
872
|
-
description: description,
|
|
873
|
-
out: out,
|
|
874
|
-
files: filter_files
|
|
875
|
-
)
|
|
876
|
-
result = builder.run
|
|
877
|
-
puts "Built #{result[:files].size} file(s) to #{result[:out_path]}"
|
|
878
|
-
puts "Bundle: #{result[:name]} v#{result[:version]} scope=#{result[:scope]}"
|
|
879
|
-
puts "Files: #{result[:files].join(', ')}" unless result[:files].empty?
|
|
880
|
-
unless result[:placeholder_warnings].empty?
|
|
881
|
-
puts "Placeholders:"
|
|
882
|
-
result[:placeholder_warnings].each { |w| puts " #{w}" }
|
|
883
|
-
end
|
|
884
|
-
exit 0
|
|
885
|
-
rescue Samagotchi::MemoryBundle::Builder::BuildError => e
|
|
886
|
-
warn "Build failed: #{e.message}"
|
|
887
|
-
exit 1
|
|
888
|
-
end
|
|
889
|
-
|
|
890
|
-
else
|
|
891
|
-
warn "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build"
|
|
892
|
-
exit 1
|
|
893
|
-
end
|
|
894
|
-
|
|
895
|
-
exit 0
|
|
40
|
+
# Subcommands with their own flags, dispatched before OptionParser.
|
|
41
|
+
SUBCOMMANDS = {
|
|
42
|
+
# list, stop, archive/unarchive, delete, prune, clean (--dry-run, --days, …)
|
|
43
|
+
"sessions" => ["samagotchi/sessions_command", "SessionsCommand"],
|
|
44
|
+
# a context note for sessions (-m, --source, --all)
|
|
45
|
+
"note" => ["samagotchi/note_command", "NoteCommand"],
|
|
46
|
+
# a message into sessions as the user's (-m)
|
|
47
|
+
"send" => ["samagotchi/send_command", "SendCommand"],
|
|
48
|
+
# find a model server and write config.yml (--model, --name)
|
|
49
|
+
"bootstrap" => ["samagotchi/bootstrap_command", "BootstrapCommand"],
|
|
50
|
+
# bring an installed chi up to date
|
|
51
|
+
"update" => ["samagotchi/update_command", "UpdateCommand"],
|
|
52
|
+
# the native "Send to chi" helper
|
|
53
|
+
"desktop" => ["samagotchi/desktop_command", "DesktopCommand"],
|
|
54
|
+
# install, upgrade, uninstall, status, diff, list, build (--scope, --force, …)
|
|
55
|
+
"bundle" => ["samagotchi/bundle_command", "BundleCommand"]
|
|
56
|
+
}.freeze
|
|
57
|
+
if (path, klass = SUBCOMMANDS[ARGV[0]])
|
|
58
|
+
require path
|
|
59
|
+
exit Samagotchi.const_get(klass).new(ARGV[1..] || []).run
|
|
896
60
|
end
|
|
897
61
|
|
|
898
62
|
# `chi scratch`: the run options below, for a session that leaves nothing
|
|
@@ -901,7 +65,7 @@ scratch = ARGV[0] == "scratch"
|
|
|
901
65
|
ARGV.shift if scratch
|
|
902
66
|
|
|
903
67
|
# no_interrupt / no_default_input start from config.yml or the env; the flags below turn them on.
|
|
904
|
-
options = { verbose: false, no_interrupt: Samagotchi::Config.get("no_interrupt"), non_interactive: false, no_default_input: Samagotchi::Config.get("no_default_input"), web: false, web_port: nil, web_open: false, web_markdown: nil
|
|
68
|
+
options = { verbose: false, no_interrupt: Samagotchi::Config.get("no_interrupt"), non_interactive: false, no_default_input: Samagotchi::Config.get("no_default_input"), web: false, web_port: nil, web_open: false, web_markdown: nil }
|
|
905
69
|
cli_overrides = {}
|
|
906
70
|
|
|
907
71
|
# Strict kebab enforcement: --recap_base_url is unknown (OptionParser would otherwise accept _ as -)
|
|
@@ -1028,31 +192,8 @@ Samagotchi::Config.reload!(cli_overrides: cli_overrides) unless cli_overrides.em
|
|
|
1028
192
|
require "samagotchi/log"
|
|
1029
193
|
Samagotchi::Log.configure(level: options[:verbose] ? :debug : nil, mirror: options[:verbose])
|
|
1030
194
|
|
|
1031
|
-
#
|
|
1032
|
-
|
|
1033
|
-
case ARGV[0]
|
|
1034
|
-
when "web"
|
|
1035
|
-
options[:web] = true
|
|
1036
|
-
# consume subcommand and re-parse web-specific flags that may follow it
|
|
1037
|
-
web_args.shift
|
|
1038
|
-
web_args.each_with_index do |arg, i|
|
|
1039
|
-
if arg == "--port" && web_args[i + 1]
|
|
1040
|
-
options[:web_port] = web_args[i + 1].to_i
|
|
1041
|
-
elsif arg == "--open"
|
|
1042
|
-
options[:web_open] = true
|
|
1043
|
-
elsif arg == "--web-markdown"
|
|
1044
|
-
options[:web_markdown] = true
|
|
1045
|
-
elsif arg == "--no-web-markdown"
|
|
1046
|
-
options[:web_markdown] = false
|
|
1047
|
-
elsif arg == "--web-view" && Samagotchi::Config::BY_KEY["web.view"].enum_values.include?(web_args[i + 1])
|
|
1048
|
-
options[:web_view] = web_args[i + 1]
|
|
1049
|
-
elsif arg.start_with?("--web-view=") && Samagotchi::Config::BY_KEY["web.view"].enum_values.include?(arg.split("=", 2).last)
|
|
1050
|
-
options[:web_view] = arg.split("=", 2).last
|
|
1051
|
-
elsif arg.start_with?("--port=")
|
|
1052
|
-
options[:web_port] = arg.split("=", 2).last.to_i
|
|
1053
|
-
end
|
|
1054
|
-
end
|
|
1055
|
-
end
|
|
195
|
+
# `chi web`: its flags (--port, --open, --web-view…) went through the parser above.
|
|
196
|
+
options[:web] = true if web
|
|
1056
197
|
|
|
1057
198
|
# The first interactive start of a new installed chi says what `chi update` would update.
|
|
1058
199
|
chi_update_hint = lambda do
|
|
@@ -1070,7 +211,7 @@ if options[:web]
|
|
|
1070
211
|
port = options[:web_port] || Samagotchi::Config.get("web.port")
|
|
1071
212
|
markdown = options[:web_markdown]
|
|
1072
213
|
markdown = Samagotchi::Config.get("web.markdown") if markdown.nil?
|
|
1073
|
-
view =
|
|
214
|
+
view = Samagotchi::Config.get("web.view")
|
|
1074
215
|
exit Samagotchi::Web::Server.launch(port: port, scope: options[:scope] || "project", open_browser: options[:web_open],
|
|
1075
216
|
markdown: markdown, view: view, new_token: options[:new_token],
|
|
1076
217
|
annotate_presets: Samagotchi::Config.get("web.annotate_presets"))
|