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,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "fileutils"
5
+ require "json"
6
+ require "open3"
7
+ require "rbconfig"
8
+ require_relative "../version"
9
+ require_relative "../self_report"
10
+ require_relative "../installed_gem"
11
+
12
+ module Samagotchi
13
+ module Desktop
14
+ # The macOS helper, "Chi Helper.app": a Service ("Send to chi") and a
15
+ # hotkey that open a panel sending text to live sessions via `chi note`.
16
+ #
17
+ # Built on this machine with plain swiftc and an ad-hoc signature (no
18
+ # Xcode project, no notarization) into ~/Applications. Every outside
19
+ # command goes through the runner, and the dirs are injectable, so specs
20
+ # (and smokes with register: false) never touch the real system.
21
+ class MacOS
22
+ Error = Class.new(StandardError)
23
+
24
+ APP_NAME = "Chi Helper"
25
+ BUNDLE_ID = "dev.samagotchi.chi-helper"
26
+ EXECUTABLE = "ChiHelper"
27
+ SOURCES_DIR = File.expand_path("macos", __dir__)
28
+ PBS = "/System/Library/CoreServices/pbs"
29
+ LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"
30
+ # Built next to the target without an .app suffix, so Launch Services
31
+ # never registers a half-built app.
32
+ BUILD_NAME = ".chi-helper-build"
33
+ OLD_NAME = ".chi-helper-old"
34
+ # Apps started by launchd get a bare env (no shell rc files), so the
35
+ # launch file carries what chi needs to find its gems and state. Only
36
+ # these, and only when set: no tokens, no runtime knobs.
37
+ ENV_ALLOWLIST = %w[XDG_CONFIG_HOME XDG_STATE_HOME GEM_HOME GEM_PATH RUBYLIB].freeze
38
+ # Without it Ruby reads stdin as US-ASCII and non-ASCII notes break.
39
+ LANG = "en_US.UTF-8"
40
+
41
+ # Runs a command without a shell; [output, success].
42
+ class Runner
43
+ def run(argv)
44
+ output, status = Open3.capture2e(*argv)
45
+ [output, status.success?]
46
+ rescue SystemCallError => e
47
+ [e.message, false]
48
+ end
49
+ end
50
+
51
+ attr_reader :app_dir, :support_dir
52
+
53
+ def initialize(app_dir: nil, support_dir: nil, env: ENV, runner: Runner.new,
54
+ source_dir: SelfReport::SOURCE_DIR, ruby: RbConfig.ruby, version: VERSION,
55
+ arch: nil, sources_dir: SOURCES_DIR, register: true, chi_path: nil)
56
+ home = env["HOME"] || Dir.home
57
+ @app_dir = app_dir || File.join(home, "Applications")
58
+ @support_dir = support_dir || File.join(home, "Library", "Application Support", APP_NAME)
59
+ @env = env
60
+ @runner = runner
61
+ @source_dir = source_dir
62
+ @chi_path = chi_path || InstalledGem.wrapper(InstalledGem.spec(source_dir)) || File.join(source_dir, "bin", "chi")
63
+ @ruby = ruby
64
+ @version = version
65
+ @arch = arch
66
+ @sources_dir = sources_dir
67
+ @register = register
68
+ end
69
+
70
+ def app_path = File.join(@app_dir, "#{APP_NAME}.app")
71
+ def launch_path = File.join(@support_dir, "launch.json")
72
+ # Written by the app at launch: its hotkey and whether macOS took it.
73
+ def hotkey_path = File.join(@support_dir, "hotkey.json")
74
+ def executable_path = File.join(app_path, "Contents", "MacOS", EXECUTABLE)
75
+ def installed? = File.directory?(app_path)
76
+
77
+ # What the helper runs: absolute ruby + chi (no shims, which need the
78
+ # shell's PATH), and the allowlisted env. chi is an installed gem's
79
+ # RubyGems wrapper (it activates the gem and outlives upgrades and
80
+ # `gem cleanup`), else this checkout's bin/chi.
81
+ def launch_config
82
+ env = { "LANG" => LANG }
83
+ ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
84
+ { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env }
85
+ end
86
+
87
+ def warnings
88
+ return [] unless File.file?(File.join(@source_dir, ".git"))
89
+
90
+ ["chi runs from a linked git worktree (#{@source_dir}); the helper stops working once it is removed. " \
91
+ "Install from the main checkout or a gem install."]
92
+ end
93
+
94
+ def info_plist
95
+ ERB.new(File.read(File.join(SOURCES_DIR, "Info.plist.erb")))
96
+ .result_with_hash(bundle_id: BUNDLE_ID, app_name: APP_NAME, executable: EXECUTABLE, version: @version)
97
+ end
98
+
99
+ # @yield [String] progress lines
100
+ # @param login [Boolean] also start the app at login
101
+ def install(force: false, login: false, &progress)
102
+ raise Error, "already installed (#{app_version}); use chi desktop upgrade" if installed? && !force
103
+
104
+ toolchain!
105
+ quit if installed?
106
+ progress&.call("building #{APP_NAME} #{@version} (can take a few minutes)…")
107
+ built = build_app
108
+ swap_in(built)
109
+ write_launch_file
110
+ register_app
111
+ self.login = true if login
112
+ progress&.call("installed #{app_path}")
113
+ app_path
114
+ end
115
+
116
+ # Keeps the login item as it was.
117
+ def upgrade(&progress)
118
+ was_login = installed? && login == "enabled"
119
+ install(force: true, login: was_login, &progress)
120
+ end
121
+
122
+ # SMAppService status as the app reports it ("enabled",
123
+ # "notRegistered", "requiresApproval", "notFound"); nil if unknown.
124
+ # Only the app itself can register it, run directly with --login.
125
+ def login
126
+ return nil unless @register && installed?
127
+
128
+ output, ok = @runner.run([executable_path, "--login", "status"])
129
+ ok ? output.strip : nil
130
+ end
131
+
132
+ def login=(on)
133
+ return unless @register && installed?
134
+
135
+ output, ok = @runner.run([executable_path, "--login", on ? "on" : "off"])
136
+ raise Error, "could not turn #{on ? "on" : "off"} the login item: #{output.strip}" if on && !ok
137
+ end
138
+
139
+ # @return [Boolean] false when there was nothing to remove
140
+ def uninstall
141
+ return false unless installed? || File.exist?(@support_dir)
142
+
143
+ self.login = false
144
+ quit
145
+ FileUtils.rm_rf(app_path)
146
+ FileUtils.rm_rf(@support_dir)
147
+ if @register
148
+ @runner.run(["defaults", "delete", BUNDLE_ID])
149
+ @runner.run([PBS, "-update"])
150
+ end
151
+ true
152
+ end
153
+
154
+ def status
155
+ result = { installed: installed?, app_path: app_path, chi_version: @version }
156
+ return result unless result[:installed]
157
+
158
+ launch = (JSON.parse(File.read(launch_path)) rescue nil)
159
+ argv = launch.is_a?(Hash) ? Array(launch["argv"]) : []
160
+ env = launch.is_a?(Hash) && launch["env"].is_a?(Hash) ? launch["env"] : {}
161
+ dump, = @runner.run([PBS, "-dump"])
162
+ _, running = @runner.run(["pgrep", "-x", EXECUTABLE])
163
+ hotkey = (JSON.parse(File.read(hotkey_path)) rescue nil)
164
+ result.merge(
165
+ app_version: app_version,
166
+ login: login,
167
+ hotkey: hotkey.is_a?(Hash) ? hotkey : nil,
168
+ launch_argv: argv,
169
+ launch_ok: !argv.empty? && argv.all? { |path| File.exist?(path) },
170
+ baked_dirs: env.select { |name, _| name.start_with?("XDG_") },
171
+ service: dump.include?(BUNDLE_ID),
172
+ running: running
173
+ )
174
+ end
175
+
176
+ def app_version
177
+ plist = File.read(File.join(app_path, "Contents", "Info.plist"))
178
+ plist[%r{<key>CFBundleShortVersionString</key>\s*<string>([^<]*)</string>}, 1]
179
+ rescue SystemCallError
180
+ nil
181
+ end
182
+
183
+ # rename(2) can't replace a non-empty dir: old aside, new in, old gone;
184
+ # the old copy comes back if the second rename fails.
185
+ def swap_in(build)
186
+ old = File.join(@app_dir, OLD_NAME)
187
+ FileUtils.rm_rf(old)
188
+ had_old = installed?
189
+ File.rename(app_path, old) if had_old
190
+ begin
191
+ File.rename(build, app_path)
192
+ rescue StandardError
193
+ File.rename(old, app_path) if had_old
194
+ raise
195
+ end
196
+ FileUtils.rm_rf(old)
197
+ end
198
+
199
+ private
200
+
201
+ def toolchain!
202
+ _, ok = @runner.run(%w[xcrun --find swiftc])
203
+ return if ok
204
+
205
+ raise Error, "chi desktop needs the Command Line Tools (swiftc): xcode-select --install"
206
+ end
207
+
208
+ def build_app
209
+ build = File.join(@app_dir, BUILD_NAME)
210
+ FileUtils.rm_rf(build)
211
+ macos_dir = File.join(build, "Contents", "MacOS")
212
+ FileUtils.mkdir_p(macos_dir)
213
+ File.write(File.join(build, "Contents", "Info.plist"), info_plist)
214
+ sources = Dir[File.join(@sources_dir, "*.swift")].sort
215
+ # Through xcrun: swiftc run by its bare path finds no SDK.
216
+ compile = ["xcrun", "--sdk", "macosx", "swiftc", "-O", "-swift-version", "5", "-target", "#{arch}-apple-macos13", "-parse-as-library",
217
+ *sources, "-o", File.join(macos_dir, EXECUTABLE)]
218
+ run!(compile, "swiftc failed")
219
+ run!(["codesign", "--force", "--sign", "-", build], "codesign failed")
220
+ build
221
+ rescue StandardError
222
+ FileUtils.rm_rf(build) if build
223
+ raise
224
+ end
225
+
226
+ def run!(argv, what)
227
+ output, ok = @runner.run(argv)
228
+ raise Error, "#{what}:\n#{output}" unless ok
229
+ end
230
+
231
+ def arch
232
+ @arch ||= RbConfig::CONFIG["host_cpu"] == "arm64" ? "arm64" : `uname -m`.strip
233
+ end
234
+
235
+ def write_launch_file
236
+ FileUtils.mkdir_p(@support_dir)
237
+ tmp = "#{launch_path}.tmp"
238
+ File.write(tmp, JSON.pretty_generate(launch_config) + "\n")
239
+ File.rename(tmp, launch_path)
240
+ end
241
+
242
+ def register_app
243
+ return unless @register
244
+
245
+ @runner.run([PBS, "-update"])
246
+ @runner.run([LSREGISTER, "-f", app_path])
247
+ @runner.run(["open", "-g", app_path])
248
+ end
249
+
250
+ def quit
251
+ @runner.run(["pkill", "-x", EXECUTABLE]) if @register
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module Samagotchi
6
+ # `chi desktop`: a small native helper that sends selected text to live
7
+ # sessions as a context note. It talks to chi only through the CLI
8
+ # (`chi sessions list --live --scope=all --format json`, `chi note`), so it could ship
9
+ # on its own later. macOS only for now; a Linux variant would be another
10
+ # class next to MacOS.
11
+ module Desktop
12
+ autoload :MacOS, "samagotchi/desktop/macos"
13
+
14
+ module_function
15
+
16
+ # @return [Boolean] whether this machine has a desktop helper variant
17
+ def supported?(host_os = RbConfig::CONFIG["host_os"])
18
+ host_os.to_s.include?("darwin")
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "desktop"
4
+
5
+ module Samagotchi
6
+ # `chi desktop install|upgrade|uninstall|status`: the native helper that
7
+ # sends selected text to live sessions as a context note (macOS for now).
8
+ class DesktopCommand
9
+ USAGE = <<~TEXT
10
+ Usage: chi desktop <install|upgrade|uninstall|status>
11
+ install [--force] [--login]
12
+ build "Chi Helper" into ~/Applications (needs the Command Line Tools)
13
+ and start it: Services > "Send to chi", or ⌃⌥⌘N with the clipboard,
14
+ sends text to a live session as a context note. --login: start it
15
+ at login too (else it runs until you log out)
16
+ upgrade rebuild it for this chi and restart it
17
+ uninstall remove it, its settings and its launch file
18
+ status installed version, how it runs chi, Service and process state
19
+ TEXT
20
+
21
+ SUBCOMMANDS = %w[install upgrade uninstall status].freeze
22
+
23
+ # @param platform [#call] register: → a Desktop::MacOS-like object
24
+ def initialize(argv, stdout: $stdout, stderr: $stderr, platform: nil, supported: Desktop.supported?)
25
+ @argv = argv.dup
26
+ @stdout = stdout
27
+ @stderr = stderr
28
+ @platform = platform || ->(register:) { Desktop::MacOS.new(register: register) }
29
+ @supported = supported
30
+ end
31
+
32
+ # @return [Integer] 0 ok, 1 failed, 2 usage
33
+ def run
34
+ sub = @argv.shift
35
+ if sub.nil? || %w[-h --help help].include?(sub)
36
+ @stdout.puts(USAGE)
37
+ return 0
38
+ end
39
+ return usage_error("unknown subcommand #{sub}") unless SUBCOMMANDS.include?(sub)
40
+
41
+ unless @supported
42
+ @stderr.puts("chi desktop supports macOS only for now")
43
+ return 1
44
+ end
45
+
46
+ options = parse(sub) or return 2
47
+ # --no-register (hidden): no pbs/lsregister/open/pkill, for installs
48
+ # into temp dirs (HOME=…) that must not touch the real Services.
49
+ helper = @platform.call(register: !options[:no_register])
50
+ send(sub.to_sym, helper, options)
51
+ rescue Desktop::MacOS::Error => e
52
+ @stderr.puts("chi desktop: #{e.message}")
53
+ 1
54
+ end
55
+
56
+ private
57
+
58
+ # @return [Hash, nil] nil after a usage error
59
+ def parse(sub)
60
+ flags = { "--no-register" => :no_register }
61
+ flags.merge!("--force" => :force, "--login" => :login) if sub == "install"
62
+ @argv.each_with_object({}) do |arg, options|
63
+ unless flags.key?(arg)
64
+ usage_error("unknown option #{arg}")
65
+ return nil
66
+ end
67
+ options[flags[arg]] = true
68
+ end
69
+ end
70
+
71
+ # The warnings come after a done install: a refused or failed one
72
+ # prints only its error.
73
+ def install(helper, options)
74
+ helper.install(force: options[:force] || false, login: options[:login] || false) { |line| @stdout.puts(line) }
75
+ print_warnings(helper)
76
+ 0
77
+ end
78
+
79
+ def upgrade(helper, _options)
80
+ helper.upgrade { |line| @stdout.puts(line) }
81
+ print_warnings(helper)
82
+ 0
83
+ end
84
+
85
+ def uninstall(helper, _options)
86
+ @stdout.puts(helper.uninstall ? "removed Chi Helper" : "Chi Helper is not installed")
87
+ 0
88
+ end
89
+
90
+ def status(helper, _options)
91
+ s = helper.status
92
+ unless s[:installed]
93
+ @stdout.puts(format_rows([["Chi Helper", "not installed (chi desktop install)"], ["app", s[:app_path]]]))
94
+ return 0
95
+ end
96
+
97
+ version = if s[:app_version] == s[:chi_version]
98
+ "#{s[:app_version]} (matches chi)"
99
+ else
100
+ "#{s[:app_version] || "?"} (chi is #{s[:chi_version]}: chi desktop upgrade)"
101
+ end
102
+ launch = s[:launch_argv].empty? ? "(none)" : s[:launch_argv].join(" ")
103
+ launch += s[:launch_ok] ? " (ok)" : " (missing: chi desktop upgrade)"
104
+ dirs = s[:baked_dirs].empty? ? "(defaults)" : s[:baked_dirs].map { |name, value| "#{name}=#{value}" }.join(" ")
105
+ service = s[:service] ? "registered" : "not registered (try chi desktop upgrade, or log out and back in)"
106
+ running = s[:running] ? "running" : "not running (open -g \"#{s[:app_path]}\")"
107
+ @stdout.puts(format_rows([["Chi Helper", version], ["app", s[:app_path]], ["runs chi", launch],
108
+ ["state dirs", dirs], ["service", service], ["hotkey", hotkey(s[:hotkey])],
109
+ ["process", running], ["at login", login(s[:login])]]))
110
+ 0
111
+ end
112
+
113
+ def hotkey(state)
114
+ return "unknown (the app writes it when it starts)" unless state
115
+
116
+ state["registered"] ? "#{state["keys"]} (clipboard)" : "#{state["keys"]} taken by another app"
117
+ end
118
+
119
+ def login(state)
120
+ case state
121
+ when "enabled" then "on"
122
+ when "requiresApproval" then "waiting for approval in System Settings > General > Login Items"
123
+ when nil then "unknown"
124
+ else "off (chi desktop install --force --login)"
125
+ end
126
+ end
127
+
128
+ def format_rows(rows)
129
+ width = rows.map { |label, _| label.length }.max
130
+ rows.map { |label, value| "#{label.ljust(width)} #{value}" }.join("\n")
131
+ end
132
+
133
+ def print_warnings(helper)
134
+ helper.warnings.each { |warning| @stderr.puts("warning: #{warning}") }
135
+ end
136
+
137
+ def usage_error(message)
138
+ @stderr.puts("chi desktop: #{message}")
139
+ @stderr.puts(USAGE)
140
+ 2
141
+ end
142
+ end
143
+ end