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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -1
- data/docs/architecture.md +44 -6
- data/docs/bundle-authoring.md +125 -0
- data/docs/cli.md +54 -4
- data/docs/configuration.md +8 -5
- data/docs/guardrails.md +63 -35
- data/docs/hooks.md +20 -4
- data/docs/internals/anthropic.md +2 -2
- data/docs/internals/bundles.md +11 -2
- data/docs/internals/context-telemetry.md +19 -1
- data/docs/internals/engine.md +4 -3
- data/docs/memory.md +1 -1
- data/docs/plugins.md +59 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +8 -6
- data/docs/sub-agent.md +35 -24
- data/docs/testing.md +7 -2
- data/lib/samagotchi/answer_command.rb +21 -25
- data/lib/samagotchi/bridge/client_event.rb +50 -0
- data/lib/samagotchi/bridge/sse_writer.rb +6 -2
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
- data/lib/samagotchi/bridge.rb +76 -12
- data/lib/samagotchi/bridge_client.rb +14 -2
- data/lib/samagotchi/bundle_command/check.rb +348 -0
- data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
- data/lib/samagotchi/bundle_command/test_run.rb +89 -0
- data/lib/samagotchi/bundle_command.rb +125 -2
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
- data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/client_id.rb +1 -1
- data/lib/samagotchi/config.rb +195 -115
- data/lib/samagotchi/context_status.rb +1 -1
- data/lib/samagotchi/continue_offer.rb +2 -1
- data/lib/samagotchi/engine.rb +5 -5
- data/lib/samagotchi/guardrail_wiring.rb +4 -0
- data/lib/samagotchi/guardrails/approval.rb +12 -2
- data/lib/samagotchi/guardrails/load_failures.rb +17 -3
- data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
- data/lib/samagotchi/guardrails/parent_policy.rb +258 -0
- data/lib/samagotchi/guardrails/verdict.rb +6 -3
- data/lib/samagotchi/hook_routes.rb +5 -3
- data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
- data/lib/samagotchi/hooks/registry.rb +13 -6
- data/lib/samagotchi/hooks.rb +5 -0
- data/lib/samagotchi/kernel_loop.rb +10 -3
- data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
- data/lib/samagotchi/llm/chat_loop.rb +5 -1
- data/lib/samagotchi/log_subscriber.rb +1 -0
- data/lib/samagotchi/memory_bundle/shas.rb +127 -0
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/parent_report.rb +92 -16
- data/lib/samagotchi/pending_question.rb +8 -4
- data/lib/samagotchi/plugin/api.rb +16 -0
- data/lib/samagotchi/plugin/context.rb +22 -12
- data/lib/samagotchi/plugin/loader.rb +5 -3
- data/lib/samagotchi/question_desk.rb +37 -24
- data/lib/samagotchi/queued_command.rb +5 -4
- data/lib/samagotchi/relay.rb +5 -1
- data/lib/samagotchi/relay_verifier.rb +4 -5
- data/lib/samagotchi/reply_wait.rb +22 -6
- data/lib/samagotchi/send_command.rb +34 -8
- data/lib/samagotchi/session_commands.rb +21 -7
- data/lib/samagotchi/session_inbox.rb +68 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +102 -12
- data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
- data/lib/samagotchi/terminal_ui.rb +15 -9
- data/lib/samagotchi/testing/boundary.rb +0 -3
- data/lib/samagotchi/testing/bundle.rb +7 -0
- data/lib/samagotchi/testing/host.rb +6 -4
- data/lib/samagotchi/testing/install.rb +119 -0
- data/lib/samagotchi/testing/recorder.rb +13 -4
- data/lib/samagotchi/testing.rb +1 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/delegate_wait.rb +6 -1
- data/lib/samagotchi/tools/task_runtime.rb +5 -3
- data/lib/samagotchi/turn_checkpoint.rb +64 -2
- data/lib/samagotchi/turn_flow.rb +10 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +76 -10
- data/lib/samagotchi/web/public/app.js +17 -0
- data/lib/samagotchi/web/public/bubbles.js +9 -0
- data/lib/samagotchi/web/public/chunk_router.js +8 -19
- data/lib/samagotchi/web/public/commands.js +1 -0
- data/lib/samagotchi/web/public/ctx.js +12 -26
- data/lib/samagotchi/web/public/data.js +11 -0
- data/lib/samagotchi/web/public/index.html +9 -0
- data/lib/samagotchi/web/public/stage_view.js +6 -0
- data/lib/samagotchi/web/public/stream_handlers.js +4 -1
- data/lib/samagotchi/web/public/turn_events.js +4 -0
- data/lib/samagotchi/web/public/turn_html.js +9 -1
- data/lib/samagotchi/web/public/unqueue.js +56 -0
- data/lib/samagotchi/web/server.rb +4 -0
- data/lib/samagotchi/worker.rb +19 -5
- data/lib/samagotchi/worker_inbound.rb +4 -4
- metadata +12 -2
- 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
|