samagotchi 0.5.1 → 0.7.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -1
  3. data/bin/chi +32 -891
  4. data/docs/architecture.md +8 -1
  5. data/docs/cli.md +12 -13
  6. data/docs/configuration.md +24 -1
  7. data/docs/desktop.md +52 -1
  8. data/docs/hooks.md +69 -4
  9. data/docs/plugins.md +98 -3
  10. data/docs/sessions.md +3 -3
  11. data/lib/samagotchi/bridge/card_store.rb +30 -5
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
  13. data/lib/samagotchi/bridge/turn_notice.rb +37 -0
  14. data/lib/samagotchi/bundle_command.rb +652 -0
  15. data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
  16. data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
  18. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  19. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  20. data/lib/samagotchi/cancellation_controller.rb +42 -1
  21. data/lib/samagotchi/config.rb +7 -2
  22. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  23. data/lib/samagotchi/desktop/macos/ChiRunner.swift +27 -68
  24. data/lib/samagotchi/desktop/macos/Images.swift +38 -3
  25. data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
  26. data/lib/samagotchi/desktop/macos/Panel.swift +248 -55
  27. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
  28. data/lib/samagotchi/desktop/macos.rb +23 -8
  29. data/lib/samagotchi/desktop_command.rb +1 -1
  30. data/lib/samagotchi/engine.rb +138 -618
  31. data/lib/samagotchi/guardrail_wiring.rb +238 -0
  32. data/lib/samagotchi/hooks/registry.rb +11 -3
  33. data/lib/samagotchi/hooks/stream_watch.rb +93 -0
  34. data/lib/samagotchi/hooks.rb +3 -0
  35. data/lib/samagotchi/kernel_loop.rb +65 -6
  36. data/lib/samagotchi/llm/chat_loop.rb +73 -11
  37. data/lib/samagotchi/llm/openai_chat.rb +4 -2
  38. data/lib/samagotchi/log_subscriber.rb +10 -4
  39. data/lib/samagotchi/plugin/api.rb +8 -0
  40. data/lib/samagotchi/plugin/context.rb +12 -3
  41. data/lib/samagotchi/plugin_tasks.rb +229 -0
  42. data/lib/samagotchi/question_desk.rb +280 -0
  43. data/lib/samagotchi/recap_setup.rb +128 -0
  44. data/lib/samagotchi/session.rb +9 -1
  45. data/lib/samagotchi/sessions_command.rb +255 -0
  46. data/lib/samagotchi/steer.rb +19 -2
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
  48. data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
  49. data/lib/samagotchi/tool_declarations.rb +2 -2
  50. data/lib/samagotchi/tools/delegate.rb +4 -1
  51. data/lib/samagotchi/turn_note.rb +20 -1
  52. data/lib/samagotchi/update_command.rb +1 -1
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +12 -8
  55. data/lib/samagotchi/web/public/app.js +94 -52
  56. data/lib/samagotchi/web/public/card.js +8 -1
  57. data/lib/samagotchi/web/public/format.js +1 -1
  58. data/lib/samagotchi/web/public/index.html +2 -5
  59. data/lib/samagotchi/web/public/stage_model.js +44 -0
  60. data/lib/samagotchi/web/public/stage_view.js +26 -17
  61. data/lib/samagotchi/web/public/timing.js +9 -3
  62. data/lib/samagotchi/web/public/turn_events.js +18 -5
  63. data/lib/samagotchi/web/public/turn_view.js +68 -16
  64. data/lib/samagotchi/web/server.rb +4 -3
  65. metadata +11 -2
  66. 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
- # Early handle `sessions` subcommand before OptionParser (has its own flags like --dry-run)
34
- if ARGV[0] == "sessions"
35
- require "samagotchi/session"
36
- require "samagotchi/session_manager"
37
- require "samagotchi/project_scope"
38
- sessions_args = ARGV[1..] || []
39
- sub = sessions_args[0]
40
- if sub.nil? || %w[-h --help help].include?(sub)
41
- puts "Usage: chi sessions <list|stop|archive|unarchive|delete|prune|clean> [options]"
42
- puts " list [--sort updated_at|created_at] [--order desc|asc] [--limit N]"
43
- puts " [--live] [--cwd PATH] [--format text|json|tsv] [--archived]"
44
- puts " --live: sessions a worker runs now (the ones chi note reaches), 10 unless --limit"
45
- puts " --cwd PATH: sessions in PATH or below; json/tsv (id<TAB>description) are for scripts"
46
- puts " [--scope=all]: every project's sessions; by default only this git project's (all outside a repo)"
47
- puts " --archived: archived sessions too (marked [archived]; json: archived: true)"
48
- puts " stop ID... # stop each session's worker (IDs or unique prefixes); chi --resume ID then starts a fresh one"
49
- puts " archive ID... # hide sessions (and their delegates) from every list and keep them for good; unarchive ID... brings them back"
50
- puts " delete [--force] ID... # delete sessions for good (IDs or unique prefixes); --force stops a live worker first"
51
- puts " prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]"
52
- puts " clean [--dry-run] [--days N] # test sessions (SAMAGOTCHI_ENV=test, CI) and leftover chi scratch ones: all of them, or those older than N days"
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, web_view: 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
- # Handle subcommands (support `chi web --port 4567 --open --web-markdown`)
1032
- web_args = ARGV.dup
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 = options[:web_view] || Samagotchi::Config.get("web.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"))