samagotchi 0.9.0 → 0.10.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -1
- data/docs/architecture.md +24 -9
- data/docs/cli.md +4 -3
- data/docs/guardrails.md +3 -2
- data/docs/hooks.md +44 -7
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/plugins.md +34 -12
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap_command.rb +29 -29
- data/lib/samagotchi/bridge/card_store.rb +4 -4
- data/lib/samagotchi/bridge/pending_card.rb +2 -2
- data/lib/samagotchi/bridge/sse_writer.rb +22 -28
- data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
- data/lib/samagotchi/bridge.rb +59 -112
- data/lib/samagotchi/bridge_client.rb +11 -28
- data/lib/samagotchi/bundle_command.rb +158 -212
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +4 -6
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +51 -0
- data/lib/samagotchi/cli/flags.rb +116 -0
- data/lib/samagotchi/config.rb +70 -55
- data/lib/samagotchi/config_text_edit.rb +107 -0
- data/lib/samagotchi/desktop_command.rb +24 -18
- data/lib/samagotchi/edit_preview.rb +2 -2
- data/lib/samagotchi/engine.rb +20 -75
- data/lib/samagotchi/events.rb +23 -0
- data/lib/samagotchi/guardrail_wiring.rb +2 -5
- data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
- data/lib/samagotchi/hooks/loader.rb +23 -33
- data/lib/samagotchi/hooks/registry.rb +41 -0
- data/lib/samagotchi/hooks.rb +0 -3
- data/lib/samagotchi/host_registry.rb +15 -0
- data/lib/samagotchi/idle_reminders.rb +0 -7
- data/lib/samagotchi/image_store.rb +22 -0
- data/lib/samagotchi/kernel_loop.rb +12 -16
- data/lib/samagotchi/live_versions.rb +2 -13
- data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
- data/lib/samagotchi/memory_bundle/builder.rb +1 -14
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
- data/lib/samagotchi/memory_bundle/installer.rb +1 -27
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
- data/lib/samagotchi/memory_bundle/status.rb +1 -14
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/note_command.rb +23 -37
- data/lib/samagotchi/owner_lock.rb +14 -3
- data/lib/samagotchi/plugin/api.rb +11 -17
- data/lib/samagotchi/plugin/context.rb +70 -15
- data/lib/samagotchi/plugin/loader.rb +2 -3
- data/lib/samagotchi/sampling_settings.rb +3 -5
- data/lib/samagotchi/self_report.rb +6 -9
- data/lib/samagotchi/send_command.rb +33 -52
- data/lib/samagotchi/session.rb +88 -92
- data/lib/samagotchi/session_archive_command.rb +8 -14
- data/lib/samagotchi/session_commands.rb +3 -21
- data/lib/samagotchi/session_delete_command.rb +15 -29
- data/lib/samagotchi/session_manager.rb +27 -13
- data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
- data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
- data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
- data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
- data/lib/samagotchi/terminal_ui.rb +156 -234
- data/lib/samagotchi/thinking.rb +2 -3
- data/lib/samagotchi/thought_stream_splitter.rb +10 -9
- data/lib/samagotchi/tool_call_parser.rb +56 -292
- data/lib/samagotchi/tools/builtin_calls.rb +146 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/edit.rb +21 -29
- data/lib/samagotchi/tools/memory.rb +1 -11
- data/lib/samagotchi/tools/send_note.rb +1 -1
- data/lib/samagotchi/update_command.rb +32 -22
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -5
- data/lib/samagotchi/web/app.rb +89 -151
- data/lib/samagotchi/web/public/activity.js +1 -11
- data/lib/samagotchi/web/public/annotate_bar.js +120 -0
- data/lib/samagotchi/web/public/app.js +158 -785
- data/lib/samagotchi/web/public/chunk_router.js +7 -6
- data/lib/samagotchi/web/public/command_list.js +99 -0
- data/lib/samagotchi/web/public/data.js +22 -66
- data/lib/samagotchi/web/public/format.js +0 -23
- data/lib/samagotchi/web/public/index.html +0 -2
- data/lib/samagotchi/web/public/model_pick.js +0 -7
- data/lib/samagotchi/web/public/notifications.js +169 -0
- data/lib/samagotchi/web/public/question_cards.js +352 -0
- data/lib/samagotchi/web/public/session_state.js +34 -0
- data/lib/samagotchi/web/public/timing.js +1 -1
- data/lib/samagotchi/web/public/turn_events.js +11 -2
- data/lib/samagotchi/web/public/turn_model.js +0 -14
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +5 -3
- data/lib/samagotchi/worker.rb +59 -53
- data/lib/samagotchi/worker_sidecar.rb +27 -1
- metadata +12 -1
|
@@ -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
|
-
|
|
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(
|
|
237
|
+
return stop_thinking(ctx, found, what) if @thinking_loops > 1
|
|
238
238
|
|
|
239
|
-
ctx.notify("thinking repeats itself (#{what}): cut", level: :warn) if
|
|
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(
|
|
241
|
+
stop_thinking(ctx, found, what)
|
|
242
242
|
end
|
|
243
243
|
end
|
|
244
244
|
|
|
245
|
-
def stop_thinking(
|
|
246
|
-
return unless
|
|
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(
|
|
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(
|
|
312
|
-
|
|
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.
|
|
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:
|
|
11
|
-
requires_chi: ">= 0.
|
|
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
|
-
|
|
425
|
-
|
|
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.
|
|
3
|
+
version: 0.1.3
|
|
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:
|
|
9
|
+
sha256: sha256:4f32b3457ada3392009e4a32b690e577ef0bb05d46c6cfed9ae02640119620d8
|
|
10
10
|
requires_chi: ">= 0.9.0"
|
|
@@ -43,7 +43,6 @@ class Plugin
|
|
|
43
43
|
USAGE = "usage: /skill save [name] [--system] | list | show <name> | diff <name> [N]"
|
|
44
44
|
|
|
45
45
|
def initialize(settings = {})
|
|
46
|
-
settings = {} unless settings.is_a?(Hash)
|
|
47
46
|
@history_keep = positive(settings["history_keep"]) || 20
|
|
48
47
|
@nudge = settings.key?("nudge") ? settings["nudge"] != false : true
|
|
49
48
|
@stash = nil
|
|
@@ -87,7 +86,7 @@ class Plugin
|
|
|
87
86
|
@stash = nil
|
|
88
87
|
stash = nil unless stash && stash[:tool] == event[:tool].to_s
|
|
89
88
|
changed_elsewhere(ctx) unless stash
|
|
90
|
-
step_failed(event) if @nudge && !@read.empty?
|
|
89
|
+
step_failed(event, ctx) if @nudge && !@read.empty?
|
|
91
90
|
return unless stash
|
|
92
91
|
|
|
93
92
|
now = File.file?(stash[:path]) ? File.read(stash[:path]) : nil
|
|
@@ -171,14 +170,14 @@ class Plugin
|
|
|
171
170
|
nil
|
|
172
171
|
end
|
|
173
172
|
|
|
174
|
-
def step_failed(event)
|
|
173
|
+
def step_failed(event, ctx)
|
|
175
174
|
tool = event[:tool].to_s
|
|
176
175
|
return if NOT_FAILURES.include?(tool) || !failure?(tool, event[:output].to_s)
|
|
177
176
|
|
|
178
177
|
@failed = true
|
|
179
178
|
return if @nudged || @read.any? { |name| @written.include?(name) }
|
|
180
179
|
|
|
181
|
-
@nudged =
|
|
180
|
+
@nudged = ctx.steer(format(NUDGE, @read.join(", ")))
|
|
182
181
|
end
|
|
183
182
|
|
|
184
183
|
# A tool error ("[x] Error: …" raised, "[x]\nError: …" returned), or an
|
|
@@ -216,8 +215,7 @@ class Plugin
|
|
|
216
215
|
|
|
217
216
|
stamp = Time.now.utc.strftime("%Y%m%dT%H%M%S.%6NZ")
|
|
218
217
|
path = File.join(dir, "#{stamp}.md")
|
|
219
|
-
|
|
220
|
-
File.rename("#{path}.tmp", path)
|
|
218
|
+
Samagotchi::AtomicFile.write(path, content)
|
|
221
219
|
versions(dir).drop(@history_keep).each { |old| File.delete(old) }
|
|
222
220
|
rescue SystemCallError => e
|
|
223
221
|
ctx.log.warn(:history_failed, skill: name, error: e.class.name, msg: e.message)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: source-links
|
|
3
|
-
version: 0.3.
|
|
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:
|
|
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!`
|
|
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
|
|
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.
|
|
3
|
+
version: 0.10.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:
|
|
7
7
|
identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
8
|
self_map.md: sha256:93c788fadaf98adeb8946b4a357d2372bd6ebb439042d7cef8dda8fb44187315
|
|
9
|
-
config_modification_protocol.md: sha256:
|
|
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
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -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
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
-
|
|
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
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
unless alias_key.match?(/\A[a-z0-9][a-z0-9._-]*\z/i)
|
|
1019
|
-
|
|
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
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
raw_data = read_yaml(env: env, path: path) || {}
|
|
1030
|
+
nil
|
|
1031
|
+
end
|
|
1026
1032
|
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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
|
-
|
|
1034
|
-
|
|
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 =
|
|
1043
|
-
|
|
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
|
-
|
|
1046
|
-
|
|
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
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "psych"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# Sets one section.key in config.yml text, line by line, so the user's
|
|
9
|
+
# comments and layout stay (YAML.dump would drop them), the way
|
|
10
|
+
# Bootstrap::ConfigWriter inserts a host. A key already in the block is
|
|
11
|
+
# replaced on its line (a comment after the old value goes with it), a new
|
|
12
|
+
# one goes at the end of the block, a missing section at the end of the
|
|
13
|
+
# file. The result is parsed again and must equal +expected+; when it
|
|
14
|
+
# doesn't, or the text is something a line edit can't handle (a flow-style
|
|
15
|
+
# section, anchors, a duplicate section), the whole file is dumped from
|
|
16
|
+
# +expected+ instead.
|
|
17
|
+
module ConfigTextEdit
|
|
18
|
+
DEFAULT_INDENT = 2
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# @param text [String, nil] the file's text (nil: no file)
|
|
23
|
+
# @param section [String] a top-level key ("default")
|
|
24
|
+
# @param key [String] the key under it; an existing key matches in any case
|
|
25
|
+
# @param value [String] the value, written as a quoted string
|
|
26
|
+
# @param expected [Hash] the whole file's data after the change
|
|
27
|
+
# @return [String] the new text
|
|
28
|
+
def set(text, section:, key:, value:, expected:)
|
|
29
|
+
edited = text && edit(text, section, key, value)
|
|
30
|
+
edited && parse(edited) == expected ? edited : YAML.dump(expected)
|
|
31
|
+
rescue Psych::Exception
|
|
32
|
+
YAML.dump(expected)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def edit(text, section, key, value)
|
|
36
|
+
return nil if anchors?(text)
|
|
37
|
+
|
|
38
|
+
# A key the lines below can't see (flow style, odd quoting) would end up
|
|
39
|
+
# written twice, and YAML keeps the last one without a word.
|
|
40
|
+
original = parse(text)
|
|
41
|
+
original = {} unless original.is_a?(Hash)
|
|
42
|
+
section_data = original[section]
|
|
43
|
+
has_key = section_data.is_a?(Hash) && section_data.keys.any? { |k| k.to_s.downcase == key.downcase }
|
|
44
|
+
|
|
45
|
+
eol = text.include?("\r\n") ? "\r\n" : "\n"
|
|
46
|
+
lines = text.split(/(?<=\n)/)
|
|
47
|
+
lines[-1] = "#{lines[-1]}#{eol}" if lines.any? && !lines[-1].end_with?("\n")
|
|
48
|
+
header = /\A#{Regexp.escape(section)}:[ \t]*(#.*)?\z/
|
|
49
|
+
starts = lines.each_index.select { |i| lines[i].chomp.match?(header) }
|
|
50
|
+
return nil if starts.length > 1
|
|
51
|
+
|
|
52
|
+
return nil if starts.empty? && original.key?(section)
|
|
53
|
+
|
|
54
|
+
if starts.empty?
|
|
55
|
+
lines.concat(["#{section}:#{eol}", "#{" " * DEFAULT_INDENT}#{key}: #{scalar(value)}#{eol}"])
|
|
56
|
+
return lines.join
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
at = starts.first
|
|
60
|
+
stop = at + 1
|
|
61
|
+
stop += 1 while stop < lines.length && inside_block?(lines[stop])
|
|
62
|
+
children = lines[(at + 1)...stop].reject { |line| line.strip.empty? || comment?(line) }
|
|
63
|
+
child = children.first ? indent(children.first) : DEFAULT_INDENT
|
|
64
|
+
new_line = "#{" " * child}#{key}: #{scalar(value)}#{eol}"
|
|
65
|
+
found = ((at + 1)...stop).find { |i| indent(lines[i]) == child && key_of(lines[i])&.downcase == key.downcase }
|
|
66
|
+
if found
|
|
67
|
+
lines[found] = new_line
|
|
68
|
+
else
|
|
69
|
+
return nil if has_key
|
|
70
|
+
|
|
71
|
+
stop -= 1 while stop > at + 1 && (lines[stop - 1].strip.empty? || (comment?(lines[stop - 1]) && indent(lines[stop - 1]) < child))
|
|
72
|
+
lines.insert(stop, new_line)
|
|
73
|
+
end
|
|
74
|
+
lines.join
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def key_of(line)
|
|
78
|
+
line[/\A\s*(["']?)([^"':#\s][^"':#]*?)\1:(?:\s|\z)/, 2]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def inside_block?(line)
|
|
82
|
+
return true if line.strip.empty?
|
|
83
|
+
return false if line.start_with?("---", "...")
|
|
84
|
+
|
|
85
|
+
line.start_with?(" ", "\t") || comment?(line)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def comment?(line) = line.lstrip.start_with?("#")
|
|
89
|
+
def indent(line) = line[/\A */].length
|
|
90
|
+
|
|
91
|
+
# JSON's quoting is valid YAML: model ids hold "/" and ":".
|
|
92
|
+
def scalar(value) = JSON.generate(value.to_s)
|
|
93
|
+
|
|
94
|
+
def parse(text) = YAML.safe_load(text, permitted_classes: [], aliases: false)
|
|
95
|
+
|
|
96
|
+
def anchors?(text)
|
|
97
|
+
stack = [Psych.parse_stream(text)]
|
|
98
|
+
until stack.empty?
|
|
99
|
+
node = stack.pop
|
|
100
|
+
return true if node.is_a?(Psych::Nodes::Alias) || (node.respond_to?(:anchor) && node.anchor)
|
|
101
|
+
|
|
102
|
+
stack.concat(Array(node.children)) if node.respond_to?(:children)
|
|
103
|
+
end
|
|
104
|
+
false
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|