samagotchi 0.84.0 → 0.86.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/docs/architecture.md +44 -6
  4. data/docs/bundle-authoring.md +125 -0
  5. data/docs/cli.md +54 -4
  6. data/docs/configuration.md +8 -5
  7. data/docs/guardrails.md +63 -35
  8. data/docs/hooks.md +20 -4
  9. data/docs/internals/anthropic.md +2 -2
  10. data/docs/internals/bundles.md +11 -2
  11. data/docs/internals/context-telemetry.md +19 -1
  12. data/docs/internals/engine.md +4 -3
  13. data/docs/memory.md +1 -1
  14. data/docs/plugins.md +59 -2
  15. data/docs/releasing.md +1 -1
  16. data/docs/sessions.md +8 -6
  17. data/docs/sub-agent.md +35 -24
  18. data/docs/testing.md +7 -2
  19. data/lib/samagotchi/answer_command.rb +21 -25
  20. data/lib/samagotchi/bridge/client_event.rb +50 -0
  21. data/lib/samagotchi/bridge/sse_writer.rb +6 -2
  22. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
  23. data/lib/samagotchi/bridge.rb +76 -12
  24. data/lib/samagotchi/bridge_client.rb +14 -2
  25. data/lib/samagotchi/bundle_command/check.rb +348 -0
  26. data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
  27. data/lib/samagotchi/bundle_command/test_run.rb +89 -0
  28. data/lib/samagotchi/bundle_command.rb +125 -2
  29. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
  31. data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
  32. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
  34. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  35. data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
  36. data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
  37. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  38. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  39. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  40. data/lib/samagotchi/client_id.rb +1 -1
  41. data/lib/samagotchi/config.rb +195 -115
  42. data/lib/samagotchi/context_status.rb +1 -1
  43. data/lib/samagotchi/continue_offer.rb +2 -1
  44. data/lib/samagotchi/engine.rb +5 -5
  45. data/lib/samagotchi/guardrail_wiring.rb +4 -0
  46. data/lib/samagotchi/guardrails/approval.rb +12 -2
  47. data/lib/samagotchi/guardrails/load_failures.rb +17 -3
  48. data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
  49. data/lib/samagotchi/guardrails/parent_policy.rb +258 -0
  50. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  51. data/lib/samagotchi/hook_routes.rb +5 -3
  52. data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
  53. data/lib/samagotchi/hooks/registry.rb +13 -6
  54. data/lib/samagotchi/hooks.rb +5 -0
  55. data/lib/samagotchi/kernel_loop.rb +10 -3
  56. data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
  57. data/lib/samagotchi/llm/chat_loop.rb +5 -1
  58. data/lib/samagotchi/log_subscriber.rb +1 -0
  59. data/lib/samagotchi/memory_bundle/shas.rb +127 -0
  60. data/lib/samagotchi/memory_bundle.rb +1 -0
  61. data/lib/samagotchi/parent_report.rb +92 -16
  62. data/lib/samagotchi/pending_question.rb +8 -4
  63. data/lib/samagotchi/plugin/api.rb +16 -0
  64. data/lib/samagotchi/plugin/context.rb +22 -12
  65. data/lib/samagotchi/plugin/loader.rb +5 -3
  66. data/lib/samagotchi/question_desk.rb +37 -24
  67. data/lib/samagotchi/queued_command.rb +5 -4
  68. data/lib/samagotchi/relay.rb +5 -1
  69. data/lib/samagotchi/relay_verifier.rb +4 -5
  70. data/lib/samagotchi/reply_wait.rb +22 -6
  71. data/lib/samagotchi/send_command.rb +34 -8
  72. data/lib/samagotchi/session_commands.rb +21 -7
  73. data/lib/samagotchi/session_inbox.rb +68 -0
  74. data/lib/samagotchi/terminal_ui/attach_launcher.rb +1 -1
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +102 -12
  76. data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
  77. data/lib/samagotchi/terminal_ui.rb +15 -9
  78. data/lib/samagotchi/testing/boundary.rb +0 -3
  79. data/lib/samagotchi/testing/bundle.rb +7 -0
  80. data/lib/samagotchi/testing/host.rb +6 -4
  81. data/lib/samagotchi/testing/install.rb +119 -0
  82. data/lib/samagotchi/testing/recorder.rb +13 -4
  83. data/lib/samagotchi/testing.rb +1 -0
  84. data/lib/samagotchi/tools/builtins.rb +2 -1
  85. data/lib/samagotchi/tools/delegate_wait.rb +6 -1
  86. data/lib/samagotchi/tools/task_runtime.rb +5 -3
  87. data/lib/samagotchi/turn_checkpoint.rb +64 -2
  88. data/lib/samagotchi/turn_flow.rb +10 -0
  89. data/lib/samagotchi/version.rb +1 -1
  90. data/lib/samagotchi/web/app.rb +76 -10
  91. data/lib/samagotchi/web/public/app.js +17 -0
  92. data/lib/samagotchi/web/public/bubbles.js +9 -0
  93. data/lib/samagotchi/web/public/chunk_router.js +8 -19
  94. data/lib/samagotchi/web/public/commands.js +1 -0
  95. data/lib/samagotchi/web/public/ctx.js +12 -26
  96. data/lib/samagotchi/web/public/data.js +11 -0
  97. data/lib/samagotchi/web/public/index.html +9 -0
  98. data/lib/samagotchi/web/public/stage_view.js +6 -0
  99. data/lib/samagotchi/web/public/stream_handlers.js +4 -1
  100. data/lib/samagotchi/web/public/turn_events.js +4 -0
  101. data/lib/samagotchi/web/public/turn_html.js +9 -1
  102. data/lib/samagotchi/web/public/unqueue.js +56 -0
  103. data/lib/samagotchi/web/server.rb +4 -0
  104. data/lib/samagotchi/worker.rb +19 -5
  105. data/lib/samagotchi/worker_inbound.rb +4 -4
  106. metadata +12 -2
  107. data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
@@ -0,0 +1,348 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require "stringio"
5
+ require "tmpdir"
6
+ require_relative "../memory_bundle"
7
+ require_relative "../memory_bundle/shas"
8
+ require_relative "../memory_bundle/memory_files"
9
+ require_relative "../plugin/api"
10
+ require_relative "../version"
11
+
12
+ module Samagotchi
13
+ class BundleCommand
14
+ # `chi bundle check [PATH] [--fix] [--set KEY=VALUE]`: what keeps the
15
+ # bundle at PATH from installing and loading cleanly, one finding per
16
+ # line (docs/cli.md):
17
+ #
18
+ # error|warn|info CODE file[:line]: message (fix: hint)
19
+ #
20
+ # It installs and loads the bundle in a Testing::Sandbox (temp HOME and
21
+ # XDG dirs), not in the user's config or state, but in this process: the
22
+ # plugin's and hooks' own code runs as written; --fix rewrites stale
23
+ # sha256 lines (MemoryBundle::Shas). #run returns 0 without errors, 1
24
+ # with any.
25
+ class Check
26
+ Finding = Data.define(:level, :code, :file, :line, :message, :fix) do
27
+ def error? = level == :error
28
+
29
+ def to_s
30
+ where = line ? "#{file}:#{line}" : file
31
+ "#{level} #{code} #{where}: #{message}#{" (fix: #{fix})" if fix}"
32
+ end
33
+ end
34
+
35
+ LIB_DIR = File.expand_path("../..", __dir__)
36
+ TRUSTED = %w[reviewed trusted].freeze
37
+ # A call of the plugin API, or a name SINCE lists, in the source.
38
+ API_CALL = /\b(?:chi|ctx)\.[a-z_]+\b|\bfallback_for:|\bSamagotchi::AtomicFile\b/
39
+ # A requirement's lower bound: >= X, > X, ~> X or = X.
40
+ LOWER_BOUND = /\A(?:>=|>|~>|=)?\s*(\d+(?:\.\d+)*)\z/
41
+
42
+ # @param dir [String] the bundle's folder
43
+ # @param fix [Boolean] rewrite stale sha lines
44
+ # @param settings [Hash] config.yml `bundles: <name>:` for the load
45
+ def initialize(dir, fix: false, settings: {}, stdout: $stdout)
46
+ @dir = File.expand_path(dir)
47
+ @fix = fix
48
+ @settings = settings
49
+ @stdout = stdout
50
+ @findings = []
51
+ end
52
+
53
+ # @return [Integer] exit status
54
+ def run
55
+ manifest = read_manifest
56
+ if manifest
57
+ missing = check_files(manifest)
58
+ check_unlisted(manifest)
59
+ check_shas unless missing
60
+ check_requires_chi(manifest)
61
+ check_trust(manifest)
62
+ check_clash(manifest)
63
+ check_install(manifest) unless missing || placeholders?
64
+ check_boundary(manifest)
65
+ check_tests
66
+ end
67
+ errors = @findings.count(&:error?)
68
+ warnings = @findings.count { |f| f.level == :warn }
69
+ @stdout.puts "#{errors.zero? ? "ok" : "failed"}: #{errors} error#{"s" unless errors == 1}, " \
70
+ "#{warnings} warning#{"s" unless warnings == 1} (#{display(@dir)})"
71
+ errors.zero? ? CLI::Exit::OK : CLI::Exit::FAILED
72
+ end
73
+
74
+ private
75
+
76
+ def add(level, code, file, message, line: nil, fix: nil)
77
+ finding = Finding.new(level: level, code: code, file: file, line: line, message: message, fix: fix)
78
+ @findings << finding
79
+ @stdout.puts finding
80
+ end
81
+
82
+ def path(rel) = File.join(@dir, rel)
83
+
84
+ def display(dir) = dir.start_with?("#{Dir.pwd}/") ? dir.delete_prefix("#{Dir.pwd}/") : dir
85
+
86
+ # Through symlinks on both sides: a linked path to chi's own bundle is it.
87
+ def shipped? = File.dirname(File.realpath(@dir)) == File.realpath(MemoryBundle::SourceNormalizer::SHIPPED_DIR)
88
+
89
+ # ── manifest ──────────────────────────────────────────────────────
90
+
91
+ # The manifest, parsed with its placeholder shas taken (a stale sha is
92
+ # check_shas' finding, not a parse error); nil after a `manifest` error.
93
+ def read_manifest
94
+ unless File.file?(path("manifest.yml"))
95
+ add(:error, :manifest, "manifest.yml", "no manifest.yml in #{display(@dir)}", fix: "chi bundle new NAME")
96
+ return nil
97
+ end
98
+
99
+ @text = File.read(path("manifest.yml"))
100
+ Dir.mktmpdir("chi-bundle-check-") do |tmp|
101
+ File.write(File.join(tmp, "manifest.yml"), MemoryBundle::Shas.parseable(@text))
102
+ MemoryBundle::Manifest.read(dir: tmp)
103
+ rescue MemoryBundle::Manifest::ValidationError => e
104
+ add(:error, :manifest, "manifest.yml", e.message.gsub(tmp, display(@dir)))
105
+ nil
106
+ end
107
+ end
108
+
109
+ # The line of manifest.yml naming +file+ (its entry), or nil.
110
+ def manifest_line(file)
111
+ base = File.basename(file)
112
+ index = @text.lines.index { |l| l.match?(/\A\s*(?:file: )?["']?#{Regexp.escape(base)}["']?:?(?:\s|\z)/) }
113
+ index && (index + 1)
114
+ end
115
+
116
+ # ── files ─────────────────────────────────────────────────────────
117
+
118
+ # @return [Boolean] whether a listed file is missing
119
+ def check_files(manifest)
120
+ listed = manifest.files.keys + Array(manifest.plugin && manifest.plugin[:file]) +
121
+ manifest.hooks.keys.map { |h| File.join("hooks", h) } + manifest.scripts.keys.map { |s| File.join("scripts", s) }
122
+ missing = listed.reject { |rel| File.file?(path(rel)) }
123
+ missing.each do |rel|
124
+ add(:error, :"missing-file", "manifest.yml", "lists #{rel}, which isn't in the bundle", line: manifest_line(rel),
125
+ fix: "add the file or drop the entry")
126
+ end
127
+ !missing.empty?
128
+ end
129
+
130
+ # What an install leaves out: a memory files: doesn't list (by
131
+ # MemoryFiles' rules), a plugin, hook or script the manifest doesn't name.
132
+ def check_unlisted(manifest)
133
+ memories = MemoryBundle::MemoryFiles.select(@dir, manifest)
134
+ loose = Dir.glob(path("*.md")).map { |f| File.basename(f) }.sort.reject do |name|
135
+ memories.include?(name) || MemoryBundle::MemoryFiles.repo_meta?(name)
136
+ end
137
+ loose.each { |name| unlisted(name, "files:") }
138
+ plugin = manifest.plugin && manifest.plugin[:file]
139
+ unlisted("plugin.rb", "plugin:") if File.file?(path("plugin.rb")) && plugin != "plugin.rb"
140
+ Dir.glob(path("hooks/*.rb")).sort.each do |file|
141
+ unlisted("hooks/#{File.basename(file)}", "hooks:") unless manifest.hooks.key?(File.basename(file))
142
+ end
143
+ Dir.glob(path("scripts/*")).sort.each do |file|
144
+ unlisted("scripts/#{File.basename(file)}", "scripts:") unless manifest.scripts.key?(File.basename(file))
145
+ end
146
+ end
147
+
148
+ def unlisted(rel, key)
149
+ add(:warn, :unlisted, rel, "manifest.yml's #{key} doesn't list it, so an install leaves it out",
150
+ fix: "list it under #{key}, then chi bundle check --fix")
151
+ end
152
+
153
+ # ── sha256 lines ──────────────────────────────────────────────────
154
+
155
+ def check_shas
156
+ if @fix
157
+ text, changes = MemoryBundle::Shas.refresh(@dir, @text)
158
+ unless changes.empty?
159
+ File.write(path("manifest.yml"), text)
160
+ @text = text
161
+ changes.each { |file, _old, new| @stdout.puts "fixed sha #{file} → sha256:#{new[0, 12]}…" }
162
+ end
163
+ end
164
+ MemoryBundle::Shas.stale(@dir, @text).each do |stale|
165
+ line = manifest_line(stale.file)
166
+ if stale.old.nil?
167
+ add(:error, :sha, "manifest.yml", "#{stale.file} has no sha256 line, so it installs unchecked", line: line,
168
+ fix: "add `sha256: sha256:` under its entry, then chi bundle check --fix")
169
+ else
170
+ what = stale.old.empty? ? "a placeholder sha256" : "a stale sha256"
171
+ add(:error, :sha, "manifest.yml", "#{what} for #{stale.file}", line: line, fix: "chi bundle check --fix")
172
+ end
173
+ end
174
+ rescue MemoryBundle::Shas::Error => e
175
+ add(:error, :sha, "manifest.yml", e.message.sub(%r{\A[^/]+/manifest\.yml:? ?}, ""))
176
+ end
177
+
178
+ # ── requires_chi ──────────────────────────────────────────────────
179
+
180
+ def code?(manifest) = manifest.plugin || manifest.hooks.any? || manifest.scripts.any?
181
+
182
+ def check_requires_chi(manifest)
183
+ line = manifest_line("requires_chi")
184
+ if (failure = MemoryBundle::Manifest.requires_chi_failure(manifest.requires_chi, VERSION))
185
+ @too_new = true
186
+ add(:error, :"requires-chi", "manifest.yml", "this chi can't load it: #{failure}", line: line,
187
+ fix: "chi update, or lower requires_chi")
188
+ elsif manifest.requires_chi.nil? && code?(manifest)
189
+ add(:warn, :"requires-chi", "manifest.yml", "no requires_chi for a bundle with code", fix: "add requires_chi: \">= #{VERSION}\"")
190
+ end
191
+ check_floor(manifest, line) if manifest.plugin && manifest.requires_chi && !@too_new
192
+ end
193
+
194
+ # The plugin calls an API newer than the oldest chi requires_chi admits.
195
+ def check_floor(manifest, line)
196
+ floor = lower_bound(manifest.requires_chi)
197
+ return unless floor && File.file?(path(manifest.plugin[:file]))
198
+
199
+ calls = File.read(path(manifest.plugin[:file])).scan(API_CALL).uniq.select { |call| Plugin::Api::SINCE.key?(call) }
200
+ call = calls.max_by { |c| Gem::Version.new(Plugin::Api::SINCE[c]) } or return
201
+ version = Plugin::Api::SINCE[call]
202
+ return unless Gem::Version.new(version) > floor
203
+
204
+ add(:warn, :"requires-chi-floor", "manifest.yml",
205
+ "requires_chi #{manifest.requires_chi.inspect} admits chi #{floor}, but #{manifest.plugin[:file]} uses " \
206
+ "#{call}, which needs chi #{version}", line: line,
207
+ fix: "requires_chi: \">= #{version}\"")
208
+ end
209
+
210
+ def lower_bound(requirement)
211
+ requirement.to_s.split(",").filter_map do |part|
212
+ version = part.strip[LOWER_BOUND, 1]
213
+ Gem::Version.new(version) if version
214
+ end.max
215
+ end
216
+
217
+ def check_trust(manifest)
218
+ return if shipped? || !TRUSTED.include?(manifest.trust_level)
219
+
220
+ add(:warn, :trust, "manifest.yml", "trust_level: #{manifest.trust_level} is set by the bundle's author",
221
+ line: manifest_line("trust_level"), fix: "leave it out unless someone reviewed the bundle")
222
+ end
223
+
224
+ # ── install and load ──────────────────────────────────────────────
225
+
226
+ # Before the sandbox: the user's installed bundles and chi's shipped ones.
227
+ def check_clash(manifest)
228
+ name = manifest.name
229
+ shipped = MemoryBundle::Listing.shipped.map(&:name) + [MemoryBundle::SystemBundle::BUNDLE_NAME]
230
+ if shipped.include?(name) && !shipped?
231
+ add(:warn, :clash, "manifest.yml", "chi ships a bundle named #{name}; installing this one replaces chi's",
232
+ line: manifest_line("name"), fix: "rename the bundle")
233
+ end
234
+ # A shipped bundle installed from any chi's copy is that bundle.
235
+ return if shipped?
236
+
237
+ record = MemoryBundle::Provenance.new(name: name).record
238
+ return unless record && File.expand_path(record.source.to_s) != @dir
239
+
240
+ add(:warn, :clash, "manifest.yml", "a bundle named #{name} is installed from #{record.source}",
241
+ line: manifest_line("name"), fix: "rename the bundle, or chi bundle upgrade #{display(@dir)} to replace it")
242
+ rescue StandardError
243
+ nil
244
+ end
245
+
246
+ # An unfilled `sha256: sha256:` isn't YAML: the Installer can't read
247
+ # the manifest until --fix fills it.
248
+ def placeholders?
249
+ return false unless @findings.any? { |f| f.code == :sha && f.message.start_with?("a placeholder") }
250
+
251
+ add(:info, :install, "manifest.yml", "skipped until the placeholder shas are filled", fix: "chi bundle check --fix")
252
+ true
253
+ end
254
+
255
+ def check_install(manifest)
256
+ require_relative "../testing"
257
+ result = nil
258
+ # The log's echoes (the "experimental" line every external bundle
259
+ # gets) are left out: what failed is in the result.
260
+ quietly do
261
+ Testing::Sandbox.with do |sandbox|
262
+ result = Testing::Install.run(@dir, settings: @settings, sandbox: sandbox, force: true)
263
+ end
264
+ end
265
+ install_warnings(result.warnings)
266
+ # The loaders refuse a bundle requires_chi rules out: that's the requires-chi error.
267
+ result.failures.each { |failure| install_failure(failure) unless @too_new && failure.reason.include?("it requires chi") }
268
+ report_registered(manifest, result.registered)
269
+ rescue MemoryBundle::Installer::InstallError, MemoryBundle::Manifest::ValidationError => e
270
+ add(:error, :install, "manifest.yml", e.message)
271
+ rescue StandardError => e
272
+ # Whatever else the install or the load raised: one finding, not a trace.
273
+ add(:error, :install, "manifest.yml", "the sandboxed install failed: #{e.class}: #{e.message}")
274
+ end
275
+
276
+ def quietly
277
+ saved = $stderr
278
+ $stderr = StringIO.new
279
+ yield
280
+ ensure
281
+ $stderr = saved
282
+ end
283
+
284
+ def install_warnings(warnings)
285
+ stale_reported = @findings.any? { |f| f.code == :sha }
286
+ warnings.each do |warning|
287
+ next if stale_reported && warning.start_with?("Checksum mismatch")
288
+ next if @too_new && warning.include?("won't load:")
289
+
290
+ if warning.start_with?("needs ")
291
+ add(:warn, :install, "manifest.yml", warning)
292
+ else
293
+ add(:error, :install, "manifest.yml", warning)
294
+ end
295
+ end
296
+ end
297
+
298
+ def install_failure(failure)
299
+ file, line = failure.location&.match(/\A(.+):(\d+)\z/)&.captures
300
+ file = file ? file.delete_prefix("#{@dir}/") : "manifest.yml"
301
+ add(:error, :install, file, "#{failure.what} failed to load: #{failure.reason}", line: line&.to_i)
302
+ end
303
+
304
+ def report_registered(manifest, registered)
305
+ plugin = manifest.plugin ? manifest.plugin[:file] : "manifest.yml"
306
+ registered[:commands].each { |name, _, _| add(:info, :registered, plugin, "command #{name}") }
307
+ registered[:tools].each { |name| add(:info, :registered, plugin, "tool #{name}") }
308
+ registered[:hooks].each do |event, hooks|
309
+ hooks.each do |_bundle, hook, priority|
310
+ file = manifest.hooks.key?(hook) ? "hooks/#{hook}" : plugin
311
+ next add(:info, :registered, file, "hook #{event} (priority #{priority})") if Hooks::EVENTS.include?(event)
312
+
313
+ add(:warn, :"hook-event", file, "hook on #{event.inspect}, which chi never fires",
314
+ fix: "one of #{Hooks::EVENTS.join(", ")}")
315
+ end
316
+ end
317
+ { inits: "init", toolkits: "toolkit", tool_sources: "tool source", peer_sources: "peer source" }.each do |key, what|
318
+ registered[key].each { |name| add(:info, :registered, plugin, "#{what} #{name}") }
319
+ end
320
+ end
321
+
322
+ # ── what it reaches of chi ────────────────────────────────────────
323
+
324
+ def check_boundary(manifest)
325
+ require_relative "../testing/boundary"
326
+ known = shipped? ? Testing::Boundary::EXCEPTIONS.fetch(manifest.name, {}) : {}
327
+ Testing::Boundary.scan(@dir).reject { |f| known.key?(f.reference) }.each do |finding|
328
+ add(:warn, :boundary, finding.file, "#{finding.reference} is internal to chi and may break on a chi upgrade",
329
+ fix: "use chi.* / ctx, or the names docs/plugins.md lists as stable")
330
+ end
331
+ end
332
+
333
+ # ── tests ─────────────────────────────────────────────────────────
334
+
335
+ def check_tests
336
+ tests = Dir.glob(path("test/**/*_test.rb")).sort.map { |f| f.delete_prefix("#{@dir}/") }
337
+ if tests.any?
338
+ add(:info, :tests, "test/", "#{tests.size} file#{"s" unless tests.size == 1}: run chi bundle test " \
339
+ "(Minitest on #{RbConfig.ruby}, -I #{LIB_DIR})")
340
+ elsif File.directory?(path("spec"))
341
+ add(:info, :tests, "spec/", "chi bundle test (runs rspec with the kit)")
342
+ else
343
+ add(:info, :tests, display(@dir), "no tests", fix: "chi bundle new NAME writes one to copy")
344
+ end
345
+ end
346
+ end
347
+ end
348
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../memory_bundle/shas"
5
+ require_relative "../version"
6
+
7
+ module Samagotchi
8
+ class BundleCommand
9
+ # `chi bundle new NAME [--dir DIR]`: a bundle to start from, in DIR/NAME:
10
+ # a manifest (block-form plugin: entry, its sha filled, requires_chi at
11
+ # this chi), a plugin with one command and one after_tool_call hook, a
12
+ # Minitest test on the kit that passes as written, and a README. The
13
+ # test is the pattern to copy (docs/bundle-authoring.md).
14
+ class Scaffold
15
+ # A name that is also a slash command (Plugin::Api::COMMAND_NAME).
16
+ NAME = /\A[a-z][a-z0-9_-]{0,31}\z/
17
+
18
+ # @return [String] the bundle's folder
19
+ attr_reader :dir
20
+
21
+ def initialize(name, parent: ".")
22
+ @name = name
23
+ @dir = File.expand_path(name, parent)
24
+ end
25
+
26
+ # Write the files; ArgumentError for a bad name or a folder that's there.
27
+ # @return [Array<String>] the files written, relative to #dir
28
+ def write
29
+ raise ArgumentError, "#{@name.inspect} isn't a bundle name: a-z first, then a-z, 0-9, - or _ (32 at most)" unless @name.match?(NAME)
30
+ raise ArgumentError, "#{@dir} exists already" if File.exist?(@dir)
31
+
32
+ files.each do |rel, text|
33
+ FileUtils.mkdir_p(File.dirname(File.join(@dir, rel)))
34
+ File.write(File.join(@dir, rel), text)
35
+ end
36
+ File.write(File.join(@dir, "manifest.yml"), MemoryBundle::Shas.refresh(@dir).first)
37
+ files.keys
38
+ end
39
+
40
+ private
41
+
42
+ def test_file = "test/#{@name.tr("-", "_")}_test.rb"
43
+ def test_class = "#{@name.split(/[-_]/).map(&:capitalize).join}Test"
44
+
45
+ def files
46
+ { "manifest.yml" => manifest, "plugin.rb" => plugin, test_file => test, "README.md" => readme }
47
+ end
48
+
49
+ def manifest
50
+ <<~YAML
51
+ name: #{@name}
52
+ version: 0.1.0
53
+ scope: system
54
+ description: "What #{@name} adds to chi, in one line"
55
+ requires_chi: ">= #{VERSION}"
56
+ plugin:
57
+ file: plugin.rb
58
+ sha256: sha256:
59
+ YAML
60
+ end
61
+
62
+ def plugin
63
+ <<~RUBY
64
+ # frozen_string_literal: true
65
+
66
+ # #{@name}: one slash command and one hook (docs/bundle-authoring.md in chi's source dir).
67
+ class Plugin
68
+ # settings: config.yml `bundles: #{@name}:` (string keys), read once when the plugin loads.
69
+ def initialize(settings)
70
+ @greeting = settings.fetch("greeting", "hello")
71
+ end
72
+
73
+ def register(chi)
74
+ # /#{@name} [WHO]: the block gets the text after the name and returns what to show.
75
+ chi.command("/#{@name}", "say hello") do |args, _ctx|
76
+ "\#{@greeting}, \#{args.empty? ? "there" : args}"
77
+ end
78
+
79
+ # After each tool call: an execute that exited non-zero gets one notice. Match on event[:result]
80
+ # (the tool's own text, ending in "exit: N"), not event[:output] (what the model reads, maybe cut).
81
+ chi.on(:after_tool_call) do |event, ctx|
82
+ next unless event[:tool] == "execute"
83
+
84
+ code = event[:result].to_s[/^exit: (\\d+)/, 1].to_i
85
+ ctx.notify("the command exited \#{code}", level: :warn) unless code.zero?
86
+ end
87
+ end
88
+ end
89
+ RUBY
90
+ end
91
+
92
+ def test
93
+ <<~RUBY
94
+ # frozen_string_literal: true
95
+
96
+ require "minitest/autorun"
97
+ require "samagotchi/testing/minitest"
98
+
99
+ # `chi bundle test` runs this with chi's own Ruby and lib. Each test gets a sandbox (temp HOME, config,
100
+ # state); load_bundle loads the bundle's source through chi's loaders, and the recorders show what it did.
101
+ class #{test_class} < Samagotchi::Testing::Case
102
+ BUNDLE = File.expand_path("..", __dir__)
103
+
104
+ def test_the_command_greets
105
+ assert_equal "hello, chi", load_bundle(BUNDLE).command("/#{@name} chi")
106
+ assert_equal "hi, there", load_bundle(BUNDLE, settings: { "greeting" => "hi" }).command("/#{@name}")
107
+ end
108
+
109
+ def test_a_failed_execute_gets_a_notice
110
+ bundle = load_bundle(BUNDLE)
111
+ # An execute's after_tool_call as chi fires it (docs/hooks.md, Hook Events).
112
+ bundle.fire(:after_tool_call, tool: "execute", status: "error", iteration: 1, elapsed_ms: 12,
113
+ call: { name: "execute", content: "false" },
114
+ result: "exit: 1 (no output)", output: "[execute]\\nexit: 1 (no output)")
115
+ bundle.fire(:after_tool_call, tool: "execute", status: "ok", iteration: 2, elapsed_ms: 9,
116
+ call: { name: "execute", content: "echo hi" },
117
+ result: "stdout:\\nhi\\n\\nexit: 0", output: "[execute]\\nstdout:\\nhi\\n\\nexit: 0")
118
+
119
+ assert_equal [["the command exited 1", :warn]], bundle.notices.map { |n| [n.text, n.level] }
120
+ end
121
+ end
122
+ RUBY
123
+ end
124
+
125
+ def readme
126
+ <<~MD
127
+ # #{@name}
128
+
129
+ A chi bundle: `/#{@name}`, and a notice when a command fails.
130
+ Check it with `chi bundle check`, test it with `chi bundle test`, install it with `chi bundle install .`
131
+ MD
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module Samagotchi
6
+ class BundleCommand
7
+ # `chi bundle test [PATH] [-- ARGS]`: a bundle's tests on the Ruby and
8
+ # the lib chi runs on (RbConfig.ruby, -I chi's lib), which an external
9
+ # bundle can't easily find itself (mise, rbenv, gem paths). test/**/
10
+ # *_test.rb runs on Minitest with the kit's Case; a spec/ with no test/
11
+ # on RSpec with the kit's helpers, as `rake bundles:specs` runs the
12
+ # shipped ones. Outside any Bundler env (Bundler.with_unbundled_env, as
13
+ # SessionManager's unbundled_env_changes): chi's gems don't include minitest.
14
+ # When the framework loads only inside the Bundler env chi itself runs in
15
+ # (a checkout or a vendored install: its gems in vendor/bundle), there.
16
+ # The tests' exit status is #run's.
17
+ class TestRun
18
+ LIB_DIR = File.expand_path("../..", __dir__)
19
+
20
+ def initialize(dir, args: [], stdout: $stdout, stderr: $stderr)
21
+ @dir = File.expand_path(dir)
22
+ @args = args
23
+ @stdout = stdout
24
+ @stderr = stderr
25
+ end
26
+
27
+ # @return [Integer] exit status
28
+ def run
29
+ tests = Dir.glob(File.join(@dir, "test", "**", "*_test.rb")).sort
30
+ return minitest(tests) if tests.any?
31
+ return rspec if File.directory?(File.join(@dir, "spec"))
32
+
33
+ @stderr.puts "chi bundle test: no test/*_test.rb or spec/ in #{@dir} (chi bundle new NAME writes a test to copy)"
34
+ CLI::Exit::FAILED
35
+ end
36
+
37
+ private
38
+
39
+ def minitest(tests)
40
+ return missing("minitest", "gem install minitest") unless loads?("minitest")
41
+
42
+ requires = tests.map { |file| "require #{file.inspect}" }.join("; ")
43
+ say("#{tests.size} test file#{"s" unless tests.size == 1}")
44
+ spawn(RbConfig.ruby, "-I", LIB_DIR, "-r", "minitest/autorun", "-r", "samagotchi/testing/minitest", "-e", requires,
45
+ "--", *@args)
46
+ end
47
+
48
+ def rspec
49
+ return missing("rspec", "gem install rspec") unless loads?("rspec/core")
50
+
51
+ say("spec/ on RSpec")
52
+ spawn(RbConfig.ruby, "-I", LIB_DIR, "-r", "rspec/core", "-e", "exit RSpec::Core::Runner.run(ARGV)", "--",
53
+ "--options", File::NULL, "-r", "samagotchi/testing/rspec", "spec", *@args)
54
+ end
55
+
56
+ def say(what) = @stdout.puts("chi bundle test: #{what} on #{RbConfig.ruby} (chi's lib #{LIB_DIR})")
57
+
58
+ def missing(lib, hint)
59
+ @stderr.puts "chi bundle test: #{lib} doesn't load on chi's Ruby (#{RbConfig.ruby}); #{hint}"
60
+ CLI::Exit::USAGE
61
+ end
62
+
63
+ # Whether +lib+ loads unbundled, else in chi's own Bundler env (then
64
+ # #spawn stays in it).
65
+ def loads?(lib)
66
+ check = -> { system(RbConfig.ruby, "-e", "require #{lib.inspect}", out: File::NULL, err: File::NULL) }
67
+ @bundled = false
68
+ return true if unbundled(&check)
69
+
70
+ @bundled = bundler?
71
+ @bundled && check.call
72
+ end
73
+
74
+ def spawn(*command)
75
+ @stdout.flush
76
+ run = -> { system(*command, chdir: @dir) }
77
+ @bundled ? run.call : unbundled(&run)
78
+ $?.exitstatus || CLI::Exit::FAILED
79
+ end
80
+
81
+ def unbundled(&)
82
+ bundler? ? ::Bundler.with_unbundled_env(&) : yield
83
+ end
84
+
85
+ # Bundler may be defined without its runtime (RubyGems' own stub).
86
+ def bundler? = defined?(::Bundler) && ::Bundler.respond_to?(:with_unbundled_env)
87
+ end
88
+ end
89
+ end