samagotchi 0.21.0 → 0.23.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 (161) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/bin/chi +9 -4
  4. data/docs/architecture.md +3 -2
  5. data/docs/configuration.md +15 -4
  6. data/docs/releasing.md +1 -1
  7. data/docs/sessions.md +1 -1
  8. data/docs/sub-agent.md +11 -5
  9. data/docs/testing.md +6 -0
  10. data/lib/samagotchi/answer_command.rb +11 -5
  11. data/lib/samagotchi/bootstrap/config_writer.rb +2 -1
  12. data/lib/samagotchi/bootstrap/probe.rb +1 -1
  13. data/lib/samagotchi/bootstrap_command.rb +2 -2
  14. data/lib/samagotchi/bridge/bounded_queue.rb +0 -2
  15. data/lib/samagotchi/bridge/sse_writer.rb +0 -6
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -2
  17. data/lib/samagotchi/bridge.rb +12 -9
  18. data/lib/samagotchi/bridge_client/event_stream.rb +2 -2
  19. data/lib/samagotchi/bridge_client.rb +1 -1
  20. data/lib/samagotchi/bundle_command.rb +39 -29
  21. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/btw/plugin.rb +2 -0
  23. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/check-in/plugin.rb +2 -0
  25. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +6 -5
  26. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/loop-guard/plugin.rb +2 -0
  29. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -0
  31. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/skills/plugin.rb +5 -3
  33. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -7
  34. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/cancellation_controller.rb +1 -1
  38. data/lib/samagotchi/cli/command.rb +1 -1
  39. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  40. data/lib/samagotchi/client.rb +3 -5
  41. data/lib/samagotchi/commands/registry.rb +2 -2
  42. data/lib/samagotchi/config.rb +38 -14
  43. data/lib/samagotchi/context_note.rb +1 -1
  44. data/lib/samagotchi/context_status.rb +6 -6
  45. data/lib/samagotchi/continue_offer.rb +44 -12
  46. data/lib/samagotchi/debug_log.rb +1 -1
  47. data/lib/samagotchi/desktop/macos.rb +5 -4
  48. data/lib/samagotchi/empty_answer_retry.rb +1 -1
  49. data/lib/samagotchi/engine.rb +49 -19
  50. data/lib/samagotchi/gem_update.rb +2 -1
  51. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  52. data/lib/samagotchi/guardrails/approval.rb +5 -5
  53. data/lib/samagotchi/guardrails/approvals.rb +2 -2
  54. data/lib/samagotchi/guardrails/gate.rb +2 -2
  55. data/lib/samagotchi/guardrails/read_only_shell.rb +5 -3
  56. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  57. data/lib/samagotchi/guardrails/shell_lex.rb +2 -2
  58. data/lib/samagotchi/guardrails/shell_paths.rb +2 -2
  59. data/lib/samagotchi/hooks/bundle_loader.rb +9 -6
  60. data/lib/samagotchi/hooks/loader.rb +18 -16
  61. data/lib/samagotchi/hooks/registry.rb +10 -2
  62. data/lib/samagotchi/host_registry.rb +1 -3
  63. data/lib/samagotchi/idle_recap.rb +18 -15
  64. data/lib/samagotchi/idle_reminders.rb +6 -3
  65. data/lib/samagotchi/image_store.rb +5 -2
  66. data/lib/samagotchi/kernel_loop.rb +31 -31
  67. data/lib/samagotchi/llm/chat_loop.rb +9 -6
  68. data/lib/samagotchi/llm/errors.rb +33 -2
  69. data/lib/samagotchi/llm/http.rb +12 -12
  70. data/lib/samagotchi/llm/openai_chat.rb +53 -30
  71. data/lib/samagotchi/llm/prompt_cache.rb +83 -0
  72. data/lib/samagotchi/log.rb +2 -2
  73. data/lib/samagotchi/log_subscriber.rb +2 -1
  74. data/lib/samagotchi/memory_bundle/builder.rb +9 -3
  75. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -1
  76. data/lib/samagotchi/memory_bundle/installer.rb +67 -57
  77. data/lib/samagotchi/memory_bundle/listing.rb +5 -1
  78. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  79. data/lib/samagotchi/memory_bundle/merger.rb +6 -4
  80. data/lib/samagotchi/memory_bundle/provenance.rb +16 -13
  81. data/lib/samagotchi/memory_bundle/source.rb +7 -3
  82. data/lib/samagotchi/memory_bundle/status.rb +3 -1
  83. data/lib/samagotchi/memory_bundle/system_bundle.rb +3 -2
  84. data/lib/samagotchi/memory_bundle/trash.rb +5 -2
  85. data/lib/samagotchi/memory_bundle/uninstaller.rb +9 -6
  86. data/lib/samagotchi/memory_bundle.rb +1 -0
  87. data/lib/samagotchi/model_overlay.rb +3 -5
  88. data/lib/samagotchi/model_profile.rb +12 -8
  89. data/lib/samagotchi/note_command.rb +2 -1
  90. data/lib/samagotchi/output_formatter.rb +18 -17
  91. data/lib/samagotchi/owner_lock.rb +2 -1
  92. data/lib/samagotchi/parent_report.rb +24 -7
  93. data/lib/samagotchi/plugin/api.rb +8 -5
  94. data/lib/samagotchi/plugin/context.rb +1 -3
  95. data/lib/samagotchi/plugin/loader.rb +1 -1
  96. data/lib/samagotchi/plugin/service.rb +1 -1
  97. data/lib/samagotchi/plugin/sessions.rb +1 -0
  98. data/lib/samagotchi/plugin_tasks.rb +1 -1
  99. data/lib/samagotchi/prompt.rb +68 -12
  100. data/lib/samagotchi/prompt_literal_guard.rb +1 -1
  101. data/lib/samagotchi/question_desk.rb +7 -13
  102. data/lib/samagotchi/recap_setup.rb +1 -1
  103. data/lib/samagotchi/relay_desk.rb +1 -1
  104. data/lib/samagotchi/reminder_store.rb +2 -1
  105. data/lib/samagotchi/reply_wait.rb +12 -2
  106. data/lib/samagotchi/send_command.rb +4 -2
  107. data/lib/samagotchi/session.rb +2 -2
  108. data/lib/samagotchi/session_archive_command.rb +5 -1
  109. data/lib/samagotchi/session_commands.rb +1 -3
  110. data/lib/samagotchi/session_manager.rb +2 -4
  111. data/lib/samagotchi/session_metrics.rb +10 -10
  112. data/lib/samagotchi/session_observer.rb +3 -2
  113. data/lib/samagotchi/session_retention.rb +2 -2
  114. data/lib/samagotchi/sessions_command.rb +14 -5
  115. data/lib/samagotchi/system_prompt.rb +19 -8
  116. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  117. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  118. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -2
  119. data/lib/samagotchi/terminal_ui/formatting.rb +10 -10
  120. data/lib/samagotchi/terminal_ui/input_support.rb +2 -2
  121. data/lib/samagotchi/terminal_ui/line_reader.rb +3 -3
  122. data/lib/samagotchi/terminal_ui/question_prompt.rb +23 -23
  123. data/lib/samagotchi/terminal_ui/question_slot.rb +10 -10
  124. data/lib/samagotchi/terminal_ui/reline_seam.rb +1 -1
  125. data/lib/samagotchi/terminal_ui/repl_input.rb +3 -2
  126. data/lib/samagotchi/terminal_ui.rb +9 -5
  127. data/lib/samagotchi/text_diff.rb +12 -12
  128. data/lib/samagotchi/thinking.rb +6 -2
  129. data/lib/samagotchi/thought_stream_splitter.rb +6 -3
  130. data/lib/samagotchi/tool_activity.rb +1 -0
  131. data/lib/samagotchi/tool_call_parser.rb +13 -14
  132. data/lib/samagotchi/tool_declarations.rb +36 -35
  133. data/lib/samagotchi/tools/ask_user_question.rb +3 -4
  134. data/lib/samagotchi/tools/builtin_calls.rb +1 -1
  135. data/lib/samagotchi/tools/delegate.rb +1 -1
  136. data/lib/samagotchi/tools/delegate_relay.rb +1 -1
  137. data/lib/samagotchi/tools/delegate_wait.rb +0 -1
  138. data/lib/samagotchi/tools/edit.rb +14 -13
  139. data/lib/samagotchi/tools/execute.rb +3 -5
  140. data/lib/samagotchi/tools/list_reminders.rb +1 -1
  141. data/lib/samagotchi/tools/memory.rb +21 -22
  142. data/lib/samagotchi/tools/read.rb +7 -7
  143. data/lib/samagotchi/tools/task_create.rb +1 -1
  144. data/lib/samagotchi/tools/task_get.rb +1 -1
  145. data/lib/samagotchi/tools/task_list.rb +1 -1
  146. data/lib/samagotchi/tools/task_runtime.rb +8 -9
  147. data/lib/samagotchi/tools/task_stop.rb +1 -1
  148. data/lib/samagotchi/tools/task_wait.rb +3 -4
  149. data/lib/samagotchi/tools/web_fetch.rb +2 -4
  150. data/lib/samagotchi/tools/write.rb +1 -1
  151. data/lib/samagotchi/turn_flow.rb +8 -8
  152. data/lib/samagotchi/turn_note.rb +5 -1
  153. data/lib/samagotchi/turn_state.rb +22 -0
  154. data/lib/samagotchi/version.rb +1 -1
  155. data/lib/samagotchi/vision_support.rb +2 -2
  156. data/lib/samagotchi/web/app.rb +13 -6
  157. data/lib/samagotchi/web/markdown_renderer.rb +2 -1
  158. data/lib/samagotchi/web/qr.rb +7 -1
  159. data/lib/samagotchi/web/server.rb +6 -2
  160. data/lib/samagotchi/worker.rb +10 -2
  161. metadata +21 -20
@@ -80,11 +80,9 @@ module Samagotchi
80
80
  rescue SourceNormalizer::UnknownSourceError => e
81
81
  raise InstallError, "Source normalization failed: #{e.message}"
82
82
  rescue Manifest::ValidationError => e
83
- if @strict
84
- raise InstallError, e.message
85
- else
86
- @warnings << "No manifest found — proceeding without strict manifest validation"
87
- end
83
+ raise InstallError, e.message if @strict
84
+
85
+ @warnings << "No manifest found — proceeding without strict manifest validation"
88
86
  end
89
87
 
90
88
  # Determine target scope (CLI wins over manifest).
@@ -185,8 +183,9 @@ module Samagotchi
185
183
  end
186
184
  end
187
185
 
188
- hook_entries.each do |basename, meta|
186
+ hook_entries.each do |basename, _meta|
189
187
  next unless basename.is_a?(String) && !basename.empty?
188
+
190
189
  all_hooks_in_bundle << basename
191
190
  src = File.join(normalized_dir, "hooks", basename)
192
191
  next unless File.exist?(src)
@@ -194,11 +193,11 @@ module Samagotchi
194
193
  dest = File.join(hooks_target, basename)
195
194
 
196
195
  if @dry_run
197
- if File.exist?(dest) && !@force
198
- @results[basename] = { status: "would_skip" }
199
- else
200
- @results[basename] = { status: "would_install" }
201
- end
196
+ @results[basename] = if File.exist?(dest) && !@force
197
+ { status: "would_skip" }
198
+ else
199
+ { status: "would_install" }
200
+ end
202
201
  next
203
202
  end
204
203
 
@@ -207,10 +206,8 @@ module Samagotchi
207
206
  prev_meta = existing_provenance[:hooks] ? (existing_provenance[:hooks][basename.to_sym] || existing_provenance[:hooks][basename]) : nil
208
207
  if prev_meta
209
208
  prev_sha = prev_meta[:sha256] || prev_meta["sha256"]
210
- if File.exist?(dest)
211
- if !Provenance.recorded_sha(prev_sha).empty? && !Provenance.sha_matches?(dest, prev_sha)
212
- @warnings << "Hook #{basename} was locally modified; overwriting"
213
- end
209
+ if File.exist?(dest) && !Provenance.recorded_sha(prev_sha).empty? && !Provenance.sha_matches?(dest, prev_sha)
210
+ @warnings << "Hook #{basename} was locally modified; overwriting"
214
211
  end
215
212
  end
216
213
  end
@@ -249,11 +246,12 @@ module Samagotchi
249
246
  existing_provenance[:hooks].keys.each do |old_key|
250
247
  old_key_str = old_key.to_s
251
248
  next if all_hooks_in_bundle.include?(old_key_str)
249
+
252
250
  old_dest = File.join(hooks_target, old_key_str)
253
251
  if File.exist?(old_dest) && !@force
254
252
  @warnings << "Bundle no longer includes hook #{old_key_str} but local file exists — removing"
255
253
  end
256
- FileUtils.rm_f(old_dest) if File.exist?(old_dest)
254
+ FileUtils.rm_f(old_dest)
257
255
  end
258
256
  end
259
257
 
@@ -261,14 +259,16 @@ module Samagotchi
261
259
  if manifest && @strict
262
260
  all_hooks_in_bundle.each do |basename|
263
261
  next if @conflicts.key?(basename)
262
+
264
263
  expected = manifest.checksum_for_hook(basename)
265
- if expected
266
- dest = File.join(hooks_target, basename)
267
- next unless File.exist?(dest)
268
- actual = Digest::SHA256.hexdigest(File.read(dest))
269
- if actual != expected
270
- @warnings << "Checksum mismatch for hook #{basename}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
271
- end
264
+ next unless expected
265
+
266
+ dest = File.join(hooks_target, basename)
267
+ next unless File.exist?(dest)
268
+
269
+ actual = Digest::SHA256.hexdigest(File.read(dest))
270
+ if actual != expected
271
+ @warnings << "Checksum mismatch for hook #{basename}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
272
272
  end
273
273
  end
274
274
  end
@@ -313,18 +313,21 @@ module Samagotchi
313
313
  if manifest && @strict
314
314
  all_files_in_bundle.each do |file_key|
315
315
  next if @conflicts.key?(file_key)
316
+
316
317
  status = @results[file_key] ? @results[file_key][:status].to_s : nil
317
318
  # skipped: not copied, so a local edit isn't a bad download.
318
319
  next if %w[kept kept_pruned fast_forward updated skipped].include?(status)
320
+
319
321
  # For dry-run, fast_forward would appear as "fast_forward", updated not yet
320
322
  target_path = File.join(target_dir, file_key)
321
323
  next unless File.exist?(target_path)
324
+
322
325
  expected = manifest.checksum_for(file_key)
323
- if expected
324
- actual = Digest::SHA256.hexdigest(File.read(target_path))
325
- if actual != expected
326
- @warnings << "Checksum mismatch for #{file_key}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
327
- end
326
+ next unless expected
327
+
328
+ actual = Digest::SHA256.hexdigest(File.read(target_path))
329
+ if actual != expected
330
+ @warnings << "Checksum mismatch for #{file_key}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
328
331
  end
329
332
  end
330
333
  end
@@ -342,18 +345,19 @@ module Samagotchi
342
345
  owned.each do |file_key|
343
346
  target_path = File.join(target_dir, file_key)
344
347
  next unless File.exist?(target_path)
348
+
345
349
  status = @results[file_key] ? @results[file_key][:status].to_s : nil
346
350
  if %w[kept noop conflict kept_pruned].include?(status)
347
351
  # Keep old base snapshot — do not overwrite with edited current
348
352
  # Use base snapshot as source if it exists to preserve old checksum
349
353
  base_path = provenance.base_path(file_key)
350
- if File.exist?(base_path)
351
- # Keep existing provenance entry by re-using base content
352
- # We still need to include it to prevent pruning, but with base content
353
- provenance_files[file_key] = base_path
354
- else
355
- provenance_files[file_key] = target_path
356
- end
354
+ provenance_files[file_key] = if File.exist?(base_path)
355
+ # Keep existing provenance entry by re-using base content
356
+ # We still need to include it to prevent pruning, but with base content
357
+ base_path
358
+ else
359
+ target_path
360
+ end
357
361
  else
358
362
  # installed, updated, skipped — use current target (which is incoming for updated)
359
363
  provenance_files[file_key] = target_path
@@ -364,14 +368,15 @@ module Samagotchi
364
368
  existing_provenance[:files].keys.each do |old_key|
365
369
  old_key_str = old_key.to_s
366
370
  next if all_files_in_bundle.include?(old_key_str)
371
+
367
372
  # If it was kept_pruned, include it with base content to prevent pruning
368
- if @results[old_key_str] && @results[old_key_str][:status].to_s == "kept_pruned"
369
- base_path = provenance.base_path(old_key_str)
370
- target_path = File.join(target_dir, old_key_str)
371
- # Use base if exists to keep old checksum, else target
372
- src = File.exist?(base_path) ? base_path : target_path
373
- provenance_files[old_key_str] = src if File.exist?(src)
374
- end
373
+ next unless @results[old_key_str] && @results[old_key_str][:status].to_s == "kept_pruned"
374
+
375
+ base_path = provenance.base_path(old_key_str)
376
+ target_path = File.join(target_dir, old_key_str)
377
+ # Use base if exists to keep old checksum, else target
378
+ src = File.exist?(base_path) ? base_path : target_path
379
+ provenance_files[old_key_str] = src if File.exist?(src)
375
380
  end
376
381
  end
377
382
  else
@@ -393,6 +398,7 @@ module Samagotchi
393
398
  elsif hooks_files_for_provenance.any?
394
399
  hooks_files_for_provenance.each do |basename, path|
395
400
  next unless File.exist?(path)
401
+
396
402
  sha = Digest::SHA256.hexdigest(File.read(path))
397
403
  hooks_for_provenance[basename] = { "sha256" => "sha256:#{sha}", "event" => "", "on_error" => "skip", "priority" => 100 }
398
404
  end
@@ -418,6 +424,7 @@ module Samagotchi
418
424
  all_files_in_bundle.each do |file_key|
419
425
  file_path = File.join(target_dir, file_key)
420
426
  next unless File.exist?(file_path)
427
+
421
428
  names = Placeholder.detect_in_file(file_path)
422
429
  if names.any?
423
430
  @placeholder_warnings << "#{file_key}: {{#{names.join("}}, {{")}}}"
@@ -451,15 +458,15 @@ module Samagotchi
451
458
  removed = @results.select { |_, r| r[:status].to_s == "removed" }.keys
452
459
  would_remove = @results.select { |_, r| r[:status].to_s == "would_remove" }.keys
453
460
 
454
- lines << "Installed: #{installed.join(', ')}" unless installed.empty?
455
- lines << "Updated: #{updated.join(', ')}" unless updated.empty?
456
- lines << "Would install: #{would_install.join(', ')}" unless would_install.empty?
457
- lines << "Fast-forward: #{fast_forward.join(', ')}" unless fast_forward.empty?
458
- lines << "Removed (no longer in the bundle): #{removed.join(', ')}#{trash_dir ? " (moved to #{trash_dir})" : ""}" unless removed.empty?
459
- lines << "Would remove (no longer in the bundle): #{would_remove.join(', ')}" unless would_remove.empty?
460
- lines << "Kept (local edits preserved): #{kept.join(', ')}" unless kept.empty?
461
- lines << "Skipped: #{skipped.join(', ')}" unless skipped.empty?
462
- lines << "Conflicts: #{conflicts.join(', ')}" unless conflicts.empty?
461
+ lines << "Installed: #{installed.join(", ")}" unless installed.empty?
462
+ lines << "Updated: #{updated.join(", ")}" unless updated.empty?
463
+ lines << "Would install: #{would_install.join(", ")}" unless would_install.empty?
464
+ lines << "Fast-forward: #{fast_forward.join(", ")}" unless fast_forward.empty?
465
+ lines << "Removed (no longer in the bundle): #{removed.join(", ")}#{" (moved to #{trash_dir})" if trash_dir}" unless removed.empty?
466
+ lines << "Would remove (no longer in the bundle): #{would_remove.join(", ")}" unless would_remove.empty?
467
+ lines << "Kept (local edits preserved): #{kept.join(", ")}" unless kept.empty?
468
+ lines << "Skipped: #{skipped.join(", ")}" unless skipped.empty?
469
+ lines << "Conflicts: #{conflicts.join(", ")}" unless conflicts.empty?
463
470
  lines.concat(@warnings) unless @warnings.empty?
464
471
  lines.concat(@placeholder_warnings) unless @placeholder_warnings.empty?
465
472
  lines << "#{@name} is already installed; `chi bundle upgrade #{@name}` updates it and keeps local edits" if @reinstall
@@ -494,6 +501,7 @@ module Samagotchi
494
501
  previous_files.each do |old_key, meta|
495
502
  key = old_key.to_s
496
503
  next if bundle_files.include?(key)
504
+
497
505
  target = File.join(target_dir, key)
498
506
  next unless File.exist?(target)
499
507
 
@@ -504,6 +512,7 @@ module Samagotchi
504
512
  elsif @force || installed_unchanged?(target, meta, provenance.base_path(key))
505
513
  @results[key] = { status: @dry_run ? "would_remove" : "removed", reason: "no longer in the bundle" }
506
514
  next if @dry_run
515
+
507
516
  @trash.move(target)
508
517
  remove_target_index(scope, key)
509
518
  else
@@ -518,6 +527,7 @@ module Samagotchi
518
527
  def installed_unchanged?(target, meta, base_path)
519
528
  recorded = meta.is_a?(Hash) ? meta[:checksum] || meta["checksum"] : nil
520
529
  return Provenance.sha_matches?(target, recorded) unless Provenance.recorded_sha(recorded).empty?
530
+
521
531
  File.exist?(base_path) && Provenance.file_sha(target) == Provenance.file_sha(base_path)
522
532
  end
523
533
 
@@ -577,12 +587,12 @@ module Samagotchi
577
587
  FileUtils.mkdir_p(provenance.plugin_dir)
578
588
  FileUtils.cp(src, dest)
579
589
  @results[plugin[:file]] = if unchanged
580
- { status: "skipped", reason: "already up to date" }
581
- elsif @upgrade && provenance.read
582
- { status: "updated" }
583
- else
584
- { status: "installed" }
585
- end
590
+ { status: "skipped", reason: "already up to date" }
591
+ elsif @upgrade && provenance.read
592
+ { status: "updated" }
593
+ else
594
+ { status: "installed" }
595
+ end
586
596
  expected = manifest.checksum_for_plugin
587
597
  actual = Digest::SHA256.hexdigest(File.binread(dest))
588
598
  if @strict && expected && actual != expected
@@ -30,8 +30,10 @@ module Samagotchi
30
30
 
31
31
  Dir.children(dir).sort.filter_map do |source|
32
32
  next unless File.file?(File.join(dir, source, "manifest.yml"))
33
+
33
34
  m = Manifest.read(dir: File.join(dir, source))
34
35
  next if m.name == SystemBundle::BUNDLE_NAME
36
+
35
37
  Shipped.new(name: m.name, source: source, version: m.version, description: m.description, includes: m.includes)
36
38
  rescue Manifest::ValidationError, Psych::Exception
37
39
  nil
@@ -70,8 +72,10 @@ module Samagotchi
70
72
  end
71
73
 
72
74
  def newer?(candidate, current)
73
- candidate, current = candidate.to_s, current.to_s
75
+ candidate = candidate.to_s
76
+ current = current.to_s
74
77
  return false unless Gem::Version.correct?(candidate) && Gem::Version.correct?(current)
78
+
75
79
  Gem::Version.new(candidate) > Gem::Version.new(current)
76
80
  end
77
81
  end
@@ -64,6 +64,7 @@ module Samagotchi
64
64
  def self.read(dir:)
65
65
  manifest_path = File.join(dir, "manifest.yml")
66
66
  raise ValidationError, "manifest.yml not found in #{dir}" unless File.exist?(manifest_path)
67
+
67
68
  new(path: manifest_path)
68
69
  end
69
70
 
@@ -72,6 +73,7 @@ module Samagotchi
72
73
  def checksum_for(file_key)
73
74
  raw = @files[file_key]
74
75
  return nil unless raw
76
+
75
77
  raw.start_with?("sha256:") ? raw[7..] : raw
76
78
  end
77
79
 
@@ -79,8 +81,10 @@ module Samagotchi
79
81
  def checksum_for_hook(basename)
80
82
  raw = @hooks[basename]
81
83
  return nil unless raw
84
+
82
85
  sha = raw[:sha256] || raw["sha256"]
83
86
  return nil unless sha
87
+
84
88
  sha.to_s.start_with?("sha256:") ? sha.to_s[7..] : sha.to_s
85
89
  end
86
90
 
@@ -99,18 +103,18 @@ module Samagotchi
99
103
  return nil if Gem::Requirement.new(*requirement.to_s.split(",").map(&:strip)).satisfied_by?(Gem::Version.new(version.to_s))
100
104
 
101
105
  "it requires chi #{requirement} (this is chi #{version})"
102
- rescue ArgumentError, Gem::Requirement::BadRequirementError => e
106
+ rescue ArgumentError => e # Gem::Requirement::BadRequirementError is one
103
107
  "its requires_chi #{requirement.inspect} is not a version requirement (#{e.message})"
104
108
  end
105
109
 
106
110
  # Writes a fresh manifest.yml from computed checksums at `dir`.
107
- def self.write(dir:, name:, version:, scope: nil, description: "", files:, hooks: nil, trust_level: nil,
111
+ def self.write(dir:, name:, version:, files:, scope: nil, description: "", hooks: nil, trust_level: nil,
108
112
  plugin: nil, requires_chi: nil, needs: nil)
109
113
  FileUtils.mkdir_p(dir)
110
114
  manifest = {
111
115
  "name" => name,
112
116
  "version" => version,
113
- "description" => description,
117
+ "description" => description
114
118
  }
115
119
  manifest["scope"] = scope if scope
116
120
  manifest["files"] = files # { "file.md" => "sha256:abc..." }
@@ -167,19 +171,23 @@ module Samagotchi
167
171
  def require_str(hash, key)
168
172
  val = hash[key]
169
173
  return val if val.is_a?(String) && !val.empty?
174
+
170
175
  raise ValidationError, "manifest missing required field: #{key}"
171
176
  end
172
177
 
173
178
  def normalize_scope(val)
174
179
  return nil if val.nil?
180
+
175
181
  v = val.to_s.strip.downcase
176
182
  %w[project system].include?(v) ? v : nil
177
183
  end
178
184
 
179
185
  def parse_files(raw)
180
186
  return {} unless raw.is_a?(Hash)
187
+
181
188
  raw.each_with_object({}) do |(k, v), acc|
182
189
  next unless k.is_a?(String) && !k.empty?
190
+
183
191
  str = v.to_s
184
192
  acc[k] = if str.start_with?("sha256:")
185
193
  str
@@ -234,11 +242,13 @@ module Samagotchi
234
242
 
235
243
  def parse_hooks(raw)
236
244
  return {} unless raw.is_a?(Hash)
245
+
237
246
  raw.each_with_object({}) do |(k, v), acc|
238
247
  next unless k.is_a?(String) && !k.empty?
239
248
  next unless v.is_a?(Hash)
249
+
240
250
  sha = (v["sha256"] || v[:sha256] || "").to_s
241
- sha = sha.start_with?("sha256:") ? sha : "sha256:#{sha}" unless sha.empty?
251
+ sha = "sha256:#{sha}" unless sha.empty? || sha.start_with?("sha256:")
242
252
  event = (v["event"] || v[:event] || "").to_s.strip
243
253
  on_error = (v["on_error"] || v[:on_error] || "skip").to_s.strip
244
254
  on_error = "skip" if on_error.empty?
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require "digest"
3
4
 
4
5
  module Samagotchi
@@ -33,18 +34,19 @@ module Samagotchi
33
34
  incoming_digest = Digest::SHA256.hexdigest(File.read(incoming_path))
34
35
 
35
36
  if current_digest == incoming_digest
36
- return :noop
37
+ :noop
37
38
  elsif current_digest == base_digest
38
- return :fast_forward
39
+ :fast_forward
39
40
  elsif incoming_digest == base_digest
40
- return :keep
41
+ :keep
41
42
  else
42
- return :conflict
43
+ :conflict
43
44
  end
44
45
  end
45
46
 
46
47
  def self.current_modified?(base_path, current_path)
47
48
  return true unless base_path && File.exist?(base_path) && File.exist?(current_path)
49
+
48
50
  Digest::SHA256.hexdigest(File.read(base_path)) != Digest::SHA256.hexdigest(File.read(current_path))
49
51
  end
50
52
  end
@@ -154,11 +154,11 @@ module Samagotchi
154
154
  if existing && existing[:files]
155
155
  existing[:files].keys.each do |old_key|
156
156
  old_key_str = old_key.to_s
157
- unless files.key?(old_key_str) || files.key?(old_key)
158
- old_base = File.join(bases_dir, old_key_str)
159
- FileUtils.rm_f(old_base) if File.exist?(old_base)
160
- merged_entries.delete(old_key_str)
161
- end
157
+ next if files.key?(old_key_str) || files.key?(old_key)
158
+
159
+ old_base = File.join(bases_dir, old_key_str)
160
+ FileUtils.rm_f(old_base)
161
+ merged_entries.delete(old_key_str)
162
162
  end
163
163
  end
164
164
 
@@ -172,9 +172,10 @@ module Samagotchi
172
172
  hooks.each do |k, v|
173
173
  next unless k.is_a?(String) && !k.empty?
174
174
  next unless v.is_a?(Hash)
175
+
175
176
  hv = v.transform_keys(&:to_sym)
176
177
  sha = (hv[:sha256] || "").to_s
177
- sha = sha.start_with?("sha256:") ? sha : "sha256:#{sha}" unless sha.empty?
178
+ sha = "sha256:#{sha}" unless sha.empty? || sha.start_with?("sha256:")
178
179
  normalized_hooks[k] = {
179
180
  sha256: sha,
180
181
  event: (hv[:event] || "").to_s,
@@ -189,7 +190,7 @@ module Samagotchi
189
190
  old_key_str = old_key.to_s
190
191
  unless normalized_hooks.key?(old_key_str) || normalized_hooks.key?(old_key_str.to_sym)
191
192
  old_base = File.join(bases_dir, old_key_str)
192
- FileUtils.rm_f(old_base) if File.exist?(old_base)
193
+ FileUtils.rm_f(old_base)
193
194
  end
194
195
  end
195
196
  end
@@ -204,12 +205,12 @@ module Samagotchi
204
205
  meta = meta.merge(sha256: "sha256:#{Digest::SHA256.hexdigest(File.read(src.to_s))}")
205
206
  end
206
207
  hooks_map[k] = meta.transform_keys(&:to_s)
207
- if src && File.exist?(src.to_s)
208
- begin
209
- File.write(File.join(bases_dir, k), File.read(src.to_s))
210
- rescue StandardError
211
- nil
212
- end
208
+ next unless src && File.exist?(src.to_s)
209
+
210
+ begin
211
+ File.write(File.join(bases_dir, k), File.read(src.to_s))
212
+ rescue StandardError
213
+ nil
213
214
  end
214
215
  end
215
216
 
@@ -256,6 +257,7 @@ module Samagotchi
256
257
  conflicts.each do |file_key, info|
257
258
  key = file_key.to_s
258
259
  next unless raw["files"].key?(key)
260
+
259
261
  src = [info[:incoming], info[:current]].find { |p| p && File.exist?(p) }
260
262
  next unless src
261
263
 
@@ -270,6 +272,7 @@ module Samagotchi
270
272
  def read
271
273
  manifest_path = File.join(@bundle_dir, "manifest.json")
272
274
  return nil unless File.exist?(manifest_path)
275
+
273
276
  JSON.parse(File.read(manifest_path), symbolize_names: true)
274
277
  end
275
278
 
@@ -28,7 +28,7 @@ module Samagotchi
28
28
  lower = path.downcase
29
29
  if lower.end_with?(".zip")
30
30
  normalize_zip(path)
31
- elsif lower.end_with?(".tar.gz") || lower.end_with?(".tgz") || lower.end_with?(".tar")
31
+ elsif lower.end_with?(".tar.gz", ".tgz", ".tar")
32
32
  normalize_tar(path)
33
33
  else
34
34
  ext = File.extname(path).downcase
@@ -57,6 +57,7 @@ module Samagotchi
57
57
  def self.git_url?(str)
58
58
  s = str.to_s.strip
59
59
  return false if s.empty?
60
+
60
61
  s.match?(%r{\A(?:https?://|git@|ssh://|git://|file://).+})
61
62
  end
62
63
 
@@ -78,6 +79,7 @@ module Samagotchi
78
79
  args += [url, clone_dir]
79
80
  result = system(*args)
80
81
  raise UnknownSourceError, "git clone failed for #{git_url}" unless result && File.directory?(clone_dir)
82
+
81
83
  # Capture HEAD commit before stripping .git (audit trail)
82
84
  commit = nil
83
85
  begin
@@ -103,12 +105,11 @@ module Samagotchi
103
105
  end
104
106
  end
105
107
 
106
- private
107
-
108
108
  def self.normalize_zip(zip_path)
109
109
  Dir.mktmpdir("samagotchi-zip-") do |extract_dir|
110
110
  result = system("unzip", "-o", zip_path, "-d", extract_dir)
111
111
  raise UnknownSourceError, "unzip failed for #{zip_path}" unless result
112
+
112
113
  clean_copy_of(extract_dir)
113
114
  end
114
115
  end
@@ -119,6 +120,7 @@ module Samagotchi
119
120
  cmd = is_gz ? ["tar", "-xzf"] : ["tar", "-xf"]
120
121
  result = system(*cmd, tar_path, "-C", extract_dir)
121
122
  raise UnknownSourceError, "tar extraction failed for #{tar_path}" unless result
123
+
122
124
  clean_copy_of(extract_dir)
123
125
  end
124
126
  end
@@ -150,6 +152,8 @@ module Samagotchi
150
152
  end
151
153
  [dst, true]
152
154
  end
155
+
156
+ private_class_method :normalize_zip, :normalize_tar, :clean_copy_of
153
157
  end
154
158
  end
155
159
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require "digest"
3
4
  require_relative "provenance"
4
5
  require_relative "manifest"
@@ -20,7 +21,7 @@ module Samagotchi
20
21
  return nil unless data
21
22
 
22
23
  raw_scope = scope_override || data[:scope]&.to_s
23
- scope = (raw_scope.nil? || raw_scope.strip.empty?) ? "system" : raw_scope
24
+ scope = raw_scope.nil? || raw_scope.strip.empty? ? "system" : raw_scope
24
25
  target_dir = MemoryPaths.scope_dir(scope)
25
26
  files = data[:files] || {}
26
27
  details = {}
@@ -108,6 +109,7 @@ module Samagotchi
108
109
  def self.index_has_entry?(scope, file_key)
109
110
  path = IndexUpdater.index_path_for(scope)
110
111
  return false unless path && File.exist?(path)
112
+
111
113
  content = File.read(path)
112
114
  [file_key.delete_suffix(".md"), file_key].uniq.any? { |name| content.match?(IndexUpdater.managed_pattern(name)) }
113
115
  end
@@ -110,12 +110,13 @@ module Samagotchi
110
110
 
111
111
  def skip?
112
112
  val = ENV[SKIP_ENV].to_s.strip.downcase
113
- val == "1" || val == "true"
113
+ %w[1 true].include?(val)
114
114
  end
115
115
 
116
116
  # Unparseable versions fall back to the old "any difference upgrades" behaviour.
117
117
  def installed_newer?(installed, shipped)
118
- installed, shipped = installed.to_s, shipped.to_s
118
+ installed = installed.to_s
119
+ shipped = shipped.to_s
119
120
  return false unless Gem::Version.correct?(installed) && Gem::Version.correct?(shipped)
120
121
 
121
122
  Gem::Version.new(installed) > Gem::Version.new(shipped)
@@ -44,7 +44,7 @@ module Samagotchi
44
44
  def self.empty!(bundles_dir: MemoryPaths.bundles_dir, older_than_days: nil, now: Time.now, dry_run: false)
45
45
  entries = self.entries(bundles_dir: bundles_dir, now: now)
46
46
  if older_than_days
47
- cutoff = now - older_than_days.to_i * 86_400
47
+ cutoff = now - (older_than_days.to_i * 86_400)
48
48
  entries = entries.select { |e| e.time < cutoff }
49
49
  end
50
50
  return entries if dry_run
@@ -98,7 +98,7 @@ module Samagotchi
98
98
  File.directory?(path) && !File.symlink?(path)
99
99
  end
100
100
 
101
- def self.entry_for(trash_dir, name, now)
101
+ def self.entry_for(trash_dir, name, _now)
102
102
  path = File.join(trash_dir, name)
103
103
  time = parse_timestamp(name) || File.mtime(path)
104
104
  files, bytes = count_files(path)
@@ -126,11 +126,14 @@ module Samagotchi
126
126
  bytes = 0
127
127
  Dir.glob(File.join(dir, "**", "*"), File::FNM_DOTMATCH).each do |f|
128
128
  next if File.directory?(f)
129
+
129
130
  files += 1
130
131
  bytes += File.size(f) rescue 0
131
132
  end
132
133
  [files, bytes]
133
134
  end
135
+
136
+ private_class_method :real_dir?, :entry_for, :parse_timestamp, :count_files
134
137
  end
135
138
  end
136
139
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require "fileutils"
3
4
  require "digest"
4
5
  require_relative "../memory_paths"
@@ -38,7 +39,7 @@ module Samagotchi
38
39
  # Determine scope from flag or provenance
39
40
  cli_scope = @scope.to_s.strip.empty? ? nil : @scope
40
41
  raw = cli_scope || data[:scope]&.to_s
41
- target_scope = (raw.nil? || raw.strip.empty?) ? "system" : raw
42
+ target_scope = raw.nil? || raw.strip.empty? ? "system" : raw
42
43
  target_dir = MemoryPaths.scope_dir(target_scope) or raise UninstallError, "invalid scope: #{target_scope}"
43
44
 
44
45
  files = data[:files] || {}
@@ -55,19 +56,20 @@ module Samagotchi
55
56
  end.reject { |_, others| others.empty? }
56
57
 
57
58
  blocked = []
58
- files.each do |file_key, meta|
59
+ files.each do |file_key, _meta|
59
60
  file_key_str = file_key.to_s
60
61
  target_path = File.join(target_dir, file_key_str)
61
62
  base_path = provenance.base_path(file_key_str)
62
63
  next unless File.exist?(target_path)
63
64
  next if shared.key?(file_key_str)
65
+
64
66
  if !@force && File.exist?(base_path) && Merger.current_modified?(base_path, target_path)
65
67
  blocked << file_key_str
66
68
  @warnings << "Skipped #{file_key_str}: local edits detected (use --force to remove)"
67
69
  end
68
70
  end
69
71
 
70
- raise UninstallError, "Uninstall blocked: #{blocked.join(', ')} has local edits (use --force)" if blocked.any?
72
+ raise UninstallError, "Uninstall blocked: #{blocked.join(", ")} has local edits (use --force)" if blocked.any?
71
73
 
72
74
  files.each do |file_key, _meta|
73
75
  file_key_str = file_key.to_s
@@ -108,10 +110,11 @@ module Samagotchi
108
110
  end
109
111
  end
110
112
  # Remove hooks dir if empty, else rm_rf will clean
111
- FileUtils.rm_rf(hooks_dir) if Dir.exist?(hooks_dir)
113
+ FileUtils.rm_rf(hooks_dir)
112
114
  else
113
- # Ensure stale hooks dir removed even if provenance has no hooks map but dir exists
114
- FileUtils.rm_rf(provenance.hooks_dir) if Dir.exist?(provenance.hooks_dir)
115
+ # A stale hooks dir goes even when provenance has no hooks map
116
+ # (rm_rf ignores a missing dir).
117
+ FileUtils.rm_rf(provenance.hooks_dir)
115
118
  end
116
119
 
117
120
  plugin_path = provenance.plugin_path(data)
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require_relative "memory_bundle/manifest"
3
4
  require_relative "memory_bundle/source"
4
5
  require_relative "memory_bundle/provenance"