samagotchi 0.9.0 → 0.10.1

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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -1
  3. data/docs/architecture.md +24 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/guardrails.md +5 -3
  6. data/docs/hooks.md +44 -7
  7. data/docs/internals/gemma4-contract.md +1 -0
  8. data/docs/plugins.md +36 -13
  9. data/docs/sessions.md +1 -1
  10. data/lib/samagotchi/bootstrap_command.rb +29 -29
  11. data/lib/samagotchi/bridge/card_store.rb +4 -4
  12. data/lib/samagotchi/bridge/pending_card.rb +2 -2
  13. data/lib/samagotchi/bridge/sse_writer.rb +22 -28
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
  15. data/lib/samagotchi/bridge.rb +59 -112
  16. data/lib/samagotchi/bridge_client.rb +11 -28
  17. data/lib/samagotchi/bundle_command.rb +158 -212
  18. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
  20. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +13 -4
  21. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
  24. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  25. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
  26. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/skills/plugin.rb +18 -9
  28. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
  29. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  31. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  32. data/lib/samagotchi/cli/command.rb +51 -0
  33. data/lib/samagotchi/cli/flags.rb +116 -0
  34. data/lib/samagotchi/config.rb +70 -55
  35. data/lib/samagotchi/config_text_edit.rb +107 -0
  36. data/lib/samagotchi/desktop_command.rb +24 -18
  37. data/lib/samagotchi/edit_preview.rb +2 -2
  38. data/lib/samagotchi/engine.rb +20 -75
  39. data/lib/samagotchi/events.rb +23 -0
  40. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  41. data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
  42. data/lib/samagotchi/hooks/loader.rb +23 -33
  43. data/lib/samagotchi/hooks/registry.rb +41 -0
  44. data/lib/samagotchi/hooks.rb +0 -3
  45. data/lib/samagotchi/host_registry.rb +15 -0
  46. data/lib/samagotchi/idle_reminders.rb +0 -7
  47. data/lib/samagotchi/image_store.rb +22 -0
  48. data/lib/samagotchi/kernel_loop.rb +12 -16
  49. data/lib/samagotchi/live_versions.rb +2 -13
  50. data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
  51. data/lib/samagotchi/memory_bundle/builder.rb +1 -14
  52. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
  53. data/lib/samagotchi/memory_bundle/installer.rb +1 -27
  54. data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
  55. data/lib/samagotchi/memory_bundle/status.rb +1 -14
  56. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
  57. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
  58. data/lib/samagotchi/memory_paths.rb +10 -0
  59. data/lib/samagotchi/note_command.rb +23 -37
  60. data/lib/samagotchi/owner_lock.rb +14 -3
  61. data/lib/samagotchi/plugin/api.rb +11 -17
  62. data/lib/samagotchi/plugin/context.rb +70 -15
  63. data/lib/samagotchi/plugin/loader.rb +2 -3
  64. data/lib/samagotchi/sampling_settings.rb +3 -5
  65. data/lib/samagotchi/self_report.rb +6 -9
  66. data/lib/samagotchi/send_command.rb +33 -52
  67. data/lib/samagotchi/session.rb +88 -92
  68. data/lib/samagotchi/session_archive_command.rb +8 -14
  69. data/lib/samagotchi/session_commands.rb +3 -21
  70. data/lib/samagotchi/session_delete_command.rb +15 -29
  71. data/lib/samagotchi/session_manager.rb +27 -13
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
  74. data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
  75. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  76. data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
  77. data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
  78. data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
  79. data/lib/samagotchi/terminal_ui.rb +156 -234
  80. data/lib/samagotchi/thinking.rb +2 -3
  81. data/lib/samagotchi/thought_stream_splitter.rb +10 -9
  82. data/lib/samagotchi/tool_call_parser.rb +56 -292
  83. data/lib/samagotchi/tools/builtin_calls.rb +146 -0
  84. data/lib/samagotchi/tools/builtins.rb +2 -1
  85. data/lib/samagotchi/tools/edit.rb +21 -29
  86. data/lib/samagotchi/tools/memory.rb +1 -11
  87. data/lib/samagotchi/tools/send_note.rb +1 -1
  88. data/lib/samagotchi/update_command.rb +32 -22
  89. data/lib/samagotchi/version.rb +1 -1
  90. data/lib/samagotchi/vision_support.rb +4 -5
  91. data/lib/samagotchi/web/app.rb +89 -151
  92. data/lib/samagotchi/web/public/activity.js +1 -11
  93. data/lib/samagotchi/web/public/annotate_bar.js +120 -0
  94. data/lib/samagotchi/web/public/app.js +158 -785
  95. data/lib/samagotchi/web/public/chunk_router.js +7 -6
  96. data/lib/samagotchi/web/public/command_list.js +99 -0
  97. data/lib/samagotchi/web/public/data.js +22 -66
  98. data/lib/samagotchi/web/public/format.js +0 -23
  99. data/lib/samagotchi/web/public/index.html +0 -2
  100. data/lib/samagotchi/web/public/model_pick.js +0 -7
  101. data/lib/samagotchi/web/public/notifications.js +169 -0
  102. data/lib/samagotchi/web/public/question_cards.js +352 -0
  103. data/lib/samagotchi/web/public/session_state.js +34 -0
  104. data/lib/samagotchi/web/public/timing.js +1 -1
  105. data/lib/samagotchi/web/public/turn_events.js +11 -2
  106. data/lib/samagotchi/web/public/turn_model.js +0 -14
  107. data/lib/samagotchi/web/session_hub.rb +6 -4
  108. data/lib/samagotchi/web/session_summary.rb +5 -3
  109. data/lib/samagotchi/worker.rb +59 -53
  110. data/lib/samagotchi/worker_sidecar.rb +27 -1
  111. metadata +12 -1
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: loop-guard
3
- version: 0.2.0
3
+ version: 0.2.2
4
4
  scope: system
5
5
  description: "Breaks loops: the same tool call with the same result again and again in one turn is denied with advice (after a few denies the turn stops with a card), and thinking that repeats itself while it streams is cut and the model asked again"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:5a0dc653a654e73d881767c8b7b9486d4ac02cfc7823a150132593ffa2cbba04
9
+ sha256: sha256:e62c68d1a2280adf55b150488d0d85bbbc35ffce54dfed387a86adceb85e4d34
10
10
  requires_chi: ">= 0.6.0"
@@ -187,13 +187,13 @@ end
187
187
  class Plugin
188
188
  DEFAULT_IGNORE = %w[task_wait task_get delegate_result list_sessions list_reminders].freeze
189
189
  # Built-in tools carry flat fields; plugin and unknown tools carry args:.
190
- KEY_FIELDS = %i[content path start_line end_line cwd env scope].freeze
190
+ # An edit's text is its old_text/new_text.
191
+ KEY_FIELDS = %i[content path start_line end_line cwd env scope old_text new_text].freeze
191
192
  SHORT_CHARS = 60
192
193
  THOUGHT_CHARS = 80
193
194
  SOURCE = "bundle loop-guard"
194
195
 
195
196
  def initialize(settings = {})
196
- settings = {} unless settings.is_a?(Hash)
197
197
  @deny_after = positive(settings["deny_after"]) || 2
198
198
  @stop_after = positive(settings["stop_after"]) || 4
199
199
  @ignore = settings.key?("ignore_tools") ? Array(settings["ignore_tools"]).map(&:to_s) : DEFAULT_IGNORE
@@ -234,16 +234,16 @@ class Plugin
234
234
  ctx.notify("thinking repeats itself (#{what})", level: :warn)
235
235
  when "retry"
236
236
  @thinking_loops += 1
237
- return stop_thinking(event, ctx, found, what) if @thinking_loops > 1
237
+ return stop_thinking(ctx, found, what) if @thinking_loops > 1
238
238
 
239
- ctx.notify("thinking repeats itself (#{what}): cut", level: :warn) if event[:stop_generation]&.call("its thinking kept repeating itself")
239
+ ctx.notify("thinking repeats itself (#{what}): cut", level: :warn) if ctx.stop_generation("its thinking kept repeating itself")
240
240
  else
241
- stop_thinking(event, ctx, found, what)
241
+ stop_thinking(ctx, found, what)
242
242
  end
243
243
  end
244
244
 
245
- def stop_thinking(event, ctx, found, what)
246
- return unless event[:stop_turn]&.call("the model's thinking kept repeating itself (#{what})")
245
+ def stop_thinking(ctx, found, what)
246
+ return unless ctx.stop_turn("the model's thinking kept repeating itself (#{what})")
247
247
 
248
248
  lines = found.sentences.first(3).map { |sentence| "- \"#{cut(sentence, THOUGHT_CHARS)}\"" }
249
249
  ctx.card(title: "loop-guard stopped the turn",
@@ -288,7 +288,7 @@ class Plugin
288
288
  verdict.deny!("repeated call", source: SOURCE, advice: advice(key, times))
289
289
  warn(ctx, key, "repeated, denied")
290
290
  @denials += 1
291
- stop(event, ctx) if @denials == @stop_after
291
+ stop(ctx) if @denials == @stop_after
292
292
  end
293
293
 
294
294
  # A call's result, paired with the before_tool_call that keyed it. A call
@@ -308,8 +308,8 @@ class Plugin
308
308
  @previews[key] = preview(output)
309
309
  end
310
310
 
311
- def stop(event, ctx)
312
- event[:stop_turn]&.call("the model kept repeating the same calls")
311
+ def stop(ctx)
312
+ ctx.stop_turn("the model kept repeating the same calls")
313
313
  repeated = @attempts.select { |key, n| n > 1 && @last_result[key] && @results[[key, @last_result[key]]] >= @deny_after }
314
314
  lines = repeated.map do |key, n|
315
315
  "- `#{short_key(key)}`: #{n} times, the same result each time (#{@previews[key]})"
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.3.1
3
+ version: 0.3.2
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:f7bfcaae51863c0dc2b057b0d30f79b7bed7935891e817fa7373f787e5c4ca5e
11
- requires_chi: ">= 0.1.37"
10
+ sha256: sha256:61b195a5621ddf57ba69a505aff06d620d6459e836d736205343a251b0fecb71
11
+ requires_chi: ">= 0.8.0"
@@ -421,10 +421,8 @@ class Plugin
421
421
 
422
422
  # Written aside and renamed: workers share the dir.
423
423
  def save_cache(server, ctx)
424
- path = cache_path(server, ctx)
425
- tmp = "#{path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
426
- File.write(tmp, JSON.generate({ digest: server.digest, saved_at: Time.now.utc.iso8601, tools: server.listed }))
427
- File.rename(tmp, path)
424
+ Samagotchi::AtomicFile.write(cache_path(server, ctx),
425
+ JSON.generate({ digest: server.digest, saved_at: Time.now.utc.iso8601, tools: server.listed }))
428
426
  rescue SystemCallError => e
429
427
  ctx.log.warn("mcp_cache_not_written", server: server.name, msg: e.message)
430
428
  end
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: skills
3
- version: 0.1.1
3
+ version: 0.1.4
4
4
  scope: system
5
5
  description: "Skills (skill_<name> memories, the steps of a task done together): /skill save, list, show and diff; older versions kept with a short diff line on every update; a nudge when a followed skill's step fails"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:ecf76de5d0f4d07a0e87b96cdc13b0bbc22369f8eea843bfd22c6fade469dfca
9
+ sha256: sha256:cc5b3bcfd571e17074776f48c8d28184475f2a4becd06d70edbacd7381e1f306
10
10
  requires_chi: ">= 0.9.0"
@@ -25,8 +25,11 @@
25
25
  # execute with "exit: N", N ≠ 0, or with no exit line an Error: line near
26
26
  # the top; any tool's "[tool] Error: …") steers the model once to find out
27
27
  # why and fix the skill, unless a skill read was changed already (by any
28
- # tool, see above). At the turn's end, a failed step with no change to a
29
- # skill read gets a notice line.
28
+ # tool, see above). A call a guardrail or the user denied (its first line
29
+ # "[tool] Error: blocked by guardrail: …", "… denied by guardrail (…): …" or
30
+ # "… <the user's answer> It needed approval (…): …") isn't a failed step:
31
+ # it says nothing about the skill. At the turn's end, a failed step with no
32
+ # change to a skill read gets a notice line.
30
33
  #
31
34
  # Settings (config.yml, bundles: skills:):
32
35
  # history_keep: 20 older versions kept per skill
@@ -43,7 +46,6 @@ class Plugin
43
46
  USAGE = "usage: /skill save [name] [--system] | list | show <name> | diff <name> [N]"
44
47
 
45
48
  def initialize(settings = {})
46
- settings = {} unless settings.is_a?(Hash)
47
49
  @history_keep = positive(settings["history_keep"]) || 20
48
50
  @nudge = settings.key?("nudge") ? settings["nudge"] != false : true
49
51
  @stash = nil
@@ -87,7 +89,7 @@ class Plugin
87
89
  @stash = nil
88
90
  stash = nil unless stash && stash[:tool] == event[:tool].to_s
89
91
  changed_elsewhere(ctx) unless stash
90
- step_failed(event) if @nudge && !@read.empty?
92
+ step_failed(event, ctx) if @nudge && !@read.empty?
91
93
  return unless stash
92
94
 
93
95
  now = File.file?(stash[:path]) ? File.read(stash[:path]) : nil
@@ -171,14 +173,22 @@ class Plugin
171
173
  nil
172
174
  end
173
175
 
174
- def step_failed(event)
176
+ def step_failed(event, ctx)
175
177
  tool = event[:tool].to_s
176
- return if NOT_FAILURES.include?(tool) || !failure?(tool, event[:output].to_s)
178
+ output = event[:output].to_s
179
+ return if NOT_FAILURES.include?(tool) || guardrail_deny?(tool, output) || !failure?(tool, output)
177
180
 
178
181
  @failed = true
179
182
  return if @nudged || @read.any? { |name| @written.include?(name) }
180
183
 
181
- @nudged = !!event[:steer]&.call(format(NUDGE, @read.join(", ")))
184
+ @nudged = ctx.steer(format(NUDGE, @read.join(", ")))
185
+ end
186
+
187
+ # A denied call, by its first line: a legacy veto, a rule's, hook's or
188
+ # core's deny, or the user's (declined, approval cancelled). Core's
189
+ # wording (ToolRunner#denied, Verdict#deny_text).
190
+ def guardrail_deny?(tool, output)
191
+ output.match?(/\A\[#{Regexp.escape(tool)}\] Error: (?:blocked by guardrail: |denied by guardrail \(|[^\n]*? It needed approval \()/)
182
192
  end
183
193
 
184
194
  # A tool error ("[x] Error: …" raised, "[x]\nError: …" returned), or an
@@ -216,8 +226,7 @@ class Plugin
216
226
 
217
227
  stamp = Time.now.utc.strftime("%Y%m%dT%H%M%S.%6NZ")
218
228
  path = File.join(dir, "#{stamp}.md")
219
- File.write("#{path}.tmp", content)
220
- File.rename("#{path}.tmp", path)
229
+ Samagotchi::AtomicFile.write(path, content)
221
230
  versions(dir).drop(@history_keep).each { |old| File.delete(old) }
222
231
  rescue SystemCallError => e
223
232
  ctx.log.warn(:history_failed, skill: name, error: e.class.name, msg: e.message)
@@ -92,7 +92,6 @@ class SourceLinks
92
92
  end
93
93
 
94
94
  def initialize(settings = {})
95
- settings = {} unless settings.is_a?(Hash)
96
95
  @sources = compile_sources(settings["sources"])
97
96
  max = settings["max"].to_i
98
97
  @max = max.positive? ? max : DEFAULT_MAX
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: source-links
3
- version: 0.3.0
3
+ version: 0.3.1
4
4
  scope: system
5
5
  description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, and a one-line note after the turn (note false turns it off); sources are configured patterns
6
6
  trust_level: reviewed
@@ -8,7 +8,7 @@ files:
8
8
  source_links.md: sha256:aebd37f1e25491780994ecc4afc20ed6e8db32b724dc038302bb050177efada8
9
9
  hooks:
10
10
  source_links.rb:
11
- sha256: sha256:0ee86a932efc90d81777855c81dea16510710d7371fe8b6b146a5a16b29794c2
11
+ sha256: sha256:574ea0c2b36f80dde410c4251753d70cbeea081d129f3f3547f41370e3990993
12
12
  event: after_turn
13
13
  on_error: log
14
14
  priority: 90
@@ -113,7 +113,7 @@ bundles:
113
113
 
114
114
  A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cover every MCP tool; see `docs/guardrails.md`. The mcp bundle saves each server's tool list in `$XDG_STATE_HOME/samagotchi/plugins/mcp/tools-<server>.json` (keyed by a digest of command/env/cwd): a changed server config is picked up by the next session start, which shows "Starting MCP server x (config changed, …)".
115
115
 
116
- **Preservation rule**: `ConfigFile.write_default_model!` and `ConfigFile.write_model_alias!` both load raw YAML (including nested sections and maps), mutate one key (`raw_data["default"]["model"] = ...` for new form), write atomically via `tmp`+`rename`. Never overwrite the file with only scalar keys — that would clobber `hooks:` / `model_aliases:` / `hosts:` / `recap:` / `guardrails:` / `bundles:`.
116
+ **Preservation rule**: `ConfigFile.write_default_model!` and `ConfigFile.write_model_alias!` (`/model --default`, `/model --alias`) change one key on its own line of the text (`ConfigTextEdit`: the user's comments and layout stay; a new key goes at the end of its section, a missing section at the end of the file), check that the result parses to the old data plus that key (else they dump the whole file from it), and write through `AtomicFile` (a unique temp file renamed over config.yml, through a symlink, keeping its mode). Never overwrite the file with only scalar keys — that would clobber `hooks:` / `model_aliases:` / `hosts:` / `recap:` / `guardrails:` / `bundles:`.
117
117
 
118
118
  ## Workflow for any config edit
119
119
 
@@ -121,7 +121,7 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
121
121
  2. `YAML.safe_load` (permitted_classes: [], aliases: false). If data nil or not Hash, treat as `{}` or raise with path.
122
122
  3. Mutate the intended **nested** key in the raw hash. Preserve all other keys byte-for-byte where possible. Example for default model: `raw_data["default"] ||= {}; raw_data["default"]["model"] = "new-model"`.
123
123
  4. **Validate** (see below) before writing. Also run `Samagotchi::Config.validate_yaml_sections` — it returns one `config: unknown key '…' (did you mean '…'?)` per key chi doesn't read (every config-exposed `Config::ENTRIES` key is known as written, including the section-less `max_tool_output_chars` and `skip_agent_md`; names under `hosts:`/`models:`/`model_aliases:`/`hooks:`/`bundles:`/`memories:` are free-form, host and model entries are checked against `Config::MAP_ENTRY_KEYS`). An empty list means no warning at start.
124
- 5. **Write atomically**: `FileUtils.mkdir_p(File.dirname(path))`, `File.write("#{path}.tmp", YAML.dump(raw_data))`, `File.rename("#{path}.tmp", path)`.
124
+ 5. **Write**: change only the lines you mean to, with the edit tool, so the user's comments and layout stay; `YAML.dump` of the whole hash drops every comment, so rewrite the file only when it has none worth keeping. From Ruby, write atomically with `Samagotchi::AtomicFile.write(path, text)` (a unique temp file renamed over it), never through a fixed `"#{path}.tmp"` that another chi may be writing at the same time.
125
125
  6. Nothing else to update: config.yml values are never copied into `ENV`, and every `Config.get` reads the file again when it changed, so the next read (and every worker started after the write) sees the new value with origin `:file`. What a running worker set up at its start (`hosts:`, guardrail rules, bundle settings) waits for its restart. CLI overrides (`--model`, `--recap-model`, …) win over the file until the process exits; a worker gets its spawner's CLI settings through its env (`Config.cli_env`).
126
126
 
127
127
  ## Validations
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.9.0
3
+ version: 0.10.1
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:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:93c788fadaf98adeb8946b4a357d2372bd6ebb439042d7cef8dda8fb44187315
9
- config_modification_protocol.md: sha256:24e95727488daa80ac3008e020d8be098c7a1882153b7a61aba35450f72b86b7
9
+ config_modification_protocol.md: sha256:d8bdf062d5afce7430ead7f49439c1d1ed197a6261dc69a7c884f4b814266b12
10
10
  memory_guide.md: sha256:81b911b9a015f0924c48162b3dc4b6376309e743765074419a99fab46e480858
11
11
  delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module CLI
5
+ # The usage side of a subcommand: its error lines, usage errors (exit 2)
6
+ # and help. The including class sets @stdout and @stderr and defines
7
+ # #command_name ("chi send"); its usage text is USAGE unless it
8
+ # overrides #usage_text.
9
+ module Command
10
+ USAGE_EXIT = 2
11
+ HELP_WORDS = %w[-h --help help].freeze
12
+
13
+ private
14
+
15
+ def usage_text = self.class::USAGE
16
+
17
+ # What follows a usage error's line: the usage, or nil for a short
18
+ # "(see chi … --help)" at the end of the line instead.
19
+ def usage_on_error = usage_text
20
+
21
+ # stderr isn't buffered, stdout is when it's a pipe: flush the lines
22
+ # already printed, so the output keeps the order of the ids given.
23
+ def error_line(text)
24
+ @stdout.flush
25
+ @stderr.puts(text)
26
+ end
27
+
28
+ # @return [Integer] the usage exit status
29
+ def usage_error(message)
30
+ usage = usage_on_error
31
+ error_line("#{command_name}: #{message}#{usage ? "" : " (see #{command_name} --help)"}")
32
+ @stderr.puts(usage) if usage
33
+ USAGE_EXIT
34
+ end
35
+
36
+ # +argv+ through +flags+ (a Flags), help and usage errors done.
37
+ # @return [Flags::Result, Integer] the result, or the exit status
38
+ # after the help (0) or a usage error (2)
39
+ def parse_flags(flags, argv, defaults = {})
40
+ parsed = flags.parse(argv, defaults)
41
+ if parsed.help
42
+ @stdout.puts(usage_text)
43
+ return 0
44
+ end
45
+ return usage_error(parsed.error.message) if parsed.error
46
+
47
+ parsed
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module CLI
5
+ # A subcommand's flags as a table (the subcommands bin/chi dispatches
6
+ # before OptionParser): each flag's names, whether it takes a value
7
+ # ("--flag V", or "--flag=V" for a long name) and whether it repeats.
8
+ # #parse walks argv once, in order, and stops at the first help word or
9
+ # error; what isn't a flag is a positional argument.
10
+ #
11
+ # FLAGS = CLI::Flags.new(help: %w[-h --help help]) do |f|
12
+ # f.value "-m", "--message"
13
+ # f.value "--image", key: :images, repeat: true
14
+ # f.switch "--new"
15
+ # end
16
+ # FLAGS.parse(argv, images: []) # => Result
17
+ class Flags
18
+ Flag = Struct.new(:key, :value, :repeat, :set, :refusal, keyword_init: true)
19
+
20
+ # kind :unknown (an undeclared flag, a switch given =V, or a
21
+ # positional where none are taken), :missing (a value flag with no
22
+ # value after it) or :refused (a flag declared with #refuse); +arg+ is
23
+ # the argument as given.
24
+ Error = Data.define(:kind, :arg, :refusal) do
25
+ def message
26
+ case kind
27
+ when :unknown then "unknown option #{arg}"
28
+ when :missing then "#{arg} needs a value"
29
+ else refusal
30
+ end
31
+ end
32
+ end
33
+
34
+ # +options+ by key, +args+ the positionals in order; +help+ true when
35
+ # a help word stopped the parse; +error+ nil or an Error.
36
+ Result = Data.define(:options, :args, :help, :error)
37
+
38
+ # @param help [Array<String>] words that stop the parse with help
39
+ # (not when they are a flag's value)
40
+ # @param flag_pattern [Regexp] what an undeclared flag looks like (an
41
+ # unknown option); other arguments are positionals
42
+ # @param dash_values [Boolean] false: a value flag followed by a
43
+ # "--…" argument has no value
44
+ # @param args [Boolean] false: a positional is an unknown option
45
+ def initialize(help: [], flag_pattern: /\A-/, dash_values: true, args: true)
46
+ @help = help
47
+ @flag_pattern = flag_pattern
48
+ @dash_values = dash_values
49
+ @args = args
50
+ @flags = {}
51
+ yield self if block_given?
52
+ end
53
+
54
+ # A flag without a value: options[key] = +set+. The key defaults to
55
+ # the last name without its dashes, snake_cased.
56
+ def switch(*names, key: nil, set: true)
57
+ add(names, Flag.new(key: key, value: false, set: set))
58
+ end
59
+
60
+ # A flag with a value; +repeat+: options[key] collects them in an array.
61
+ def value(*names, key: nil, repeat: false)
62
+ add(names, Flag.new(key: key, value: true, repeat: repeat))
63
+ end
64
+
65
+ # A flag that is an error, with +message+ (a flag that doesn't exist
66
+ # on purpose).
67
+ def refuse(*names, message)
68
+ add(names, Flag.new(refusal: message))
69
+ end
70
+
71
+ # @param defaults [Hash] the options before any flag (not changed)
72
+ # @return [Result]
73
+ def parse(argv, defaults = {})
74
+ options = defaults.dup
75
+ args = []
76
+ rest = argv.dup
77
+ until rest.empty?
78
+ arg = rest.shift
79
+ return Result.new(options: options, args: args, help: true, error: nil) if @help.include?(arg)
80
+
81
+ name, inline = arg.start_with?("--") && arg.include?("=") ? arg.split("=", 2) : [arg, nil]
82
+ flag = @flags[name]
83
+ flag = nil if inline && !flag&.value
84
+ unless flag
85
+ return failure(options, args, :unknown, arg) if !@args || arg.match?(@flag_pattern)
86
+
87
+ args << arg
88
+ next
89
+ end
90
+ return failure(options, args, :refused, arg, flag.refusal) if flag.refusal
91
+ next options[flag.key] = flag.set unless flag.value
92
+
93
+ unless inline
94
+ return failure(options, args, :missing, arg) if rest.empty? || (!@dash_values && rest.first.start_with?("--"))
95
+
96
+ inline = rest.shift
97
+ end
98
+ options[flag.key] = flag.repeat ? [*options[flag.key], inline] : inline
99
+ end
100
+ Result.new(options: options, args: args, help: false, error: nil)
101
+ end
102
+
103
+ private
104
+
105
+ def add(names, flag)
106
+ flag.key ||= names.last.sub(/\A-+/, "").tr("-", "_").to_sym unless flag.refusal
107
+ names.each { |name| @flags[name] = flag }
108
+ self
109
+ end
110
+
111
+ def failure(options, args, kind, arg, refusal = nil)
112
+ Result.new(options: options, args: args, help: false, error: Error.new(kind: kind, arg: arg, refusal: refusal))
113
+ end
114
+ end
115
+ end
116
+ end
@@ -6,6 +6,7 @@ require "fileutils"
6
6
  require "uri"
7
7
  require "set"
8
8
  require_relative "atomic_file"
9
+ require_relative "config_text_edit"
9
10
  require_relative "log"
10
11
  require_relative "paths"
11
12
 
@@ -893,33 +894,39 @@ module Samagotchi
893
894
  nil
894
895
  end
895
896
 
896
- def write_default_model!(model_name, env: ENV, path: global_path(env: env))
897
- resolved = model_name.to_s.strip
898
- raise ArgumentError, "model name is required" if resolved.empty?
897
+ # A top-level section of config.yml as written (nil when absent or the
898
+ # file doesn't parse); a reader checks its shape.
899
+ def section(name, env: ENV, path: global_path(env: env))
900
+ data = read_yaml(path: path)
901
+ data.is_a?(Hash) ? data[name] : nil
902
+ end
899
903
 
900
- if resolved.include?(":") || resolved.include?("/")
901
- sep = resolved.include?(":") ? ":" : "/"
902
- prefix = resolved.split(sep, 2).first.to_s.strip.downcase
903
- begin
904
- hosts = hosts_config(env: env, path: path)
905
- if hosts && !hosts.empty? && !hosts.key?(prefix) && prefix.match?(HOST_NAME_RE)
906
- end
907
- rescue StandardError
908
- nil
909
- end
904
+ # config.yml `bundles:`: each bundle's settings by name, for its hooks.
905
+ # @return [Hash{String => Hash}] {} when absent; a section that isn't a
906
+ # mapping warns and counts as absent
907
+ def bundle_settings(env: ENV, path: global_path(env: env))
908
+ raw = section("bundles", env: env, path: path)
909
+ return {} if raw.nil?
910
+ unless raw.is_a?(Hash)
911
+ Log.warn(:hooks, "bundles_section_invalid", echo: "[samagotchi:hooks] config.yml bundles: must be a mapping of bundle name to settings; ignored")
912
+ return {}
910
913
  end
911
914
 
912
- raw_data = read_yaml(env: env, path: path) || {}
915
+ raw.each_with_object({}) { |(name, value), acc| acc[name.to_s] = value.is_a?(Hash) ? value : {} }
916
+ rescue StandardError
917
+ {}
918
+ end
913
919
 
914
- raw_data["default"] ||= {}
915
- if raw_data["default"].is_a?(Hash)
916
- raw_data["default"]["model"] = resolved
917
- else
918
- raw_data["default"] = { "model" => resolved }
919
- end
920
- FileUtils.mkdir_p(File.dirname(path))
921
- AtomicFile.write(path, YAML.dump(raw_data))
922
- clear_yaml_cache!(path)
920
+ # Sets default.model in config.yml; the rest of the file, comments
921
+ # included, stays as written (ConfigTextEdit).
922
+ def write_default_model!(model_name, env: ENV, path: global_path(env: env))
923
+ resolved = model_name.to_s.strip
924
+ raise ArgumentError, "model name is required" if resolved.empty?
925
+
926
+ expected = deep_copy(read_yaml(env: env, path: path) || {})
927
+ expected["default"] = {} unless expected["default"].is_a?(Hash)
928
+ expected["default"]["model"] = resolved
929
+ write_config_key(path, "default", "model", resolved, expected)
923
930
  Samagotchi::Config.reload!(cli_overrides: Samagotchi::Config.cli_overrides)
924
931
  true
925
932
  end
@@ -1003,50 +1010,58 @@ module Samagotchi
1003
1010
  aliases.fetch(value.downcase, value)
1004
1011
  end
1005
1012
 
1013
+ # Words /model takes as arguments (clear/default/none/off reset the
1014
+ # runtime model), so no alias may be named after one.
1006
1015
  RESERVED_MODEL_ALIASES = %w[clear default none off].freeze
1007
1016
 
1008
- def write_model_alias!(alias_name, model_name, env: ENV, path: global_path(env: env))
1017
+ # What is wrong with +alias_name+ as an alias for +target+, or nil.
1018
+ def model_alias_error(alias_name, target)
1009
1019
  alias_key = alias_name.to_s.strip
1010
- raise ArgumentError, "alias name is required" if alias_key.empty?
1011
- raise ArgumentError, "model name is required" if model_name.to_s.strip.empty?
1020
+ return "alias name is required" if alias_key.empty?
1012
1021
 
1013
1022
  lowered_key = alias_key.downcase
1014
- raise ArgumentError, "alias name '#{alias_key}' is reserved" if RESERVED_MODEL_ALIASES.include?(lowered_key)
1015
- raise ArgumentError, "alias name must not contain whitespace" if alias_key.match?(/\s/)
1016
- raise ArgumentError, "alias name must not start with '-'" if alias_key.start_with?("-")
1017
- raise ArgumentError, "alias name must not contain '/'" if alias_key.include?("/")
1018
- unless alias_key.match?(/\A[a-z0-9][a-z0-9._-]*\z/i)
1019
- raise ArgumentError, "alias name must match /[a-z0-9][a-z0-9._-]*/i (got '#{alias_key}')"
1020
- end
1023
+ return "alias name '#{alias_key}' is reserved" if RESERVED_MODEL_ALIASES.include?(lowered_key)
1024
+ return "alias name must not contain whitespace" if alias_key.match?(/\s/)
1025
+ return "alias name must not start with '-'" if alias_key.start_with?("-")
1026
+ return "alias name must not contain '/'" if alias_key.include?("/")
1027
+ return "alias name must match /[a-z0-9][a-z0-9._-]*/i (got '#{alias_key}')" unless alias_key.match?(/\A[a-z0-9][a-z0-9._-]*\z/i)
1028
+ return "alias must not point to itself" if lowered_key == target.to_s.strip.downcase
1021
1029
 
1022
- resolved_model = model_name.to_s.strip
1023
- raise ArgumentError, "alias must not point to itself" if lowered_key == resolved_model.downcase
1024
-
1025
- raw_data = read_yaml(env: env, path: path) || {}
1030
+ nil
1031
+ end
1026
1032
 
1027
- aliases_hash = raw_data[MODEL_ALIASES_KEY]
1028
- unless aliases_hash.is_a?(Hash)
1029
- aliases_hash = {}
1030
- raw_data[MODEL_ALIASES_KEY] = aliases_hash
1031
- end
1033
+ def write_model_alias!(alias_name, model_name, env: ENV, path: global_path(env: env))
1034
+ error = model_alias_error(alias_name, model_name)
1035
+ raise ArgumentError, error if error
1036
+ raise ArgumentError, "model name is required" if model_name.to_s.strip.empty?
1032
1037
 
1033
- # Normalize existing alias keys to downcase to avoid duplicates like Qwen/qwen
1034
- normalized = {}
1035
- aliases_hash.each do |k, v|
1036
- nk = k.to_s.strip.downcase
1037
- next if nk.empty?
1038
- normalized[nk] = v.to_s.strip unless v.to_s.strip.empty?
1039
- end
1040
- raw_data[MODEL_ALIASES_KEY] = normalized
1038
+ lowered_key = alias_name.to_s.strip.downcase
1039
+ resolved_model = model_name.to_s.strip
1041
1040
 
1042
- previous = normalized[lowered_key]
1043
- normalized[lowered_key] = resolved_model
1041
+ previous = model_aliases(env: env, path: path)[lowered_key]
1042
+ expected = deep_copy(read_yaml(env: env, path: path) || {})
1043
+ aliases = expected[MODEL_ALIASES_KEY].is_a?(Hash) ? expected[MODEL_ALIASES_KEY] : {}
1044
+ aliases.delete_if { |k, _| k.to_s.strip.downcase == lowered_key }
1045
+ aliases[lowered_key] = resolved_model
1046
+ expected[MODEL_ALIASES_KEY] = aliases
1047
+ write_config_key(path, MODEL_ALIASES_KEY, lowered_key, resolved_model, expected)
1048
+ previous
1049
+ end
1044
1050
 
1045
- FileUtils.mkdir_p(File.dirname(path))
1046
- AtomicFile.write(path, YAML.dump(raw_data))
1051
+ # Write +section+.+key+ = +value+ into config.yml (+expected+: the whole
1052
+ # file's data after it), through a symlink, keeping the file's mode.
1053
+ def write_config_key(path, section, key, value, expected)
1054
+ target = File.exist?(path) ? File.realpath(path) : path
1055
+ text = File.file?(target) ? File.read(target, encoding: "UTF-8") : nil
1056
+ FileUtils.mkdir_p(File.dirname(target))
1057
+ perm = File.stat(target).mode & 0o7777 if text
1058
+ AtomicFile.write(target, ConfigTextEdit.set(text, section: section, key: key, value: value, expected: expected), perm: perm)
1047
1059
  clear_yaml_cache!(path)
1048
- previous
1049
1060
  end
1061
+ private_class_method :write_config_key
1062
+
1063
+ def deep_copy(data) = Marshal.load(Marshal.dump(data))
1064
+ private_class_method :deep_copy
1050
1065
 
1051
1066
  def nonempty_str(value)
1052
1067
  str = value.to_s.strip