samagotchi 0.2.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 (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. metadata +374 -0
@@ -0,0 +1,528 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "digest"
5
+ require_relative "../memory_paths"
6
+ require_relative "provenance"
7
+ require_relative "manifest"
8
+ require_relative "source"
9
+ require_relative "placeholder"
10
+ require_relative "index_updater"
11
+ require_relative "merger"
12
+ require_relative "../version"
13
+ require_relative "../bundle_needs"
14
+
15
+ module Samagotchi
16
+ module MemoryBundle
17
+ # Installs a bundle into a scoped memories directory.
18
+ #
19
+ # Flow:
20
+ # 1. Normalize source → directory (returns [path, owned])
21
+ # 2. Read manifest.yml
22
+ # 3. Copy .md files to target scope dir (skip or force)
23
+ # 4. Verify checksums (strict mode)
24
+ # 5. Update index.md for each entry
25
+ # 6. Write provenance (.bundles/<name>/)
26
+ # 7. Detect and report {{placeholders}}
27
+ class Installer
28
+ class InstallError < StandardError; end
29
+
30
+ attr_reader :results, :warnings, :placeholder_warnings, :conflicts
31
+
32
+ class << self
33
+ attr_accessor :system_dir_override, :project_dir_base_override
34
+
35
+ def system_dir
36
+ system_dir_override || MemoryPaths.system_dir
37
+ end
38
+
39
+ def project_dir_base
40
+ project_dir_base_override || MemoryPaths.projects_dir
41
+ end
42
+ end
43
+
44
+ def initialize(source:, name:, scope: nil, force: false, strict: true, upgrade: false, dry_run: false)
45
+ @source = source
46
+ @name = name
47
+ @scope = scope&.to_s&.strip&.downcase || ""
48
+ @force = force
49
+ @strict = strict
50
+ @upgrade = upgrade
51
+ @dry_run = dry_run
52
+ @results = {}
53
+ @warnings = []
54
+ @placeholder_warnings = []
55
+ @conflicts = {}
56
+ # Propagate scope overrides so IndexUpdater resolves the correct directories.
57
+ IndexUpdater.system_dir_override = self.class.system_dir_override
58
+ IndexUpdater.project_dir_base_override = self.class.project_dir_base_override
59
+ end
60
+
61
+ def run
62
+ normalized_dir = nil
63
+ source_owned = false
64
+ source_commit = nil
65
+ manifest = nil
66
+ begin
67
+ normalized_dir, source_owned, source_commit = SourceNormalizer.normalize(@source)
68
+ manifest = Manifest.read(dir: normalized_dir)
69
+ rescue SourceNormalizer::UnknownSourceError => e
70
+ raise InstallError, "Source normalization failed: #{e.message}"
71
+ rescue Manifest::ValidationError => e
72
+ if @strict
73
+ raise InstallError, e.message
74
+ else
75
+ @warnings << "No manifest found — proceeding without strict manifest validation"
76
+ end
77
+ end
78
+
79
+ # Determine target scope (CLI wins over manifest).
80
+ cli_scope = @scope.to_s.strip.empty? ? nil : @scope
81
+ target_scope = cli_scope || manifest&.scope || "system"
82
+ target_dir = resolve_target_dir(target_scope)
83
+ FileUtils.mkdir_p(target_dir)
84
+
85
+ # Copy .md files and update index for each.
86
+ all_files_in_bundle = []
87
+ provenance = Provenance.new(name: @name)
88
+ existing_provenance = provenance.read
89
+
90
+ Dir.glob(File.join(normalized_dir, "*.md")).each do |file_path|
91
+ file_key = File.basename(file_path)
92
+ all_files_in_bundle << file_key
93
+ target_path = File.join(target_dir, file_key)
94
+
95
+ if @upgrade && existing_provenance && !@force && !@dry_run
96
+ # 3-way merge path for upgrades
97
+ base_path = provenance.base_path(file_key)
98
+ classification = Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
99
+ case classification
100
+ when :install
101
+ FileUtils.cp(file_path, target_path)
102
+ @results[file_key] = { status: "installed" }
103
+ update_target_index(target_scope, target_path, file_key)
104
+ when :fast_forward
105
+ FileUtils.cp(file_path, target_path)
106
+ @results[file_key] = { status: "updated", reason: "auto-merged (not edited)" }
107
+ update_target_index(target_scope, target_path, file_key)
108
+ when :keep
109
+ @results[file_key] = { status: "kept", reason: "bundle unchanged, local edits preserved" }
110
+ update_target_index(target_scope, target_path, file_key)
111
+ when :noop
112
+ @results[file_key] = { status: "skipped", reason: "already up to date" }
113
+ update_target_index(target_scope, target_path, file_key)
114
+ when :conflict
115
+ @results[file_key] = { status: "conflict", reason: "local edits conflict with bundle update" }
116
+ @conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path }
117
+ @warnings << "Conflict in #{file_key}: local edits conflict with bundle update (use --force to overwrite or resolve interactively)"
118
+ update_target_index(target_scope, target_path, file_key)
119
+ end
120
+ elsif @dry_run
121
+ # Dry-run: classify but do not write
122
+ if File.exist?(target_path) && !@force
123
+ if @upgrade && existing_provenance
124
+ base_path = provenance.base_path(file_key)
125
+ classification = Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
126
+ @results[file_key] = { status: classification.to_s }
127
+ @conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path } if classification == :conflict
128
+ else
129
+ @results[file_key] = { status: "would_skip" }
130
+ end
131
+ else
132
+ @results[file_key] = { status: "would_install" }
133
+ end
134
+ elsif File.exist?(target_path) && !@force
135
+ @results[file_key] = { status: "skipped", reason: "already exists" }
136
+ @warnings << "Skipped #{file_key} (already exists; use --force to overwrite)"
137
+ # Still update the index for skipped files.
138
+ update_target_index(target_scope, target_path, file_key)
139
+ else
140
+ FileUtils.cp(file_path, target_path) unless @dry_run
141
+ @results[file_key] = { status: "installed" }
142
+ # Update index for newly installed files.
143
+ update_target_index(target_scope, target_path, file_key) unless @dry_run
144
+ end
145
+ end
146
+
147
+ # ── Hooks: copy hooks/*.rb into <bundle_dir>/hooks/ ───────────────
148
+ hooks_target = File.join(provenance.bundle_dir, "hooks")
149
+ all_hooks_in_bundle = []
150
+ hooks_files_for_provenance = {}
151
+ if manifest
152
+ hook_entries = manifest.hooks
153
+ # Fallback: if manifest has no hooks but source has hooks/*.rb, discover them
154
+ if hook_entries.empty?
155
+ discovered = Dir.glob(File.join(normalized_dir, "hooks", "*.rb")).map { |p| File.basename(p) }
156
+ discovered.each do |bn|
157
+ # Build minimal metadata so copy still happens (event unknown -> skipped by loader but copied for integrity)
158
+ hook_entries[bn] = { sha256: "", event: "", on_error: "skip", priority: 100 }
159
+ end
160
+ end
161
+
162
+ hook_entries.each do |basename, meta|
163
+ next unless basename.is_a?(String) && !basename.empty?
164
+ all_hooks_in_bundle << basename
165
+ src = File.join(normalized_dir, "hooks", basename)
166
+ next unless File.exist?(src)
167
+
168
+ dest = File.join(hooks_target, basename)
169
+
170
+ if @dry_run
171
+ if File.exist?(dest) && !@force
172
+ @results[basename] = { status: "would_skip" }
173
+ else
174
+ @results[basename] = { status: "would_install" }
175
+ end
176
+ next
177
+ end
178
+
179
+ # Local-edit detection for upgrade path (overwrite + warn)
180
+ if @upgrade && existing_provenance && File.exist?(dest) && !@force
181
+ prev_meta = existing_provenance[:hooks] ? (existing_provenance[:hooks][basename.to_sym] || existing_provenance[:hooks][basename]) : nil
182
+ if prev_meta
183
+ prev_sha = (prev_meta[:sha256] || prev_meta["sha256"] || "").to_s.sub(/\Asha256:/, "")
184
+ if File.exist?(dest)
185
+ cur_sha = Digest::SHA256.hexdigest(File.read(dest))
186
+ if cur_sha != prev_sha && !prev_sha.empty?
187
+ @warnings << "Hook #{basename} was locally modified; overwriting"
188
+ end
189
+ end
190
+ end
191
+ end
192
+
193
+ FileUtils.mkdir_p(hooks_target)
194
+ FileUtils.cp(src, dest)
195
+ if @upgrade && existing_provenance
196
+ @results[basename] = { status: "updated" }
197
+ else
198
+ @results[basename] = { status: "installed" }
199
+ end
200
+ hooks_files_for_provenance[basename] = dest
201
+ end
202
+ else
203
+ # No manifest (non-strict): still copy any hooks/*.rb if present
204
+ Dir.glob(File.join(normalized_dir, "hooks", "*.rb")).each do |src|
205
+ basename = File.basename(src)
206
+ all_hooks_in_bundle << basename
207
+ dest = File.join(hooks_target, basename)
208
+ if @dry_run
209
+ @results[basename] = { status: "would_install" }
210
+ else
211
+ FileUtils.mkdir_p(hooks_target)
212
+ FileUtils.cp(src, dest)
213
+ @results[basename] = { status: "installed" }
214
+ hooks_files_for_provenance[basename] = dest
215
+ end
216
+ end
217
+ end
218
+
219
+ # Prune stale hooks (removed from bundle) — reuse bases pruning via provenance but also remove files
220
+ if @upgrade && existing_provenance && existing_provenance[:hooks] && !@dry_run
221
+ existing_provenance[:hooks].keys.each do |old_key|
222
+ old_key_str = old_key.to_s
223
+ next if all_hooks_in_bundle.include?(old_key_str)
224
+ old_dest = File.join(hooks_target, old_key_str)
225
+ if File.exist?(old_dest) && !@force
226
+ @warnings << "Bundle no longer includes hook #{old_key_str} but local file exists — removing"
227
+ end
228
+ FileUtils.rm_f(old_dest) if File.exist?(old_dest)
229
+ end
230
+ end
231
+
232
+ # Verify hook checksums in strict mode (mirror .md block)
233
+ if manifest && @strict
234
+ all_hooks_in_bundle.each do |basename|
235
+ next if @conflicts.key?(basename)
236
+ expected = manifest.checksum_for_hook(basename)
237
+ if expected
238
+ dest = File.join(hooks_target, basename)
239
+ next unless File.exist?(dest)
240
+ actual = Digest::SHA256.hexdigest(File.read(dest))
241
+ if actual != expected
242
+ @warnings << "Checksum mismatch for hook #{basename}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
243
+ end
244
+ end
245
+ end
246
+ end
247
+
248
+ # ── Guardrail rules: guardrails/*.yml into <bundle_dir>/guardrails/ ─
249
+ # The bundle's set replaces what an earlier version installed.
250
+ guardrails_target = provenance.guardrails_dir
251
+ guardrail_files_for_provenance = {}
252
+ incoming_rules = Dir.glob(File.join(normalized_dir, "guardrails", "*.yml")).sort
253
+ if @dry_run
254
+ incoming_rules.each { |src| @results["guardrails/#{File.basename(src)}"] = { status: "would_install" } }
255
+ else
256
+ FileUtils.rm_rf(guardrails_target)
257
+ unless incoming_rules.empty?
258
+ FileUtils.mkdir_p(guardrails_target)
259
+ incoming_rules.each do |src|
260
+ dest = File.join(guardrails_target, File.basename(src))
261
+ FileUtils.cp(src, dest)
262
+ @results["guardrails/#{File.basename(src)}"] = { status: "installed" }
263
+ guardrail_files_for_provenance[File.basename(src)] = dest
264
+ end
265
+ end
266
+ end
267
+
268
+ # ── Plugin: <file> into <bundle_dir>/plugin/ (docs/plugins.md) ────
269
+ # Like the rules, the bundle's plugin replaces an earlier one.
270
+ plugin_file_for_provenance = install_plugin(manifest, normalized_dir, provenance)
271
+
272
+ # Files the previous version shipped and this one doesn't.
273
+ if @upgrade && existing_provenance && existing_provenance[:files]
274
+ prune_dropped_files(existing_provenance[:files], all_files_in_bundle, target_dir, target_scope, provenance)
275
+ end
276
+
277
+ # Verify checksums if we have a manifest (strict).
278
+ if manifest && @strict
279
+ all_files_in_bundle.each do |file_key|
280
+ next if @conflicts.key?(file_key)
281
+ status = @results[file_key] ? @results[file_key][:status].to_s : nil
282
+ next if %w[kept kept_pruned fast_forward updated].include?(status)
283
+ # For dry-run, fast_forward would appear as "fast_forward", updated not yet
284
+ target_path = File.join(target_dir, file_key)
285
+ next unless File.exist?(target_path)
286
+ expected = manifest.checksum_for(file_key)
287
+ if expected
288
+ actual = Digest::SHA256.hexdigest(File.read(target_path))
289
+ if actual != expected
290
+ @warnings << "Checksum mismatch for #{file_key}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
291
+ end
292
+ end
293
+ end
294
+ end
295
+
296
+ warn_missing_needs(manifest) if manifest
297
+
298
+ # Write provenance (only if we had a manifest and not dry_run).
299
+ if manifest && !@dry_run
300
+ skip_provenance = @upgrade && @conflicts.any? && !@force
301
+ unless skip_provenance
302
+ provenance_files = {}
303
+ if @upgrade && existing_provenance
304
+ all_files_in_bundle.each do |file_key|
305
+ target_path = File.join(target_dir, file_key)
306
+ next unless File.exist?(target_path)
307
+ status = @results[file_key] ? @results[file_key][:status].to_s : nil
308
+ if %w[kept noop conflict kept_pruned].include?(status)
309
+ # Keep old base snapshot — do not overwrite with edited current
310
+ # Use base snapshot as source if it exists to preserve old checksum
311
+ base_path = provenance.base_path(file_key)
312
+ if File.exist?(base_path)
313
+ # Keep existing provenance entry by re-using base content
314
+ # We still need to include it to prevent pruning, but with base content
315
+ provenance_files[file_key] = base_path
316
+ else
317
+ provenance_files[file_key] = target_path
318
+ end
319
+ else
320
+ # installed, updated, skipped — use current target (which is incoming for updated)
321
+ provenance_files[file_key] = target_path
322
+ end
323
+ end
324
+ # Handle pruned but kept files (bundle removed file but local kept)
325
+ if existing_provenance[:files]
326
+ existing_provenance[:files].keys.each do |old_key|
327
+ old_key_str = old_key.to_s
328
+ next if all_files_in_bundle.include?(old_key_str)
329
+ # If it was kept_pruned, include it with base content to prevent pruning
330
+ if @results[old_key_str] && @results[old_key_str][:status].to_s == "kept_pruned"
331
+ base_path = provenance.base_path(old_key_str)
332
+ target_path = File.join(target_dir, old_key_str)
333
+ # Use base if exists to keep old checksum, else target
334
+ src = File.exist?(base_path) ? base_path : target_path
335
+ provenance_files[old_key_str] = src if File.exist?(src)
336
+ end
337
+ end
338
+ end
339
+ else
340
+ all_files_in_bundle.each do |file_key|
341
+ target_path = File.join(target_dir, file_key)
342
+ provenance_files[file_key] = target_path if File.exist?(target_path)
343
+ end
344
+ end
345
+ # Build hooks metadata for provenance
346
+ hooks_for_provenance = {}
347
+ if manifest && manifest.hooks && !manifest.hooks.empty?
348
+ hooks_for_provenance = manifest.hooks
349
+ elsif hooks_files_for_provenance.any?
350
+ hooks_files_for_provenance.each do |basename, path|
351
+ next unless File.exist?(path)
352
+ sha = Digest::SHA256.hexdigest(File.read(path))
353
+ hooks_for_provenance[basename] = { "sha256" => "sha256:#{sha}", "event" => "", "on_error" => "skip", "priority" => 100 }
354
+ end
355
+ end
356
+ Provenance.new(name: @name).write(
357
+ files: provenance_files,
358
+ scope: target_scope,
359
+ version: manifest.version,
360
+ source_path: @source,
361
+ hooks: hooks_for_provenance,
362
+ trust_level: manifest.respond_to?(:trust_level) ? manifest.trust_level : nil,
363
+ source_commit: source_commit,
364
+ hooks_files: hooks_files_for_provenance,
365
+ guardrails_files: guardrail_files_for_provenance,
366
+ plugin_file: plugin_file_for_provenance,
367
+ requires_chi: manifest.requires_chi,
368
+ needs: manifest.needs
369
+ )
370
+ end
371
+ end
372
+
373
+ # Detect placeholders.
374
+ all_files_in_bundle.each do |file_key|
375
+ file_path = File.join(target_dir, file_key)
376
+ next unless File.exist?(file_path)
377
+ names = Placeholder.detect_in_file(file_path)
378
+ if names.any?
379
+ @placeholder_warnings << "#{file_key}: {{#{names.join("}}, {{")}}}"
380
+ end
381
+ end
382
+
383
+ [normalized_dir, manifest]
384
+ ensure
385
+ SourceNormalizer.cleanup(normalized_dir) if source_owned
386
+ end
387
+
388
+ def summary
389
+ lines = []
390
+ installed = @results.select { |_, r| r[:status].to_s == "installed" }.keys
391
+ updated = @results.select { |_, r| r[:status].to_s == "updated" }.keys
392
+ kept = @results.select { |_, r| %w[kept kept_pruned].include?(r[:status].to_s) }.keys
393
+ skipped = @results.select { |_, r| %w[skipped noop would_skip].include?(r[:status].to_s) }.keys
394
+ conflicts = @results.select { |_, r| r[:status].to_s == "conflict" }.keys
395
+ would_install = @results.select { |_, r| r[:status].to_s == "would_install" }.keys
396
+ fast_forward = @results.select { |_, r| r[:status].to_s == "fast_forward" }.keys
397
+ removed = @results.select { |_, r| r[:status].to_s == "removed" }.keys
398
+ would_remove = @results.select { |_, r| r[:status].to_s == "would_remove" }.keys
399
+
400
+ lines << "Installed: #{installed.join(', ')}" unless installed.empty?
401
+ lines << "Updated: #{updated.join(', ')}" unless updated.empty?
402
+ lines << "Would install: #{would_install.join(', ')}" unless would_install.empty?
403
+ lines << "Fast-forward: #{fast_forward.join(', ')}" unless fast_forward.empty?
404
+ lines << "Removed (no longer in the bundle): #{removed.join(', ')}" unless removed.empty?
405
+ lines << "Would remove (no longer in the bundle): #{would_remove.join(', ')}" unless would_remove.empty?
406
+ lines << "Kept (local edits preserved): #{kept.join(', ')}" unless kept.empty?
407
+ lines << "Skipped: #{skipped.join(', ')}" unless skipped.empty?
408
+ lines << "Conflicts: #{conflicts.join(', ')}" unless conflicts.empty?
409
+ lines.concat(@warnings) unless @warnings.empty?
410
+ lines.concat(@placeholder_warnings) unless @placeholder_warnings.empty?
411
+
412
+ lines.any? ? lines.join("\n") : "No files to install."
413
+ end
414
+
415
+ private
416
+
417
+ # A file the previous version installed that this one doesn't ship is
418
+ # removed (with its index line) when it still matches what was
419
+ # installed, or with --force; one the user edited is kept with a note.
420
+ def prune_dropped_files(previous_files, bundle_files, target_dir, scope, provenance)
421
+ previous_files.each do |old_key, meta|
422
+ key = old_key.to_s
423
+ next if bundle_files.include?(key)
424
+ target = File.join(target_dir, key)
425
+ next unless File.exist?(target)
426
+
427
+ if @force || installed_unchanged?(target, meta, provenance.base_path(key))
428
+ @results[key] = { status: @dry_run ? "would_remove" : "removed", reason: "no longer in the bundle" }
429
+ next if @dry_run
430
+ FileUtils.rm_f(target)
431
+ remove_target_index(scope, key)
432
+ else
433
+ @results[key] = { status: "kept_pruned", reason: "local edits preserved" }
434
+ @warnings << "Kept #{key}: no longer in the bundle but edited locally (use --force to remove)"
435
+ end
436
+ end
437
+ end
438
+
439
+ # Whether the file on disk is what the previous install wrote: its
440
+ # recorded checksum, else the base snapshot. Unknown counts as edited.
441
+ def installed_unchanged?(target, meta, base_path)
442
+ current = Digest::SHA256.hexdigest(File.read(target))
443
+ recorded = meta.is_a?(Hash) ? (meta[:checksum] || meta["checksum"]).to_s.sub(/\Asha256:/, "") : ""
444
+ return current == recorded unless recorded.empty?
445
+ File.exist?(base_path) && current == Digest::SHA256.hexdigest(File.read(base_path))
446
+ end
447
+
448
+ def remove_target_index(scope, file_key)
449
+ IndexUpdater.remove_index(scope, file_key.delete_suffix(".md"))
450
+ IndexUpdater.remove_index(scope, file_key) # legacy "name.md" line
451
+ rescue StandardError
452
+ # Best-effort, like update_target_index.
453
+ end
454
+
455
+ # One warning per need not found on this PATH (read-only, so dry-run
456
+ # too). Installing goes ahead: needs are advisory.
457
+ def warn_missing_needs(manifest)
458
+ BundleNeeds.missing(manifest.needs).each do |need|
459
+ hint = need[:hint] ? " (#{need[:hint]})" : ""
460
+ @warnings << "needs #{need[:command]}: not found on PATH#{hint}; #{@dry_run ? "would install" : "installed"} anyway"
461
+ end
462
+ end
463
+
464
+ # Copy the manifest's plugin file into the bundle's plugin/ dir,
465
+ # warning when its sha256 differs from the declared one or this chi
466
+ # doesn't meet requires_chi (the Engine then won't load it).
467
+ # @return [String, nil] the installed file (nil: none, or a dry run)
468
+ def install_plugin(manifest, source_dir, provenance)
469
+ plugin = manifest&.plugin
470
+ unless plugin
471
+ FileUtils.rm_rf(provenance.plugin_dir) unless @dry_run
472
+ return nil
473
+ end
474
+
475
+ src = File.join(source_dir, plugin[:file])
476
+ raise InstallError, "the manifest names plugin #{plugin[:file]}, which the bundle doesn't have" unless File.file?(src)
477
+
478
+ if (failure = Manifest.requires_chi_failure(manifest.requires_chi, Samagotchi::VERSION))
479
+ @warnings << "Plugin #{plugin[:file]} won't load: #{failure}"
480
+ end
481
+ if @dry_run
482
+ @results[plugin[:file]] = { status: "would_install" }
483
+ return nil
484
+ end
485
+
486
+ FileUtils.rm_rf(provenance.plugin_dir)
487
+ FileUtils.mkdir_p(provenance.plugin_dir)
488
+ dest = File.join(provenance.plugin_dir, plugin[:file])
489
+ FileUtils.cp(src, dest)
490
+ @results[plugin[:file]] = { status: @upgrade && provenance.read ? "updated" : "installed" }
491
+ expected = manifest.checksum_for_plugin
492
+ actual = Digest::SHA256.hexdigest(File.binread(dest))
493
+ if @strict && expected && actual != expected
494
+ @warnings << "Checksum mismatch for plugin #{plugin[:file]}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
495
+ end
496
+ dest
497
+ end
498
+
499
+ def update_target_index(scope, file_path, file_key)
500
+ byte_count = File.exist?(file_path) ? File.size(file_path) : 0
501
+ entry_name = file_key.delete_suffix(".md")
502
+ begin
503
+ # Index lines use the entry name (as memory_write does); drop the
504
+ # legacy "name.md" line older installs wrote.
505
+ IndexUpdater.remove_index(scope, file_key) unless entry_name == file_key
506
+ IndexUpdater.update_index(scope, entry_name, byte_count)
507
+ rescue => _e
508
+ # Silently skip index updates — they're best-effort.
509
+ end
510
+ end
511
+
512
+ def resolve_target_dir(scope)
513
+ case scope
514
+ when "system", ""
515
+ self.class.system_dir
516
+ when "project"
517
+ if self.class.project_dir_base_override
518
+ self.class.project_dir_base
519
+ else
520
+ File.join(self.class.project_dir_base, MemoryPaths.project_key)
521
+ end
522
+ else
523
+ raise InstallError, "invalid scope: #{scope}"
524
+ end
525
+ end
526
+ end
527
+ end
528
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "manifest"
4
+ require_relative "source"
5
+ require_relative "provenance"
6
+ require_relative "system_bundle"
7
+
8
+ module Samagotchi
9
+ module MemoryBundle
10
+ # What `chi bundle list` shows: the installed bundles (from provenance),
11
+ # each with the shipped version when chi ships a newer one, and the
12
+ # bundles shipped with chi that aren't installed yet. The system bundle
13
+ # is left out of the shipped ones: chi installs and upgrades it itself.
14
+ module Listing
15
+ # error is set (and the rest nil) when the provenance manifest.json can't be read.
16
+ Installed = Struct.new(:name, :version, :scope, :files, :installed_at, :upgrade, :error, keyword_init: true)
17
+ # source is the name `chi bundle install` takes (the shipped dir's name).
18
+ Shipped = Struct.new(:name, :source, :version, :description, keyword_init: true)
19
+
20
+ module_function
21
+
22
+ # @return [Array<Shipped>] by name, without the system bundle; a dir
23
+ # whose manifest doesn't read is skipped
24
+ def shipped(dir: SourceNormalizer::SHIPPED_DIR)
25
+ return [] unless File.directory?(dir)
26
+
27
+ Dir.children(dir).sort.filter_map do |source|
28
+ next unless File.file?(File.join(dir, source, "manifest.yml"))
29
+ m = Manifest.read(dir: File.join(dir, source))
30
+ next if m.name == SystemBundle::BUNDLE_NAME
31
+ Shipped.new(name: m.name, source: source, version: m.version, description: m.description)
32
+ rescue Manifest::ValidationError, Psych::Exception
33
+ nil
34
+ end.sort_by(&:name)
35
+ end
36
+
37
+ # @return [Array<Installed>] by name; upgrade is the Shipped when it is newer
38
+ def installed(shipped: self.shipped)
39
+ dir = Provenance.bundles_dir
40
+ return [] unless File.directory?(dir)
41
+
42
+ by_name = shipped.to_h { |s| [s.name, s] }
43
+ Dir.children(dir).reject { |e| e.start_with?(".") }.sort.filter_map do |name|
44
+ begin
45
+ data = Provenance.new(name: name).read
46
+ rescue JSON::ParserError, SystemCallError
47
+ data = :unreadable
48
+ end
49
+ next unless data
50
+ next Installed.new(name: name, error: "manifest.json unreadable") unless data.is_a?(Hash)
51
+
52
+ ship = by_name[name]
53
+ Installed.new(name: name, version: data[:version], scope: data[:scope],
54
+ files: (data[:files] || {}).size, installed_at: data[:installed_at],
55
+ upgrade: ship && newer?(ship.version, data[:version]) ? ship : nil)
56
+ end
57
+ end
58
+
59
+ # The shipped bundles not installed under their manifest name.
60
+ def available(shipped: self.shipped, installed: self.installed(shipped: shipped))
61
+ names = installed.map(&:name)
62
+ shipped.reject { |s| names.include?(s.name) }
63
+ end
64
+
65
+ def newer?(candidate, current)
66
+ candidate, current = candidate.to_s, current.to_s
67
+ return false unless Gem::Version.correct?(candidate) && Gem::Version.correct?(current)
68
+ Gem::Version.new(candidate) > Gem::Version.new(current)
69
+ end
70
+ end
71
+ end
72
+ end