samagotchi 0.131.0 → 0.132.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -1
  3. data/bin/chi +3 -1
  4. data/docs/architecture.md +32 -1
  5. data/docs/cli.md +29 -8
  6. data/docs/configuration.md +62 -6
  7. data/docs/desktop.md +15 -6
  8. data/docs/guardrails.md +3 -3
  9. data/docs/internals/anthropic.md +3 -2
  10. data/docs/internals/engine.md +4 -4
  11. data/docs/internals/prompt-caching.md +26 -6
  12. data/docs/memory.md +16 -2
  13. data/docs/plugins.md +68 -1
  14. data/lib/samagotchi/bootstrap_command.rb +35 -6
  15. data/lib/samagotchi/bundles/model-notes/manifest.yml +4 -1
  16. data/lib/samagotchi/bundles/model-notes/model_notes_tone.md +3 -0
  17. data/lib/samagotchi/bundles/model-notes/notes/README.md +14 -0
  18. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  19. data/lib/samagotchi/cache_confirm.rb +18 -0
  20. data/lib/samagotchi/chi_home.rb +311 -0
  21. data/lib/samagotchi/desktop/macos.rb +75 -19
  22. data/lib/samagotchi/engine.rb +35 -4
  23. data/lib/samagotchi/engine_snapshots.rb +5 -0
  24. data/lib/samagotchi/gem_update.rb +19 -10
  25. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  26. data/lib/samagotchi/guardrails/protected_paths.rb +6 -3
  27. data/lib/samagotchi/guardrails/verdict.rb +1 -1
  28. data/lib/samagotchi/home_command.rb +149 -0
  29. data/lib/samagotchi/installed_versions.rb +5 -3
  30. data/lib/samagotchi/llm_context_command.rb +53 -7
  31. data/lib/samagotchi/llm_context_override.rb +3 -2
  32. data/lib/samagotchi/plugin/api.rb +45 -1
  33. data/lib/samagotchi/plugin/context.rb +34 -1
  34. data/lib/samagotchi/plugin/loader.rb +5 -2
  35. data/lib/samagotchi/plugin/prompt_sections.rb +124 -0
  36. data/lib/samagotchi/run_options.rb +1 -0
  37. data/lib/samagotchi/session_access/contract.rb +1 -1
  38. data/lib/samagotchi/session_commands.rb +64 -17
  39. data/lib/samagotchi/session_manager.rb +36 -1
  40. data/lib/samagotchi/session_model_settings.rb +5 -2
  41. data/lib/samagotchi/system_prompt.rb +34 -3
  42. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  43. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  44. data/lib/samagotchi/testing/bundle.rb +22 -3
  45. data/lib/samagotchi/testing/host.rb +22 -1
  46. data/lib/samagotchi/testing/recorder.rb +6 -1
  47. data/lib/samagotchi/testing/recording_api.rb +1 -1
  48. data/lib/samagotchi/update_command.rb +65 -7
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +11 -1
  51. data/lib/samagotchi/web/public/index.html +6 -2
  52. data/lib/samagotchi/web/public/llm_context_chip.js +4 -3
  53. data/lib/samagotchi/web/public/model_pick.js +26 -0
  54. data/lib/samagotchi/web/public/model_picker.js +3 -1
  55. data/lib/samagotchi/web/public/start_page.js +14 -2
  56. data/lib/samagotchi/web/server.rb +27 -8
  57. data/lib/samagotchi/web/update_check.rb +3 -2
  58. metadata +5 -1
@@ -9,6 +9,7 @@ require "rbconfig"
9
9
  require_relative "../atomic_file"
10
10
  require_relative "../version"
11
11
  require_relative "../self_report"
12
+ require_relative "../chi_home"
12
13
  require_relative "../installed_gem"
13
14
  require_relative "../config"
14
15
  require_relative "../host_keys"
@@ -41,9 +42,16 @@ module Samagotchi
41
42
  BUILD_NAME = ".chi-helper-build"
42
43
  OLD_NAME = ".chi-helper-old"
43
44
  # Apps started by launchd get a bare env (no shell rc files), so the
44
- # launch file carries what chi needs to find its gems, state and tools.
45
- # Only these, and only when set: no tokens, no runtime knobs.
46
- ENV_ALLOWLIST = %w[XDG_CONFIG_HOME XDG_STATE_HOME GEM_HOME GEM_PATH RUBYLIB].freeze
45
+ # launch file carries what chi needs to find its state and tools.
46
+ # Only these, and only when set: no tokens, no runtime knobs. No
47
+ # RUBYLIB, and GEM_HOME/GEM_PATH (GEM_ENV) only for a [ruby, chi]
48
+ # argv, whose wrapper may find its gem only through them (a chruby
49
+ # GEM_HOME): the chi home's boot sets its gem path itself, and they
50
+ # would reach every tool its sessions run (a project's `bundle exec`).
51
+ ENV_ALLOWLIST = %w[XDG_CONFIG_HOME XDG_STATE_HOME].freeze
52
+ GEM_ENV = %w[GEM_HOME GEM_PATH].freeze
53
+ # What older launch files carried and a refresh drops (#refresh_launch_file).
54
+ DROPPED_ENV = %w[GEM_HOME GEM_PATH RUBYLIB].freeze
47
55
  # PATH is separately filtered to absolute dirs and frozen when written,
48
56
  # so the helper's chi, workers and execute can find the shell's tools.
49
57
  # Without it Ruby reads stdin as US-ASCII and non-ASCII notes break.
@@ -131,7 +139,7 @@ module Samagotchi
131
139
  def initialize(app_dir: nil, support_dir: nil, env: ENV, runner: Runner.new,
132
140
  source_dir: SelfReport::SOURCE_DIR, ruby: RbConfig.ruby, version: VERSION,
133
141
  arch: nil, sources_dir: SOURCES_DIR, register: true, chi_path: nil, terminals: {},
134
- broadcast_timeout: nil)
142
+ broadcast_timeout: nil, chi_home: ChiHome.current(gem_spec: InstalledGem.spec(source_dir)))
135
143
  home = env["HOME"] || Dir.home
136
144
  @app_dir = app_dir || File.join(home, "Applications")
137
145
  @support_dir = support_dir || File.join(home, "Library", "Application Support", APP_NAME)
@@ -146,6 +154,7 @@ module Samagotchi
146
154
  @register = register
147
155
  @terminals = terminals
148
156
  @broadcast_timeout = broadcast_timeout
157
+ @chi_home = chi_home
149
158
  end
150
159
 
151
160
  def app_path = File.join(@app_dir, "#{APP_NAME}.app")
@@ -155,10 +164,12 @@ module Samagotchi
155
164
  def executable_path = File.join(app_path, "Contents", "MacOS", EXECUTABLE)
156
165
  def installed? = File.directory?(app_path)
157
166
 
158
- # What the helper runs: absolute ruby + chi (no shims, which need the
159
- # shell's PATH), and the allowlisted env. chi is an installed gem's
160
- # RubyGems wrapper (it activates the gem and outlives upgrades and
161
- # `gem cleanup`), else this checkout's bin/chi. A section per terminal
167
+ # What the helper runs: the chi home's boot (ChiHome: its ruby, its gem
168
+ # path in the -e string, then "--" and the helper's args) when there
169
+ # is one; else absolute ruby + chi (no shims, which need the shell's
170
+ # PATH). chi is an installed gem's RubyGems wrapper (it activates the
171
+ # gem and outlives upgrades and `gem cleanup`), else this checkout's
172
+ # bin/chi. And the allowlisted env. A section per terminal
162
173
  # switched on (TERMINALS order); the broadcast timeout when given.
163
174
  # The XDG dirs go in absolute (Paths): launchd starts the app in /,
164
175
  # where a relative one would mean /<dir>. PATH is filtered to absolute,
@@ -166,16 +177,22 @@ module Samagotchi
166
177
  # it is frozen at bake time.
167
178
  def launch_config
168
179
  env = { "LANG" => LANG }
169
- ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
180
+ argv = launch_argv
181
+ names = home_boot?(argv) ? ENV_ALLOWLIST : ENV_ALLOWLIST + GEM_ENV
182
+ names.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
170
183
  path = self.class.baked_path(@env)
171
184
  env["PATH"] = path if path
172
185
  env.merge!(Paths.absolute_xdg(env: env, set: :key))
173
- config = { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
186
+ config = { "version" => @version, "argv" => argv, "env" => env, "sources_sha" => sources_sha }
174
187
  TERMINALS.each { |name| config[name] = @terminals[name] if @terminals[name] }
175
188
  config["broadcast_timeout"] = @broadcast_timeout if @broadcast_timeout
176
189
  config
177
190
  end
178
191
 
192
+ def launch_argv
193
+ @chi_home ? @chi_home.boot_argv(run: ChiHome::CHI_BOOT) + ["--"] : [@ruby, @chi_path]
194
+ end
195
+
179
196
  # A digest of what a build compiles: the Swift sources and the
180
197
  # Info.plist template. The launch file records the one it was built
181
198
  # from, so a new chi rebuilds only when these changed.
@@ -209,13 +226,11 @@ module Samagotchi
209
226
 
210
227
  # Why chi update can't keep the launch file's ruby or chi: one of them
211
228
  # is gone. nil when it runs, or has no usable argv (a refresh writes
212
- # this chi's then).
229
+ # this chi's then). Only the absolute paths in it count (a boot
230
+ # string, "-e" and "--" are not paths), as ChiRunner.swift checks.
213
231
  def launch_problem
214
232
  argv = usable_argv(read_launch)
215
- missing = Array(argv).reject { |path| File.exist?(path) }
216
- return nil if missing.empty?
217
-
218
- "chi not found at #{missing.join(", ")}: run chi desktop install --force from the shell whose chi it should run"
233
+ argv && argv_problem(argv)
219
234
  end
220
235
 
221
236
  # The chi version the launch file names, nil without one.
@@ -232,7 +247,10 @@ module Samagotchi
232
247
  # Rewrite the launch file for this chi and its settings, keeping
233
248
  # its argv and the env it was installed with; a PATH only when it has
234
249
  # none (changing them takes chi desktop install --force or upgrade). A
235
- # newer version stays. The app reads it at each send.
250
+ # newer version stays. The app reads it at each send. The kept env
251
+ # loses what ENV_ALLOWLIST no longer takes (DROPPED_ENV): RUBYLIB
252
+ # always; GEM_HOME/GEM_PATH once the argv boots the chi home (an old
253
+ # [ruby, wrapper] argv may find its gem only through them).
236
254
  def refresh_launch_file
237
255
  config = launch_config
238
256
  old = read_launch
@@ -240,7 +258,8 @@ module Samagotchi
240
258
  config["version"] = old["version"] unless older_version?(old["version"])
241
259
  old_env = old["env"]
242
260
  if old_env.is_a?(Hash)
243
- config["env"] = old_env
261
+ dropped = home_boot?(config["argv"]) ? DROPPED_ENV : DROPPED_ENV - GEM_ENV
262
+ config["env"] = old_env.except(*dropped)
244
263
  path = self.class.baked_path(@env)
245
264
  config["env"]["PATH"] = path if config["env"]["PATH"].to_s.empty? && path
246
265
  end
@@ -466,11 +485,41 @@ module Samagotchi
466
485
  !env.is_a?(Hash) || env["PATH"].to_s.empty?
467
486
  end
468
487
 
488
+ # A launch file whose argv runs (#argv_problem finds nothing).
469
489
  def launch_ok?(launch)
470
- argv = Array(launch["argv"])
471
- !argv.empty? && argv.all? { |path| File.exist?(path) }
490
+ argv = usable_argv(launch)
491
+ !argv.nil? && argv_problem(argv).nil?
492
+ end
493
+
494
+ # Why a launch argv (strings) can't run chi, or nil: its ruby isn't an
495
+ # absolute path, an absolute path in it is gone, or it boots the chi
496
+ # home (home.json's ruby) and the home can't boot chi
497
+ # (ChiHome.problem).
498
+ def argv_problem(argv)
499
+ return "the launch file's ruby #{argv[0]} isn't an absolute path: run chi desktop install --force" unless argv[0].start_with?("/")
500
+
501
+ missing = launch_paths(argv).reject { |path| File.exist?(path) }
502
+ unless missing.empty?
503
+ return "chi not found at #{missing.join(", ")}: run chi desktop install --force from the shell whose chi it should run"
504
+ end
505
+ return nil unless home_boot?(argv)
506
+
507
+ home = begin
508
+ ChiHome.read
509
+ rescue ChiHome::Invalid
510
+ nil
511
+ end
512
+ why = home && ChiHome.real(home.ruby) == ChiHome.real(argv[0]) && ChiHome.problem(home)
513
+ why ? "the chi home it boots can't run chi (#{why}): chi home doctor" : nil
472
514
  end
473
515
 
516
+ # The argv entries that are paths (absolute): a boot's "-e", its
517
+ # string and "--" are not.
518
+ def launch_paths(argv) = Array(argv).select { |arg| arg.is_a?(String) && arg.start_with?("/") }
519
+
520
+ # An argv in the chi home's boot form ([ruby, "-e", boot, "--"]).
521
+ def home_boot?(argv) = argv.is_a?(Array) && argv[1] == "-e"
522
+
474
523
  def write_launch_file(config = launch_config, keep_launch: false)
475
524
  if keep_launch
476
525
  old = read_launch
@@ -478,6 +527,13 @@ module Samagotchi
478
527
  old_env = old["env"]
479
528
  old_path = old_env["PATH"] if old_env.is_a?(Hash)
480
529
  config["env"]["PATH"] = old_path unless old_path.to_s.empty?
530
+ # A kept [ruby, wrapper] argv keeps the GEM_* its gem was found
531
+ # through; a kept home boot takes none.
532
+ if home_boot?(config["argv"])
533
+ config["env"] = config["env"].except(*GEM_ENV)
534
+ elsif old_env.is_a?(Hash)
535
+ config["env"].merge!(old_env.slice(*GEM_ENV))
536
+ end
481
537
  end
482
538
  FileUtils.mkdir_p(@support_dir)
483
539
  AtomicFile.write(launch_path, JSON.pretty_generate(config) + "\n")
@@ -319,6 +319,9 @@ module Samagotchi
319
319
  # The context sources the peer bundles declare as a peer agent's
320
320
  # answers (chi.peer_source); the worker's ContextAbsorber asks it.
321
321
  @peer_sources = PeerSources.new
322
+ # The system-prompt text plugins add (chi.prompt_section), run when
323
+ # the prompt is built (#build_system_prompt's sections:).
324
+ @prompt_sections = Plugin::PromptSections.new
322
325
  load_plugins if load
323
326
  # Registered from what the sources published while they loaded, and
324
327
  # with no plugins too: a toolkit's left-out built-ins are reachable
@@ -527,7 +530,8 @@ module Samagotchi
527
530
  thinking: -> { turn_thinking }, model: -> { model_identity },
528
531
  model_notes: -> { model_notes }, memories: memories,
529
532
  muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files,
530
- participants: -> { @participants_line })
533
+ participants: -> { @participants_line },
534
+ sections: -> { @prompt_sections ? @prompt_sections.render(@session&.id) : [] })
531
535
  end
532
536
  private :build_system_prompt
533
537
 
@@ -832,6 +836,13 @@ module Samagotchi
832
836
  @host_registry.model_ref(name).ref
833
837
  end
834
838
 
839
+ # The ref #switch_model! would switch +model_name+ to, checked the way it
840
+ # checks one (UnknownHost, MissingModel), without switching.
841
+ # @return [String]
842
+ def model_switch_ref(model_name)
843
+ model_ref_for(ModelProfile.check_host!(ModelProfile.required_model_name(model_name), hosts: @host_registry.entries))
844
+ end
845
+
835
846
  # Writes the effective model to +session+: the resolved ref, and the
836
847
  # name as typed when that differs (model_typed: an alias, for the
837
848
  # models: lookup after a resume).
@@ -1304,7 +1315,7 @@ module Samagotchi
1304
1315
  # the saved context's status: SessionModelSettings' (resolved on every
1305
1316
  # read); these forward.
1306
1317
  def saved_context_status(context, model: :current) = @model_settings.saved_context_status(context, model: model)
1307
- def llm_context_explained = @model_settings.llm_context_explained
1318
+ def llm_context_explained(own: :session) = @model_settings.llm_context_explained(own: own)
1308
1319
  def llm_context_summary = @model_settings.llm_context_summary
1309
1320
  def llm_context_override = @model_settings.llm_context_override
1310
1321
  def chat_model? = @model_settings.chat_model?
@@ -2827,7 +2838,9 @@ module Samagotchi
2827
2838
  },
2828
2839
  toolkits: @toolkit_binding.plugin_kits,
2829
2840
  tool_index: @tool_index,
2830
- peer_sources: @peer_sources
2841
+ peer_sources: @peer_sources,
2842
+ prompt_sections: @prompt_sections,
2843
+ prompt_changed: -> { prompt_changed! }
2831
2844
  )
2832
2845
  # What plugins show while they load (an MCP server that didn't
2833
2846
  # start) waits for the first turn, beside the load warnings: no UI
@@ -3144,8 +3157,18 @@ module Samagotchi
3144
3157
  # The tools changed (a plugin's chi.tools_changed!): the system prompts,
3145
3158
  # which declare them, are built again on the next turn.
3146
3159
  def tools_changed!
3160
+ prompt_changed!
3161
+ end
3162
+
3163
+ # A plugin's prompt section would give other text now
3164
+ # (chi.prompt_changed!): the system prompts are built again at their
3165
+ # next build (the next turn's start; a running turn keeps the prompt it
3166
+ # started with). One cache restart: the plugin's command asked first
3167
+ # (Plugin::Context#cache_confirm).
3168
+ def prompt_changed!
3147
3169
  @prompt_builder&.reset!
3148
3170
  end
3171
+ public :prompt_changed!
3149
3172
 
3150
3173
  # What a Plugin::Context reads and calls: the session now, and the
3151
3174
  # hook runtime's notify and ask_user.
@@ -3176,6 +3199,12 @@ module Samagotchi
3176
3199
  },
3177
3200
  model_name: -> { effective_model_ref },
3178
3201
  model_key: -> { @model_binding.model_key },
3202
+ # The names a models: map looks the model up under; [] when it doesn't resolve.
3203
+ model_names: lambda do
3204
+ effective_model.names
3205
+ rescue StandardError
3206
+ []
3207
+ end,
3179
3208
  state_dir: -> { session_state_dir },
3180
3209
  scratch: -> { @scratch },
3181
3210
  setup: -> { SessionSetup.of(@session) },
@@ -3186,7 +3215,9 @@ module Samagotchi
3186
3215
  frontend: -> { interface },
3187
3216
  cost: -> { @metrics.cost },
3188
3217
  usage: -> { @metrics.usage },
3189
- turn_author: -> { turn_author }
3218
+ turn_author: -> { turn_author },
3219
+ # A plugin's prompt section is in the stable part: a full re-read.
3220
+ cache_confirm: ->(line, changed) { CacheConfirm.ask(line: line, cost: :full, changed: changed, engine: self) }
3190
3221
  )
3191
3222
  end
3192
3223
 
@@ -62,6 +62,8 @@ module Samagotchi
62
62
  # price_host: the effective model's host, whose hosts.<name>.models
63
63
  # prices the cost estimate names. kit_prompt: the next turn's kit
64
64
  # prompt text ({kit:, tokens:}), only when its toolkit has one.
65
+ # prompt_sections: the plugins' sections the built prompt holds, only
66
+ # when it holds some.
65
67
  # settings: the session's own settings ({"recap.enabled" => false}),
66
68
  # only when it has some.
67
69
  # @return [Hash] @metrics.snapshot, filled in
@@ -78,6 +80,9 @@ module Samagotchi
78
80
  price_host: model.host_name)
79
81
  kit_prompt = kit_prompt_stats
80
82
  snapshot = snapshot.merge(kit_prompt: kit_prompt) if kit_prompt
83
+ # The plugins' prompt sections the built prompt holds ({bundle:, name:}).
84
+ sections = prompt_builder.prompt_sections.map(&:to_stats)
85
+ snapshot = snapshot.merge(prompt_sections: sections) unless sections.empty?
81
86
  # Each participant's spend under their name (participants.json's).
82
87
  if (spend = snapshot[:spend_by_author])
83
88
  named = spend.to_h { |author, row| [author, row.merge(name: participants.name_for(author, session: session))] }
@@ -6,13 +6,15 @@ require "open3"
6
6
  require "rbconfig"
7
7
  require "rubygems/package"
8
8
  require "time"
9
+ require_relative "chi_home"
9
10
  require_relative "version"
10
11
  require_relative "redacted_inspect"
11
12
 
12
13
  module Samagotchi
13
14
  # The gem part of `chi update`: the newest samagotchi on rubygems, and a
14
- # `gem install` of it through this Ruby's own gem command (the real
15
- # interpreter's bindir, not a shim: the same under mise, rbenv and asdf).
15
+ # `gem install` of it through the chi home's gem command, else this
16
+ # Ruby's own (the real interpreter's bindir, not a shim: the same under
17
+ # mise, rbenv and asdf).
16
18
  #
17
19
  # SAMAGOTCHI_UPDATE_GEM_FILE=<path to a .gem> (hidden, for smokes) makes that
18
20
  # file the newest version and installs it instead.
@@ -32,26 +34,31 @@ module Samagotchi
32
34
  Latest = Struct.new(:version, :source, keyword_init: true)
33
35
  Release = Data.define(:version, :created_at, :prerelease)
34
36
 
35
- # Runs a command without a shell; [output, success].
37
+ # Runs a command without a shell; [output, success]. +env+ is merged
38
+ # into the inherited one (nil unsets a key).
36
39
  class Runner
37
- def run(argv)
38
- output, status = Open3.capture2e(*argv)
40
+ def run(argv, env: {})
41
+ output, status = Open3.capture2e(env, *argv)
39
42
  [output, status.success?]
40
43
  rescue SystemCallError => e
41
44
  [e.message, false]
42
45
  end
43
46
  end
44
47
 
45
- # This Ruby's own gem command (chi bootstrap installs commonmarker with
46
- # it too).
47
- def self.gem_bin = File.join(RbConfig::CONFIG["bindir"], "gem")
48
+ # The gem command an install runs: the chi home's (ChiHome.gem_bin),
49
+ # else this Ruby's own (chi bootstrap installs commonmarker with it too).
50
+ def self.gem_bin = ChiHome.gem_bin
48
51
 
49
52
  # @param fetcher [#call, nil] → the latest.json body (specs)
50
- def initialize(env: ENV, fetcher: nil, runner: Runner.new, gem_bin: GemUpdate.gem_bin)
53
+ # @param install_env [Hash] the install child's env on top of this
54
+ # process's (ChiHome.install_env: into the home's gems)
55
+ def initialize(env: ENV, fetcher: nil, runner: Runner.new, gem_bin: GemUpdate.gem_bin,
56
+ install_env: ChiHome.install_env)
51
57
  @env = env
52
58
  @fetcher = fetcher || method(:fetch)
53
59
  @runner = runner
54
60
  @gem_bin = gem_bin
61
+ @install_env = install_env
55
62
  end
56
63
 
57
64
  # @return [Latest]
@@ -104,7 +111,9 @@ module Samagotchi
104
111
 
105
112
  # @return [Array(String, Boolean)] gem's output, success
106
113
  def install(latest)
107
- @runner.run(install_argv(latest))
114
+ return @runner.run(install_argv(latest)) if @install_env.empty?
115
+
116
+ @runner.run(install_argv(latest), env: @install_env)
108
117
  end
109
118
 
110
119
  private
@@ -265,12 +265,14 @@ module Samagotchi
265
265
  @protected ||= begin
266
266
  require_relative "memory_bundle/provenance"
267
267
  require_relative "context_sources"
268
+ require_relative "chi_home"
268
269
  Guardrails::ProtectedPaths.new(
269
270
  store_dir: File.dirname(@approvals.path),
270
271
  bundles_dir: MemoryBundle::Provenance.bundles_dir,
271
272
  config_path: Samagotchi::ConfigFile.global_path,
272
273
  hooks_dir: Hooks::Loader.hooks_dir,
273
- context_dir: ContextSources.root
274
+ context_dir: ContextSources.root,
275
+ home_path: ChiHome.path
274
276
  )
275
277
  end
276
278
  end
@@ -14,7 +14,8 @@ module Samagotchi
14
14
  # hooks, rules, manifests) and attached context (a source's
15
15
  # command runs later, outside the gate; chi context writes
16
16
  # it); nothing legitimate writes there through the file tools.
17
- # ask (once / session): config.yml and the plain hooks dir. The
17
+ # ask (once / session): config.yml, home.json (the chi home: the
18
+ # Ruby every chi launch boots) and the plain hooks dir. The
18
19
  # system bundle's config protocol edits config.yml with
19
20
  # write/edit, so this keeps the user in the loop without
20
21
  # breaking it.
@@ -29,7 +30,8 @@ module Samagotchi
29
30
  # @param config_path [String] config.yml
30
31
  # @param hooks_dir [String] the plain hooks dir
31
32
  # @param context_dir [String, nil] <state dir>/context (ContextSources.root)
32
- def initialize(store_dir:, bundles_dir:, config_path:, hooks_dir:, context_dir: nil)
33
+ # @param home_path [String, nil] home.json (ChiHome.path)
34
+ def initialize(store_dir:, bundles_dir:, config_path:, hooks_dir:, context_dir: nil, home_path: nil)
33
35
  @deny = [
34
36
  ["guardrail-store", store_dir, "the approval store belongs to the user"],
35
37
  ["installed-bundles", bundles_dir, "installed bundles (hooks, rules, manifests) are changed by chi bundle install"],
@@ -37,8 +39,9 @@ module Samagotchi
37
39
  ]
38
40
  @ask = [
39
41
  ["chi-config", config_path, "changes chi's config.yml"],
42
+ ["chi-home", home_path, "changes the Ruby every chi launch boots"],
40
43
  ["chi-hooks", hooks_dir, "changes chi's hooks"]
41
- ]
44
+ ].select { |_, root, _| root }
42
45
  end
43
46
 
44
47
  # Vote on +verdict+ (its targets).
@@ -23,7 +23,7 @@ module Samagotchi
23
23
  # ParentPolicy's user_only); System 1 may, only if the user lists the
24
24
  # rule by name in guardrails.system_one.rules, and never for the
25
25
  # chi-answer/chi-broadcast ones (SystemOne::SelfProtect::NEVER_AUTO).
26
- PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi chi-context-cmd chi-broadcast prompt-memory-write
26
+ PROTECTED_RULES = %w[chi-config chi-home chi-hooks shell-touches-chi chi-context-cmd chi-broadcast prompt-memory-write
27
27
  model-overlay-write chi-answer-piped chi-answer-http].freeze
28
28
  SCOPES = %w[once session repo rule].freeze
29
29
  DO_NOT_RETRY = "Do not retry it or reach the same result another way; ask the user how to proceed."
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require_relative "chi_home"
5
+ require_relative "cli/command"
6
+ require_relative "cli/exit"
7
+ require_relative "cli/flags"
8
+ require_relative "installed_gem"
9
+ require_relative "paths"
10
+ require_relative "version"
11
+
12
+ module Samagotchi
13
+ # `chi home`: the chi home (ChiHome), the Ruby every chi launch boots.
14
+ # Shows it, sets it (`chi home set [--ruby PATH]`), and checks it
15
+ # (`chi home doctor`: for now whether its Ruby is there and has chi).
16
+ class HomeCommand
17
+ include CLI::Command
18
+
19
+ USAGE = <<~TEXT
20
+ Usage: chi home [set [--ruby PATH] | doctor]
21
+ (none) show the chi home: its Ruby, the chi it runs, its gem home and path, mise
22
+ set record this chi's Ruby and gem path as the home (a chruby shell's
23
+ GEM_HOME/GEM_PATH are part of it)
24
+ set --ruby PATH record that Ruby (its own gem dirs) as the home; it must have samagotchi
25
+ (gem install samagotchi with it first)
26
+ doctor check the home: its Ruby is there and has samagotchi
27
+ Every chi launch (workers, chi web's restart and Update, the macOS helper's next
28
+ chi desktop install or upgrade) boots the home's Ruby with the home's gems. Saved in
29
+ #{Paths.tilde(ChiHome.path)}.
30
+ TEXT
31
+
32
+ SUBCOMMANDS = %w[set doctor].freeze
33
+ FLAGS = {
34
+ nil => CLI::Flags.new(help: HELP_WORDS, args: false),
35
+ "set" => CLI::Flags.new(help: HELP_WORDS, args: false) { |f| f.value "--ruby" },
36
+ "doctor" => CLI::Flags.new(help: HELP_WORDS, args: false)
37
+ }.freeze
38
+
39
+ # @param path [String] home.json
40
+ # @param probe [#call] (ruby) → ChiHome::Record or nil (ChiHome.probe)
41
+ def initialize(argv, stdout: $stdout, stderr: $stderr, path: ChiHome.path, gem_spec: InstalledGem.spec,
42
+ env: ENV, probe: nil)
43
+ @argv = argv.dup
44
+ @stdout = stdout
45
+ @stderr = stderr
46
+ @path = path
47
+ @gem_spec = gem_spec
48
+ @env = env
49
+ @probe = probe || ->(ruby) { ChiHome.probe(ruby, env: env) }
50
+ end
51
+
52
+ # @return [Integer] 0, 1 (no usable home, a refusal), 2 usage
53
+ def run
54
+ @sub = SUBCOMMANDS.include?(@argv.first) ? @argv.first : nil
55
+ parsed = parse_flags(FLAGS.fetch(@sub), @sub ? @argv[1..] : @argv)
56
+ return parsed if parsed.is_a?(Integer)
57
+
58
+ case @sub
59
+ when "set" then run_set(parsed.options)
60
+ when "doctor" then run_doctor
61
+ else run_show
62
+ end
63
+ rescue ChiHome::Invalid => e
64
+ error_line("#{command_name}: #{e.message} (chi home set rewrites it)")
65
+ CLI::Exit::FAILED
66
+ end
67
+
68
+ private
69
+
70
+ def command_name = @sub ? "chi home #{@sub}" : "chi home"
71
+ def usage_on_error = nil
72
+
73
+ def run_show
74
+ home = ChiHome.read(path: @path)
75
+ unless home
76
+ @stdout.puts("no chi home: chi launches on the Ruby that starts it (chi home set records this one, #{running_ruby})")
77
+ return CLI::Exit::OK
78
+ end
79
+
80
+ rows(home).each { |label, value| @stdout.puts("#{label.ljust(9)} #{value}") }
81
+ problem = ChiHome.problem(home)
82
+ @stdout.puts("problem #{problem} (chi home doctor)") if problem
83
+ problem ? CLI::Exit::FAILED : CLI::Exit::OK
84
+ end
85
+
86
+ def rows(home)
87
+ list = [["ruby", home.ruby], ["chi", home.newest_version&.to_s || "none"], ["gem home", home.gem_home],
88
+ ["gem path", home.gem_path], ["mise", home.mise || "none"],
89
+ ["set", "#{home.set_at} by chi #{home.set_by_version}"]]
90
+ list << ["running", "chi #{VERSION} on #{running_ruby}"] unless running_home?(home)
91
+ list
92
+ end
93
+
94
+ def running_home?(home) = @gem_spec && home.running_here? && home.newest_version.to_s == VERSION
95
+
96
+ def running_ruby = "#{RbConfig.ruby}#{" (a checkout)" unless @gem_spec}"
97
+
98
+ def run_set(options)
99
+ home = options[:ruby] ? probed(options[:ruby]) : ChiHome.from_running(env: @env)
100
+ return CLI::Exit::FAILED unless home
101
+
102
+ if (problem = ChiHome.problem(home))
103
+ error_line("#{command_name}: #{problem}: gem install samagotchi with #{home.ruby} first")
104
+ return CLI::Exit::FAILED
105
+ end
106
+
107
+ ChiHome.write!(home, path: @path)
108
+ @stdout.puts("chi home set to #{home.ruby} (chi #{home.newest_version}); new workers and launches boot it")
109
+ CLI::Exit::OK
110
+ rescue SystemCallError => e
111
+ error_line("#{command_name}: couldn't write #{Paths.tilde(@path)}: #{e.message}")
112
+ CLI::Exit::FAILED
113
+ end
114
+
115
+ # The home a --ruby names: this one's (with this shell's gem path) when
116
+ # it is the running Ruby, else asked of it.
117
+ def probed(ruby)
118
+ path = File.expand_path(ruby)
119
+ unless File.file?(path) && File.executable?(path)
120
+ error_line("#{command_name}: no Ruby at #{ruby}")
121
+ return nil
122
+ end
123
+ return ChiHome.from_running(env: @env) if ChiHome.real(path) == ChiHome.real(RbConfig.ruby)
124
+
125
+ home = @probe.call(path)
126
+ error_line("#{command_name}: #{ruby} didn't answer as a Ruby") unless home
127
+ home
128
+ end
129
+
130
+ # P2c adds rows (running chi vs the home, the helper's argv, sessions,
131
+ # the project env); for now the home's own.
132
+ def run_doctor
133
+ home = ChiHome.read(path: @path)
134
+ unless home
135
+ @stdout.puts("ok no chi home: each launch uses the Ruby that starts it (chi home set to record one)")
136
+ return CLI::Exit::OK
137
+ end
138
+
139
+ if (problem = ChiHome.problem(home))
140
+ @stdout.puts("fail #{problem}: after a Ruby upgrade, gem install samagotchi with the new Ruby, then " \
141
+ "chi home set from it (chi home set --ruby PATH); until then launches use the Ruby that starts them")
142
+ return CLI::Exit::FAILED
143
+ end
144
+
145
+ @stdout.puts("ok chi home #{home.ruby} runs chi #{home.newest_version}")
146
+ CLI::Exit::OK
147
+ end
148
+ end
149
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "chi_home"
3
4
  require_relative "installed_gem"
4
5
  require_relative "version"
5
6
  require_relative "redacted_inspect"
@@ -8,8 +9,9 @@ module Samagotchi
8
9
  # The newest chi on this machine, which a long-lived process (chi web, an
9
10
  # attaching TUI) compares with its own VERSION to say "chi X is installed;
10
11
  # restart". Run from an installed gem: the highest samagotchi-<v>.gemspec
11
- # in RubyGems' specification dirs, matching the `>= 0.a` a new worker
12
- # activates (prereleases count). Run from a source checkout: the VERSION
12
+ # in RubyGems' specification dirs (the chi home's when there is one,
13
+ # ChiHome), matching the `>= 0.a` a new worker activates (prereleases
14
+ # count). Run from a source checkout: the VERSION
13
15
  # in lib/samagotchi/version.rb on disk now (a `git pull` moves it).
14
16
  #
15
17
  # Cheap enough to poll: each dir is listed again only when its mtime
@@ -30,7 +32,7 @@ module Samagotchi
30
32
  # runs from (nil: a source checkout)
31
33
  # @param dirs [#call] → the specification dirs to list
32
34
  # @param version_file [String] a checkout's version.rb
33
- def initialize(env: ENV, gem_spec: InstalledGem.spec, dirs: -> { Gem::Specification.dirs },
35
+ def initialize(env: ENV, gem_spec: InstalledGem.spec, dirs: -> { ChiHome.spec_dirs(gem_spec: gem_spec) },
34
36
  version_file: File.join(InstalledGem::ROOT, "lib", "samagotchi", "version.rb"))
35
37
  @env = env
36
38
  @gem_spec = gem_spec