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
data/docs/plugins.md CHANGED
@@ -150,6 +150,34 @@ chi.command "/hello-slow", "greet after 2 s, even mid-turn", anytime: true do |a
150
150
  end
151
151
  ```
152
152
 
153
+ #### Commands that ask: `ctx.cache_confirm`
154
+
155
+ A command whose change restarts the model server's prompt cache (one that changes the text of a
156
+ [prompt section](#chiprompt_sectionname--ctx---and-chiprompt_changed)) asks first, as `/thinking` and `/toolkit` do:
157
+ `ctx.cache_confirm(line, changed:)` returns nil when it may apply now, or a confirm to return with the command's
158
+ text, `[text, confirm]`, changing nothing. `line` is the command line that applies it (without `--yes`), `changed:`
159
+ whether your text would change. chi decides the rest: it asks only a person at a web tab or a TUI (never `chi send`,
160
+ a delegate or `chi -p`), only once the session has a model reply, never for a line with `--yes` (or `-y`), and
161
+ writes the confirm's text (the conversation's size to read again; on an `api: anthropic` host, that preserved
162
+ thinking is dropped). The TUI prefills `line --yes`, the web shows an Apply button that sends it, and the command
163
+ then runs again with `--yes` in `args` (drop it from your words). Outside a command (a hook, an init task, your
164
+ own thread) it is always nil. A plain String or nil return is unchanged.
165
+
166
+ A command that asks must not be `anytime: true`: the REPL shows only an anytime command's text, so its Apply would
167
+ be lost. Needs chi 0.132.0.
168
+
169
+ ```ruby
170
+ chi.command "/tone", "show or set this session's tone" do |args, ctx|
171
+ word = (args.split - %w[--yes -y]).join(" ")
172
+ ask = ctx.cache_confirm("/tone #{word}", changed: word != current_tone)
173
+ next ["Switching to #{word} restarts the prompt cache.", ask] if ask
174
+
175
+ save_tone(word)
176
+ @chi.prompt_changed!
177
+ "tone: #{word} from the next turn"
178
+ end
179
+ ```
180
+
153
181
  #### `/help`
154
182
 
155
183
  `/help` (itself an anytime command) lists every command the session knows:
@@ -607,6 +635,38 @@ chi.toolkit "coding", base: "child", prompt: "- Be concise in your responses"
607
635
  chi.toolkit "web", base: "lean", groups: %w[chrome db*]
608
636
  ```
609
637
 
638
+ ### `chi.prompt_section(name) { |ctx| … }` and `chi.prompt_changed!`
639
+
640
+ Text the session's system prompt carries, from a plugin: the block gets the Context and returns a String (at most
641
+ 2000 characters, stripped) or nil for no section. Each goes into the prompt's stable part right after the
642
+ toolkit's prompt text and before the memory indexes, as `From <bundle> (<name>):` and the text, sorted by
643
+ (bundle, name). With no section (every block nil, or no plugin uses it) the prompt is exactly what it was without.
644
+ `name` is `a-z`, `0-9`, `_` and `-` (at most 32), once per bundle.
645
+
646
+ The block runs when the prompt is built: a session's first turn, after `/model` (so `ctx.model` and
647
+ `ctx.model_key` are the new model's), after `chi.prompt_changed!` or a tools change. Never per request, and on
648
+ whatever thread builds, so keep it quick and thread-safe (no I/O beyond reading a small file). **Same inputs, same
649
+ text**: anything that differs (a time, a random pick, a count) breaks the model server's prompt cache at the next
650
+ build. A block that raises, returns something other than a String or nil, or text over the limit keeps that
651
+ section's last good text for the session (one warn in the debug log, `prompt_section_failed`); with none yet the
652
+ section is left out. A deliberate nil removes it.
653
+
654
+ `chi.prompt_changed!` says your section would give other text now (the user picked another setting): the prompt is
655
+ built again at its next build, the next turn's start (a running turn keeps the prompt it started with). That
656
+ restarts the prompt cache for the whole conversation, so a command that changes it asks first
657
+ ([Commands that ask](#commands-that-ask-ctxcache_confirm)) and calls `chi.prompt_changed!` only once it applies.
658
+ Keep `chi` from `register` for that (`@chi = chi`). `/stats` names the sections the prompt holds
659
+ (`prompt sections: tone (tone)`). The test kit runs them as a build does (`bundle.prompt_text`,
660
+ `bundle.prompt_sections`) and counts `chi.prompt_changed!` (`bundle.prompt_changes`). Needs chi
661
+ 0.132.0: a bundle using it sets `requires_chi`.
662
+
663
+ ```ruby
664
+ def register(chi)
665
+ @chi = chi
666
+ chi.prompt_section("tone") { |ctx| preset_text(ctx.model_key) } # nil: no section
667
+ end
668
+ ```
669
+
610
670
  ### `chi.tool_source(call:, acts_as:, label:, wait:, prefix:)`
611
671
 
612
672
  Tools the model reaches through a search instead of declaring them one by
@@ -764,6 +824,7 @@ full label, `plugin.rb (bundle my-bundle)`.
764
824
  | `ctx.fork?` | whether the session is a fork (`ctx.sessions.fork`): it has a parent but isn't a delegate child, and started from a conversation, so its first prompt isn't its first user message |
765
825
  | `ctx.model` | the model the session runs on now: its resolved ref (`host:id`), right after `/model` too |
766
826
  | `ctx.model_key` | that model's memory overlay key (`<name>.<key>.md`, what `memory_write current_model_only` writes) |
827
+ | `ctx.model_names` | the names chi's own per-model config looks that model up under (a `models:` map in config.yml): as typed (an alias), the alias's target, the resolved ref, without the host, the bare id; deduped, most specific first, frozen (`[]` when it doesn't resolve). Match a map of yours over them downcased, first hit, as chi does. Needs chi 0.132.0 |
767
828
  | `ctx.cost` | the session's spend so far, read now, as a frozen value: `total` (USD, reported + estimated, side requests included; 0.0 before any), `reported` (by providers, `usage.cost`), `estimated` (from `hosts.<name>.models` prices; a generation is one or the other, never both), `side` (recap, `/btw`, `ask_model`: their share of `total`) and `known?` (false while generations or side requests ran but none was reported or priced, so a limit on it can't be enforced; a session with some priced generations reads known, the unpriced ones counting 0). It includes the running turn's finished generations and what earlier workers of the session spent. Use its methods, not its class. Needs chi 0.83.0 |
768
829
  | `ctx.usage` | the session's work so far, read now, as a frozen value: `turns` (finished ones and the running one), `tool_calls` and `tool_calls_by_tool` (every call, running ones included; canonical names: an alias counts under its tool, a `call_tool` call under the inner tool), `steps` (the loop rounds that dispatched tools, `/stats`' iterations) and `active_ms` (`active_minutes` too): work time, the generations' time plus the finished tool calls' time, never the waits between them (a question, an approval, the user's answer to `ask_user_question`), with `tool_ms_by_tool` its tool part by tool, so a waiting tool's time (`task_wait`) can be left out. Its counts are the ones `/stats` and `chi sessions stats` show, earlier workers' included. Use its methods, not its class. Needs chi 0.100.0 (guard it with `ctx.respond_to?(:usage)`) |
769
830
  | `ctx.turn_author` | who wrote the running turn when it isn't the session's owner: a participant's `"p:<id>"` (`chi share`); nil for the owner's turns and between turns. Every hook event of such a turn carries the same as `author:`. The handler runs with the owner's power whatever the author ([hooks](hooks.md#participants-turns)). Needs chi 0.105.0 (guard it with `ctx.respond_to?(:turn_author)`) |
@@ -788,6 +849,7 @@ full label, `plugin.rb (bundle my-bundle)`.
788
849
  | `ctx.use_toolkit(name)` | put the session on toolkit `name` (yours from `chi.toolkit`, or chi's): saved, applied at the next turn's start and announced (`toolkit> toolkit: NAME from the next turn's start`, the web's tools chip follows). Only before the session's first model reply and outside a turn (the declared tools start every request, and a plugin doesn't ask first); otherwise it returns false and changes nothing: tell the user to run `/toolkit NAME`. An unknown name raises ArgumentError. Needs chi 0.66.0 |
789
850
  | `ctx.load_tools(names)` | load tools the session reaches but doesn't declare, so the model calls them by name: built-ins (`"delegate"`, `"chi/delegate"`), MCP tools (`"mcp_fs_read"`, `"fs/read"`). Returns the names it staged (the wire names); a declared tool, one loaded before, a removed or unknown one is skipped (an unknown one logged), so calling it every turn is fine. From a `before_turn` hook it loads for that turn; anywhere else (mid-turn too) from the next turn's start. Call it from a hook: before the session's first turn its toolkit isn't applied yet and nothing resolves. **A hint, not access**: the tools keep their guardrails and approvals, and only a host that takes tool changes (`api: anthropic`, not Fireworks) gets one, as a system message after the user's (`[tools loaded: delegate (your-bundle)]` in the session, a notice line live); elsewhere nothing is sent or saved, and the tools stay reachable with `call_tool`. A hook may switch the model after you call it, so the return value can't promise delivery. Raises ArgumentError for anything but an Array. Needs chi 0.99.0 |
790
851
  | `ctx.context` | attach outside text to this session and list it: see [Attached context](#attached-context-ctxcontext) |
852
+ | `ctx.cache_confirm(line, changed:)` | inside a command: nil to apply a prompt-cache-restarting change now, or a confirm to return as `[text, confirm]`: see [Commands that ask](#commands-that-ask-ctxcache_confirm). Needs chi 0.132.0 |
791
853
 
792
854
  The Engine itself is never handed to a plugin.
793
855
 
@@ -1211,6 +1273,10 @@ bundle.fire(:after_turn, status: "completed") # → the event, after
1211
1273
  bundle.question_asked("Fix JIRA-1?", question: { question: "Go?" }) # → the refs, as chi's seams fire it
1212
1274
  event = bundle.before_tool_call_event({ name: "read", path: "a.txt" }, params: "read a.txt")
1213
1275
  bundle.command("/mine stop") # → what the command returned
1276
+ bundle.command("/tone x", confirm: true) # sent from a web tab or a TUI: ctx.cache_confirm may ask
1277
+ bundle.set(history: true, context_tokens: 900) # a model reply to restart (ctx.cache_confirm), its size
1278
+ bundle.set(model_names: %w[tiny box:gemma gemma]) # ctx.model_names (default: model_name and its id)
1279
+ bundle.prompt_text # the prompt sections' text, as a build runs them
1214
1280
  bundle.tool("my_tool", path: "a.txt") # → the text the model reads
1215
1281
  verdict = bundle.tool_call({ name: "execute", content: "rm -rf /" })
1216
1282
  verdict.deny? # a real Guardrails::Verdict (votes: verdict.votes)
@@ -2510,11 +2576,12 @@ gem.
2510
2576
  `chi bundle install model-notes` (or the `dev` profile) installs reviewed
2511
2577
  [model notes](memory.md#model-notes) into the system scope: working habits
2512
2578
  for one model family, loaded into the prompt only for the models a note's
2513
- `models:` line names. It has no plugin or hook. It ships one note:
2579
+ `models:` line names. It has no plugin or hook. It ships two notes:
2514
2580
 
2515
2581
  | note | models | for |
2516
2582
  |---|---|---|
2517
2583
  | `model_notes_deepseek` | `*deepseek-v4*` | DeepSeek V4 / V4.1: explore briefly then act, commit one item at a time, stop digging, `memory_read` for memories, file tools rather than heredocs that guardrail rules ask about |
2584
+ | `model_notes_tone` | `*` | every model: answer first, in plain words; no preambles, restated questions or closing offers; lists only for steps or parallel items |
2518
2585
 
2519
2586
  - **Reviewed before it ships.** A note is generic (no repo or machine facts),
2520
2587
  says nothing the harness already does (a tool's description, an alias),
@@ -5,7 +5,9 @@ require_relative "config"
5
5
  require_relative "model_profile"
6
6
  require_relative "host_registry"
7
7
  require_relative "host_keys"
8
+ require_relative "chi_home"
8
9
  require_relative "gem_update"
10
+ require_relative "installed_gem"
9
11
  require_relative "bootstrap/probe"
10
12
  require_relative "bootstrap/config_writer"
11
13
  require_relative "bootstrap/bundles"
@@ -87,8 +89,10 @@ module Samagotchi
87
89
  # the process ENV points, not +env+)
88
90
  # @param gem_runner [#run] runs `gem install commonmarker` (GemUpdate::Runner)
89
91
  # @param gem_present [#call, nil] → whether commonmarker is installed
92
+ # @param home [#current, #record_first!] the chi home (ChiHome)
90
93
  def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, env: ENV, probe: nil, config_path: nil,
91
- platform: RbConfig::CONFIG["host_os"], bundles: nil, gem_runner: GemUpdate::Runner.new, gem_present: nil)
94
+ platform: RbConfig::CONFIG["host_os"], bundles: nil, gem_runner: GemUpdate::Runner.new, gem_present: nil,
95
+ gem_spec: InstalledGem.spec, home: ChiHome)
92
96
  @argv = argv.dup
93
97
  @stdin = stdin
94
98
  @stdout = stdout
@@ -99,8 +103,14 @@ module Samagotchi
99
103
  @platform = platform
100
104
  @bundles = bundles || Bootstrap::Bundles.new
101
105
  @gem_runner = gem_runner
102
- # Installed, without loading its native extension into this process.
103
- @gem_present = gem_present || -> { Gem::Specification.find_all_by_name(MARKDOWN_GEM).any? }
106
+ @gem_spec = gem_spec
107
+ @home = home
108
+ # Installed (in the chi home's gems when there is one: chi web runs
109
+ # there), without loading its native extension into this process.
110
+ @gem_present = gem_present || lambda {
111
+ home = @home.current(gem_spec: @gem_spec)
112
+ home ? home.gem?(MARKDOWN_GEM) : Gem::Specification.find_all_by_name(MARKDOWN_GEM).any?
113
+ }
104
114
  end
105
115
 
106
116
  # @return [Integer] 0 written (or already there), 1 failed (a failed
@@ -371,6 +381,7 @@ module Samagotchi
371
381
  # each. Then Markdown in chi web (#offer_markdown). 1 when a bundle
372
382
  # failed; the Markdown step never changes it.
373
383
  def install_bundles(dry_run: false)
384
+ record_home unless dry_run
374
385
  ok = system_bundle_line(@bundles.sync_system(dry_run: dry_run), dry_run)
375
386
  ok = install_profile(CORE, dry_run) && ok
376
387
  unless dry_run || !tty? || (members = dev_to_install).empty?
@@ -409,13 +420,31 @@ module Samagotchi
409
420
  false
410
421
  end
411
422
 
412
- # true when commonmarker is there or `gem install` (this Ruby's own gem
413
- # command) put it there; else one line with gem's last word on it.
423
+ # The first chi home (ChiHome.record_first!: the helper's Ruby, else
424
+ # this one), one line; nothing when one is recorded already.
425
+ def record_home
426
+ recorded = @home.record_first!(gem_spec: @gem_spec, launch_path: helper_launch_path)
427
+ @stdout.puts("chi home: #{recorded.ruby} (chi home set to change)") if recorded
428
+ end
429
+
430
+ def helper_launch_path
431
+ return nil unless @platform.to_s.include?("darwin")
432
+
433
+ require_relative "desktop/macos"
434
+ Desktop::MacOS.new(env: @env, register: false).launch_path
435
+ end
436
+
437
+ # true when commonmarker is there or `gem install` (the chi home's gem
438
+ # command, else this Ruby's own) put it there; else one line with gem's
439
+ # last word on it.
414
440
  def install_markdown_gem
415
441
  return true if @gem_present.call
416
442
 
417
443
  @stdout.puts("#{MARKDOWN_GEM}: installing…")
418
- output, ok = @gem_runner.run([GemUpdate.gem_bin, "install", MARKDOWN_GEM, "--no-document"])
444
+ home = @home.current(gem_spec: @gem_spec)
445
+ argv = [ChiHome.gem_bin(home), "install", MARKDOWN_GEM, "--no-document"]
446
+ env = ChiHome.install_env(home, env: @env)
447
+ output, ok = env.empty? ? @gem_runner.run(argv) : @gem_runner.run(argv, env: env)
419
448
  return true if ok
420
449
 
421
450
  last = output.to_s.lines.map(&:strip).reject(&:empty?).last || "no output"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: model-notes
3
- version: 0.1.0
3
+ version: 0.2.0
4
4
  scope: system
5
5
  description: "Reviewed model notes: working habits a model family needs, loaded into the prompt only for the models each note's models: line names"
6
6
  trust_level: reviewed
@@ -9,3 +9,6 @@ files:
9
9
  model_notes_deepseek.md:
10
10
  sha256: sha256:0b07e40edc1a3d4a3ad6bea08d9b338913f78cab19a5809bcf4593f459f9bc5e
11
11
  description: "DeepSeek V4 working habits: explore briefly then act, commit per item, stop digging, memory_read for memories, file tools over heredocs"
12
+ model_notes_tone.md:
13
+ sha256: sha256:e47a93dc3b3cb78a9bd3eb7f04ef1c4d2a30b4a7c0110a66e1e3a26e363827ca
14
+ description: "Concise answers for every model (models: *): answer first, no preambles or closing offers"
@@ -0,0 +1,3 @@
1
+ models: *
2
+ How to write answers:
3
+ - Answer first, in plain words. Skip preambles, restating the question and closing offers. Use a list only for steps or several parallel items.
@@ -35,3 +35,17 @@ bundle itself.
35
35
  The dates and tasks differ, so this is a hint, not an A/B: an A/B is the same kind of task with the note on and
36
36
  muted (`chi send --new --mute model_notes_deepseek`). The shipped text is shorter than the one measured (795
37
37
  characters against 1,519) and keeps its habits.
38
+
39
+ ## model_notes_tone
40
+
41
+ - **models:** `*`: every model. It stacks on a family's note (`spec/model_ids.yml` `expected_overlaps`).
42
+ - **Origin, 2026-10-29:** chi's prompt has no style guidance, so each model talks in its own voice and answers run
43
+ long. A `/tone` bundle (presets, a session command) was built and parked; the user chose the lighter way, a model
44
+ note, with the gentlest text: only the anti-padding part of the concise preset ("leave out what went as expected"
45
+ was dropped: it can hide useful confirmations).
46
+ - **Approved:** the text as shipped, by the user, 2026-10-29 (after the check below); ships in model-notes (the `dev`
47
+ profile) first, `core` later only after more evidence (other model families, a week of the user's own use).
48
+ - **Evidence:** a with/without check on the local Qwen3.8 Flash Next (Splash), 5 tasks (4 questions about this repo,
49
+ 1 small code edit), one run each, `chi --non-interactive` with isolated state: answers 12.5k → 10.1k characters
50
+ (−19%), 4 of 5 shorter; openings more direct, closing summaries gone; nothing a task needed was lost (dropped: one
51
+ worked glob example, one aside about the model's own fixed mistake). N=1 per task: a direction, not a measurement.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.131.0
3
+ version: 0.132.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -12,6 +12,9 @@ module Samagotchi
12
12
  # The costs worth asking about (Thinking.cache_cost,
13
13
  # ReasoningHistory.cache_cost); :tail and nil are not.
14
14
  COSTLY = %i[full provider history].freeze
15
+ # Thread.current key: whether the plugin command running on this
16
+ # thread may ask (#asking).
17
+ ASKING = :samagotchi_cache_confirm_asking
15
18
 
16
19
  # The line that applies a change, and the question the reply ends with.
17
20
  Confirm = Data.define(:line, :text) do
@@ -42,6 +45,21 @@ module Samagotchi
42
45
  changed && history && COSTLY.include?(cost)
43
46
  end
44
47
 
48
+ # Run +block+ as a plugin command whose sender can answer a confirm
49
+ # (+flag+: SessionCommands#run's confirm, false once the line has
50
+ # --yes): Plugin::Context#cache_confirm asks only inside it, on this
51
+ # thread. Nests; threads the block starts don't inherit it.
52
+ def asking(flag)
53
+ previous = Thread.current[ASKING]
54
+ Thread.current[ASKING] = flag ? true : false
55
+ yield
56
+ ensure
57
+ Thread.current[ASKING] = previous
58
+ end
59
+
60
+ # Whether the plugin command on this thread may ask (#asking).
61
+ def asking? = Thread.current[ASKING] == true
62
+
45
63
  # Whether a cache-changing command should ask, and its reply line.
46
64
  # Kept independent of any UI so commands and plugins can share it.
47
65
  # @param line [String] the full command line without --yes
@@ -0,0 +1,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "open3"
6
+ require "rbconfig"
7
+ require "time"
8
+ require_relative "atomic_file"
9
+ require_relative "installed_gem"
10
+ require_relative "log"
11
+ require_relative "paths"
12
+ require_relative "version"
13
+
14
+ module Samagotchi
15
+ # The chi home: the one Ruby (and its gem path) every chi launch boots,
16
+ # whoever launches it (a terminal on another Ruby, chi web, the macOS
17
+ # helper under launchd). Recorded in home.json under the config dir
18
+ # ($XDG_CONFIG_HOME/samagotchi/home.json) by `chi home set`, or once, the
19
+ # first time, by chi bootstrap or chi update (#record_first!); never
20
+ # overwritten implicitly.
21
+ #
22
+ # One mechanism (docs/architecture.md, "How chi launches itself"):
23
+ # Record#boot_argv puts `Gem.paths = {…}` at the front of a `ruby -e`
24
+ # boot string, so no GEM_HOME/GEM_PATH ever goes into an environment
25
+ # (the tools a worker runs keep the spawner's, or the project's). Every
26
+ # launch site asks #current at launch time and keeps its own argv when
27
+ # there is no usable home.
28
+ module ChiHome
29
+ FILE = "home.json"
30
+ # What a launch outside a worker boots: the newest chi in the home,
31
+ # run as its own `chi` executable (ARGV after the "--").
32
+ CHI_BOOT = "gem 'samagotchi', '>= 0.a'; load Gem.bin_path('samagotchi', 'chi')"
33
+ # Where `mise` usually is when PATH doesn't have it (launchd's bare PATH).
34
+ MISE_DIRS = ["/opt/homebrew/bin", "/usr/local/bin", File.join(Dir.home, ".local", "bin")].freeze
35
+ # What a probe of another Ruby must not inherit: they would move it off
36
+ # its own gems.
37
+ PROBE_UNSET = %w[GEM_HOME GEM_PATH RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH].freeze
38
+ PROBE = "require 'json'; print JSON.generate('ruby' => RbConfig.ruby, 'gem_home' => Gem.dir, " \
39
+ "'gem_path' => Gem.path.join(File::PATH_SEPARATOR))"
40
+ GEMSPEC = /\Asamagotchi-(#{Gem::Version::VERSION_PATTERN})\.gemspec\z/
41
+
42
+ class Invalid < StandardError
43
+ end
44
+
45
+ # home.json's fields. +gem_path+ is what `Gem.paths=` takes (dirs joined
46
+ # by File::PATH_SEPARATOR); +mise+ an absolute path or nil.
47
+ Record = Data.define(:ruby, :gem_home, :gem_path, :mise, :set_at, :set_by_version) do
48
+ # home.json's (or a probe's) fields, checked: ruby, gem_home and every
49
+ # gem_path entry absolute paths, mise an absolute path or nil.
50
+ # @raise [Invalid]
51
+ def self.from_h(hash)
52
+ fields = members.to_h { |key| [key, hash[key.to_s]] }
53
+ %i[ruby gem_home gem_path].each do |key|
54
+ value = fields[key]
55
+ raise Invalid, "#{key} is missing" unless value.is_a?(String) && !value.empty?
56
+ end
57
+ raise Invalid, "ruby is not an absolute path" unless fields[:ruby].start_with?("/")
58
+ raise Invalid, "gem_home is not an absolute path" unless fields[:gem_home].start_with?("/")
59
+
60
+ dirs = fields[:gem_path].split(File::PATH_SEPARATOR)
61
+ raise Invalid, "gem_path has a relative entry" unless dirs.all? { |dir| dir.start_with?("/") }
62
+
63
+ mise = fields[:mise]
64
+ raise Invalid, "mise is not an absolute path" unless mise.nil? || (mise.is_a?(String) && mise.start_with?("/"))
65
+
66
+ new(**fields)
67
+ end
68
+
69
+ def to_h = members.to_h { |key| [key.to_s, public_send(key)] }
70
+ def gem_dirs = gem_path.split(File::PATH_SEPARATOR).reject(&:empty?)
71
+ def spec_dirs = gem_dirs.map { |dir| File.join(dir, "specifications") }
72
+ # The home Ruby's own gem command (its bindir, not a shim).
73
+ def gem_bin = File.join(File.dirname(ruby), "gem")
74
+ def ruby_ok? = File.file?(ruby) && File.executable?(ruby)
75
+
76
+ # The samagotchi versions installed in the home, newest last.
77
+ # @return [Array<Gem::Version>]
78
+ def versions
79
+ spec_dirs.flat_map do |dir|
80
+ Dir.children(dir).filter_map { |name| GEMSPEC.match(name)&.[](1) }
81
+ rescue SystemCallError
82
+ []
83
+ end.select { |v| Gem::Version.correct?(v) }.map { |v| Gem::Version.new(v) }.uniq.sort
84
+ end
85
+
86
+ def newest_version = versions.last
87
+
88
+ # Whether a gem named +name+ is installed in the home.
89
+ def gem?(name)
90
+ spec_dirs.any? { |dir| !Dir.glob(File.join(dir, "#{name}-*.gemspec")).empty? }
91
+ end
92
+
93
+ # The `Gem.paths =` line every boot starts with: string literals only
94
+ # (String#inspect escapes `#{`), no env read.
95
+ def prelude = "Gem.paths = {'GEM_HOME' => #{gem_home.inspect}, 'GEM_PATH' => #{gem_path.inspect}}"
96
+
97
+ # [ruby, "-e", "<prelude>; <run>"]: a site appends "--" and its args
98
+ # when +run+ runs chi's executable (CHI_BOOT).
99
+ def boot_argv(run:) = [ruby, "-e", "#{prelude}; #{run}"]
100
+
101
+ # Whether this process runs on the home: its Ruby and its gem dir.
102
+ def running_here?(ruby: RbConfig.ruby, gem_dir: Gem.dir)
103
+ ChiHome.real(ruby) == ChiHome.real(self.ruby) && ChiHome.real(gem_dir) == ChiHome.real(gem_home)
104
+ end
105
+ end
106
+
107
+ @warned = {}
108
+ @warn_mutex = Mutex.new
109
+
110
+ module_function
111
+
112
+ # @return [String] $XDG_CONFIG_HOME/samagotchi/home.json
113
+ def path(env: ENV) = File.join(Paths.config_home(env: env), Paths::APP_DIR, FILE)
114
+
115
+ # The record as home.json has it, usable or not (chi home shows it);
116
+ # nil without one.
117
+ # @raise [Invalid] a file that isn't home.json's shape
118
+ def read(path: self.path)
119
+ return nil unless File.file?(path)
120
+
121
+ data = JSON.parse(File.read(path))
122
+ raise Invalid, "#{Paths.tilde(path)} is not a JSON object" unless data.is_a?(Hash)
123
+
124
+ Record.from_h(data)
125
+ rescue JSON::ParserError, SystemCallError => e
126
+ raise Invalid, "#{Paths.tilde(path)}: #{e.message.lines.first.to_s.strip}"
127
+ rescue Invalid => e
128
+ raise if e.message.start_with?(Paths.tilde(path))
129
+
130
+ raise Invalid, "#{Paths.tilde(path)}: #{e.message}"
131
+ end
132
+
133
+ # The home every launch boots, or nil, where each site keeps its own
134
+ # argv: no home.json; one that isn't its shape (warned once); its ruby
135
+ # gone (a Ruby upgrade); no samagotchi in its gem path; or this chi
136
+ # runs from a source checkout (a worktree's bin/chi spawns its own code).
137
+ # @param gem_spec [Gem::Specification, nil] the installed gem this runs from
138
+ # @return [Record, nil]
139
+ def current(path: self.path, gem_spec: InstalledGem.spec)
140
+ return nil unless gem_spec
141
+
142
+ home = read(path: path)
143
+ return nil unless home
144
+
145
+ why = problem(home)
146
+ why ? warn_once(:unusable, path: path, problem: why) : home
147
+ rescue Invalid => e
148
+ warn_once(:invalid, path: path, error: e.message)
149
+ end
150
+
151
+ # Why a recorded home can't boot chi, or nil.
152
+ # @return [String, nil]
153
+ def problem(home)
154
+ return "home ruby missing: #{home.ruby}" unless home.ruby_ok?
155
+ return "no samagotchi in the home's gems (#{home.gem_path})" if home.versions.empty?
156
+
157
+ nil
158
+ end
159
+
160
+ # The home's boot, or nil without a usable home (#current).
161
+ # @return [Array<String>, nil]
162
+ def boot_argv(run:, home: current) = home&.boot_argv(run: run)
163
+
164
+ # Where a gem install lands: the home's gem dir, else this process's
165
+ # (UpdateCheck's writability).
166
+ def gem_dir(gem_spec: InstalledGem.spec) = current(gem_spec: gem_spec)&.gem_home || Gem.dir
167
+
168
+ # The specification dirs new workers activate chi from: the home's,
169
+ # else this process's (InstalledVersions' "installed; restart").
170
+ def spec_dirs(gem_spec: InstalledGem.spec) = current(gem_spec: gem_spec)&.spec_dirs || Gem::Specification.dirs
171
+
172
+ # The gem command a gem install runs: the home's, else this Ruby's own.
173
+ def gem_bin(home = current) = home ? home.gem_bin : File.join(RbConfig::CONFIG["bindir"], "gem")
174
+
175
+ # The env for a gem install child (chi update, chi bootstrap's
176
+ # commonmarker): into the home's gems whatever the spawner's
177
+ # GEM_HOME/GEM_PATH say (a chruby shell, an old helper's launch file),
178
+ # and with mise's dir on PATH (mise's rubygems plugin runs `mise
179
+ # reshim` after an install). {} without a home: the env as today.
180
+ # @return [Hash{String=>String}]
181
+ def install_env(home = current, env: ENV)
182
+ return {} unless home
183
+
184
+ result = { "GEM_HOME" => home.gem_home, "GEM_PATH" => home.gem_path }
185
+ if home.mise
186
+ dirs = env["PATH"].to_s.split(File::PATH_SEPARATOR)
187
+ mise_dir = File.dirname(home.mise)
188
+ result["PATH"] = (dirs + [mise_dir]).uniq.join(File::PATH_SEPARATOR) unless dirs.include?(mise_dir)
189
+ end
190
+ result
191
+ end
192
+
193
+ # The fallback pin for a worker's `>= 0.a` (a broken newest install):
194
+ # the spawner's own version when the home has it, else the home's
195
+ # newest.
196
+ # @return [String, nil]
197
+ def fallback_version(home, gem_spec)
198
+ versions = home.versions
199
+ own = gem_spec && Gem::Version.new(gem_spec.version.to_s)
200
+ (own && versions.include?(own) ? own : versions.last)&.to_s
201
+ end
202
+
203
+ # This process's Ruby and gem path as a record (chi home set without
204
+ # --ruby, a first record from the running chi).
205
+ def from_running(env: ENV)
206
+ Record.new(ruby: real(RbConfig.ruby), gem_home: Gem.dir, gem_path: Gem.path.join(File::PATH_SEPARATOR),
207
+ mise: find_mise(env: env), set_at: Time.now.utc.iso8601, set_by_version: VERSION)
208
+ end
209
+
210
+ # Another Ruby's own gem dirs, asked of it (its GEM_*/RUBYOPT unset
211
+ # unless +gem_env+ gives the GEM_HOME/GEM_PATH its chi lived under).
212
+ # @param runner [#call] (env, argv) → [output, success]
213
+ # @return [Record, nil] nil when it doesn't run or answer
214
+ def probe(ruby, gem_env: {}, env: ENV, runner: method(:capture))
215
+ child_env = PROBE_UNSET.to_h { |key| [key, nil] }.merge(gem_env.slice("GEM_HOME", "GEM_PATH").compact)
216
+ output, ok = runner.call(child_env, [ruby, "-e", PROBE])
217
+ return nil unless ok
218
+
219
+ data = JSON.parse(output.to_s.lines.last.to_s)
220
+ return nil unless data.is_a?(Hash)
221
+
222
+ Record.from_h(data.merge("ruby" => data["ruby"].is_a?(String) ? real(data["ruby"]) : data["ruby"],
223
+ "mise" => find_mise(env: env), "set_at" => Time.now.utc.iso8601,
224
+ "set_by_version" => VERSION))
225
+ rescue JSON::ParserError, TypeError, Invalid
226
+ nil
227
+ end
228
+
229
+ # @raise [SystemCallError]
230
+ def write!(home, path: self.path)
231
+ FileUtils.mkdir_p(File.dirname(path))
232
+ AtomicFile.write(path, "#{JSON.pretty_generate(home.to_h)}\n", perm: 0o644)
233
+ home
234
+ end
235
+
236
+ # The first record, once, from an installed chi (chi bootstrap, chi
237
+ # update): of the macOS helper's ruby (when its launch file names one
238
+ # whose newest chi is at least this one: the Ruby it already runs) and
239
+ # this chi's, the one with the newest chi; the helper's on a tie.
240
+ # Never over an existing home.json, never from a checkout or under
241
+ # Bundler, never a Ruby without samagotchi.
242
+ # @param launch_path [String, nil] the helper's launch.json
243
+ # @param bundler [Boolean, nil] running under Bundler (nil: found out)
244
+ # @return [Record, nil] the home written, nil when none was
245
+ def record_first!(gem_spec: InstalledGem.spec, launch_path: nil, path: self.path, env: ENV,
246
+ runner: method(:capture), bundler: nil)
247
+ return nil unless gem_spec
248
+ return nil if bundler.nil? ? under_bundler?(env) : bundler
249
+ return nil if File.exist?(path)
250
+
251
+ own = Gem::Version.new(VERSION)
252
+ helper = helper_home(launch_path, env: env, runner: runner)
253
+ helper = nil if helper && (helper.newest_version.nil? || helper.newest_version < own)
254
+ home = [helper, from_running(env: env)].compact.select { |candidate| problem(candidate).nil? }
255
+ .max_by(&:newest_version)
256
+ home && write!(home, path: path)
257
+ rescue SystemCallError => e
258
+ Log.warn(:config, "home_record_failed", error: e.class.name, msg: e.message)
259
+ nil
260
+ end
261
+
262
+ # The helper's launch.json ruby (argv[0]) probed with the GEM_* it was
263
+ # installed with; nil without one.
264
+ def helper_home(launch_path, env:, runner:)
265
+ return nil unless launch_path && File.file?(launch_path)
266
+
267
+ launch = JSON.parse(File.read(launch_path))
268
+ ruby = Array(launch["argv"]).first if launch.is_a?(Hash)
269
+ return nil unless ruby.is_a?(String) && ruby.start_with?("/") && File.executable?(ruby)
270
+
271
+ gem_env = launch["env"].is_a?(Hash) ? launch["env"] : {}
272
+ probe(ruby, gem_env: gem_env, env: env, runner: runner)
273
+ rescue JSON::ParserError, SystemCallError
274
+ nil
275
+ end
276
+
277
+ # The test UpdateCommand's @bundler makes: a first record from a
278
+ # `bundle exec` would name the bundle's gems.
279
+ def under_bundler?(env = ENV) = !!(defined?(::Bundler) || !env["BUNDLE_GEMFILE"].to_s.empty?)
280
+
281
+ # mise's absolute path: on +env+'s PATH, else in MISE_DIRS; nil without.
282
+ def find_mise(env: ENV)
283
+ dirs = env["PATH"].to_s.split(File::PATH_SEPARATOR).select { |dir| dir.start_with?("/") } + MISE_DIRS
284
+ dirs.map { |dir| File.join(dir, "mise") }.find { |file| File.file?(file) && File.executable?(file) }
285
+ end
286
+
287
+ def real(path)
288
+ File.realpath(path)
289
+ rescue SystemCallError, TypeError
290
+ path.to_s
291
+ end
292
+
293
+ def capture(env, argv)
294
+ output, status = Open3.capture2e(env, *argv)
295
+ [output, status.success?]
296
+ rescue SystemCallError => e
297
+ [e.message, false]
298
+ end
299
+
300
+ # Logs a home that can't be used, once per process and reason; nil.
301
+ def warn_once(event, **fields)
302
+ key = [event, fields]
303
+ first = @warn_mutex.synchronize { @warned[key] ? false : (@warned[key] = true) }
304
+ Log.warn(:config, "home_#{event}", **fields) if first
305
+ nil
306
+ end
307
+
308
+ # Specs: forget what was warned.
309
+ def reset_warnings! = @warn_mutex.synchronize { @warned.clear }
310
+ end
311
+ end