samagotchi 0.2.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 +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- metadata +374 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
module MemoryBundle
|
|
9
|
+
# Parses and validates a bundle manifest.yml.
|
|
10
|
+
#
|
|
11
|
+
# Expected structure:
|
|
12
|
+
# name: my-bundle
|
|
13
|
+
# version: 1.0.0
|
|
14
|
+
# scope: system # optional — CLI --scope wins over this
|
|
15
|
+
# description: "…" # optional
|
|
16
|
+
# files:
|
|
17
|
+
# identity.md: sha256:abc123...
|
|
18
|
+
# commit_preferences.md: sha256:def456...
|
|
19
|
+
# plugin: # optional — the bundle's plugin.rb (docs/plugins.md)
|
|
20
|
+
# file: plugin.rb
|
|
21
|
+
# sha256: sha256:0a1b...
|
|
22
|
+
# requires_chi: ">= 0.1.28" # optional — a Gem::Requirement for chi's VERSION
|
|
23
|
+
# needs: # optional — outside commands the memories rely on
|
|
24
|
+
# - command: gh # (checked on PATH, never installed; docs/memory.md)
|
|
25
|
+
# why: reads PRs # optional
|
|
26
|
+
# hint: brew install gh # optional
|
|
27
|
+
# - jq # short form: just the command
|
|
28
|
+
class Manifest
|
|
29
|
+
class ValidationError < StandardError; end
|
|
30
|
+
|
|
31
|
+
# A plugin file is a plain .rb name in the bundle's top directory.
|
|
32
|
+
PLUGIN_FILE = /\A[A-Za-z0-9_][A-Za-z0-9_.-]*\.rb\z/
|
|
33
|
+
# A need is a plain executable name: no path, no spaces.
|
|
34
|
+
NEED_COMMAND = /\A[A-Za-z0-9][A-Za-z0-9._+-]*\z/
|
|
35
|
+
|
|
36
|
+
attr_reader :name, :version, :scope, :description, :files, :hooks, :trust_level, :plugin, :requires_chi, :needs
|
|
37
|
+
|
|
38
|
+
def initialize(path:)
|
|
39
|
+
@path = path
|
|
40
|
+
raw = YAML.load_file(path) || {}
|
|
41
|
+
@name = require_str(raw, "name")
|
|
42
|
+
@version = require_str(raw, "version")
|
|
43
|
+
@scope = normalize_scope(raw["scope"])
|
|
44
|
+
@description = (raw["description"] || "").to_s
|
|
45
|
+
@files = parse_files(raw["files"] || {})
|
|
46
|
+
@hooks = parse_hooks(raw["hooks"] || {})
|
|
47
|
+
@trust_level = (raw["trust_level"] || "experimental").to_s
|
|
48
|
+
@plugin = parse_plugin(raw["plugin"])
|
|
49
|
+
@requires_chi = parse_requires_chi(raw["requires_chi"])
|
|
50
|
+
@needs = self.class.parse_needs(raw["needs"])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.read(dir:)
|
|
54
|
+
manifest_path = File.join(dir, "manifest.yml")
|
|
55
|
+
raise ValidationError, "manifest.yml not found in #{dir}" unless File.exist?(manifest_path)
|
|
56
|
+
new(path: manifest_path)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns the expected sha256 hex digest for a given file key
|
|
60
|
+
# (e.g. "identity.md"), with the "sha256:" prefix stripped.
|
|
61
|
+
def checksum_for(file_key)
|
|
62
|
+
raw = @files[file_key]
|
|
63
|
+
return nil unless raw
|
|
64
|
+
raw.start_with?("sha256:") ? raw[7..] : raw
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns sha256 hex for a hook basename (e.g. "guardrails.rb").
|
|
68
|
+
def checksum_for_hook(basename)
|
|
69
|
+
raw = @hooks[basename]
|
|
70
|
+
return nil unless raw
|
|
71
|
+
sha = raw[:sha256] || raw["sha256"]
|
|
72
|
+
return nil unless sha
|
|
73
|
+
sha.to_s.start_with?("sha256:") ? sha.to_s[7..] : sha.to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [String, nil] the plugin's sha256 hex, "sha256:" stripped
|
|
77
|
+
def checksum_for_plugin
|
|
78
|
+
sha = @plugin && @plugin[:sha256].to_s
|
|
79
|
+
return nil if sha.nil? || sha.empty?
|
|
80
|
+
|
|
81
|
+
sha.delete_prefix("sha256:")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Why chi +version+ can't load this bundle's plugin, or nil when it can
|
|
85
|
+
# (or the bundle names no requirement).
|
|
86
|
+
def self.requires_chi_failure(requirement, version)
|
|
87
|
+
return nil if requirement.nil? || requirement.to_s.strip.empty?
|
|
88
|
+
return nil if Gem::Requirement.new(*requirement.to_s.split(",").map(&:strip)).satisfied_by?(Gem::Version.new(version.to_s))
|
|
89
|
+
|
|
90
|
+
"it requires chi #{requirement} (this is chi #{version})"
|
|
91
|
+
rescue ArgumentError, Gem::Requirement::BadRequirementError => e
|
|
92
|
+
"its requires_chi #{requirement.inspect} is not a version requirement (#{e.message})"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Writes a fresh manifest.yml from computed checksums at `dir`.
|
|
96
|
+
def self.write(dir:, name:, version:, scope: nil, description: "", files:, hooks: nil, trust_level: nil,
|
|
97
|
+
plugin: nil, requires_chi: nil, needs: nil)
|
|
98
|
+
FileUtils.mkdir_p(dir)
|
|
99
|
+
manifest = {
|
|
100
|
+
"name" => name,
|
|
101
|
+
"version" => version,
|
|
102
|
+
"description" => description,
|
|
103
|
+
}
|
|
104
|
+
manifest["scope"] = scope if scope
|
|
105
|
+
manifest["files"] = files # { "file.md" => "sha256:abc..." }
|
|
106
|
+
if hooks && !hooks.empty?
|
|
107
|
+
# Normalize hooks to string-keyed with sha256 prefix preserved
|
|
108
|
+
manifest["hooks"] = hooks.transform_keys(&:to_s).transform_values do |v|
|
|
109
|
+
h = v.transform_keys(&:to_s)
|
|
110
|
+
h["sha256"] = h["sha256"].to_s.start_with?("sha256:") ? h["sha256"].to_s : "sha256:#{h["sha256"]}" if h["sha256"]
|
|
111
|
+
h
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
manifest["trust_level"] = trust_level.to_s if trust_level && !trust_level.to_s.empty?
|
|
115
|
+
if plugin
|
|
116
|
+
sha = plugin[:sha256].to_s
|
|
117
|
+
manifest["plugin"] = { "file" => plugin[:file].to_s, "sha256" => sha.start_with?("sha256:") ? sha : "sha256:#{sha}" }
|
|
118
|
+
end
|
|
119
|
+
manifest["requires_chi"] = requires_chi.to_s if requires_chi && !requires_chi.to_s.empty?
|
|
120
|
+
needs = parse_needs(needs)
|
|
121
|
+
manifest["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } unless needs.empty?
|
|
122
|
+
File.write(File.join(dir, "manifest.yml"), YAML.dump(manifest))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# needs: → [{command:, why:, hint:}] (why/hint a String or nil), [] when
|
|
126
|
+
# absent. Takes the manifest's string-keyed YAML or the symbol-keyed
|
|
127
|
+
# form provenance stores. Duplicates merge (the first one's why/hint
|
|
128
|
+
# win); a list that isn't one, or a command that isn't a plain name,
|
|
129
|
+
# is a ValidationError.
|
|
130
|
+
def self.parse_needs(raw)
|
|
131
|
+
return [] if raw.nil?
|
|
132
|
+
raise ValidationError, "needs: must be a list of commands" unless raw.is_a?(Array)
|
|
133
|
+
|
|
134
|
+
raw.each_with_object([]) do |item, acc|
|
|
135
|
+
unless item.is_a?(Hash) || item.is_a?(String) || item.is_a?(Symbol)
|
|
136
|
+
raise ValidationError, "needs: each item must be a command name or a mapping with command:, not #{item.inspect}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
need = item.is_a?(Hash) ? item.transform_keys(&:to_s) : { "command" => item }
|
|
140
|
+
command = need["command"].to_s.strip
|
|
141
|
+
raise ValidationError, "needs: #{command.inspect} is not a plain command name" unless command.match?(NEED_COMMAND)
|
|
142
|
+
next if acc.any? { |n| n[:command] == command }
|
|
143
|
+
|
|
144
|
+
acc << { command: command, why: optional_text(need["why"]), hint: optional_text(need["hint"]) }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def self.optional_text(value)
|
|
149
|
+
text = value.to_s.strip
|
|
150
|
+
text.empty? ? nil : text
|
|
151
|
+
end
|
|
152
|
+
private_class_method :optional_text
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def require_str(hash, key)
|
|
157
|
+
val = hash[key]
|
|
158
|
+
return val if val.is_a?(String) && !val.empty?
|
|
159
|
+
raise ValidationError, "manifest missing required field: #{key}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def normalize_scope(val)
|
|
163
|
+
return nil if val.nil?
|
|
164
|
+
v = val.to_s.strip.downcase
|
|
165
|
+
%w[project system].include?(v) ? v : nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def parse_files(raw)
|
|
169
|
+
return {} unless raw.is_a?(Hash)
|
|
170
|
+
raw.each_with_object({}) do |(k, v), acc|
|
|
171
|
+
next unless k.is_a?(String) && !k.empty?
|
|
172
|
+
str = v.to_s
|
|
173
|
+
acc[k] = if str.start_with?("sha256:")
|
|
174
|
+
str
|
|
175
|
+
else
|
|
176
|
+
"sha256:#{str}"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# plugin: {file:, sha256:} → {file:, sha256:} (sha "" when not given),
|
|
182
|
+
# or nil; a file that isn't a plain .rb name is a validation error.
|
|
183
|
+
def parse_plugin(raw)
|
|
184
|
+
return nil if raw.nil?
|
|
185
|
+
raise ValidationError, "plugin: must be a mapping with file: and sha256:" unless raw.is_a?(Hash)
|
|
186
|
+
|
|
187
|
+
file = (raw["file"] || raw[:file]).to_s.strip
|
|
188
|
+
unless file.match?(PLUGIN_FILE)
|
|
189
|
+
raise ValidationError, "plugin file must be a .rb file name in the bundle's top directory, not #{file.inspect}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
sha = (raw["sha256"] || raw[:sha256]).to_s.strip
|
|
193
|
+
sha = "sha256:#{sha}" unless sha.empty? || sha.start_with?("sha256:")
|
|
194
|
+
{ file: file, sha256: sha }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def parse_requires_chi(raw)
|
|
198
|
+
return nil if raw.nil?
|
|
199
|
+
|
|
200
|
+
text = raw.to_s.strip
|
|
201
|
+
text.empty? ? nil : text
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def parse_hooks(raw)
|
|
205
|
+
return {} unless raw.is_a?(Hash)
|
|
206
|
+
raw.each_with_object({}) do |(k, v), acc|
|
|
207
|
+
next unless k.is_a?(String) && !k.empty?
|
|
208
|
+
next unless v.is_a?(Hash)
|
|
209
|
+
sha = (v["sha256"] || v[:sha256] || "").to_s
|
|
210
|
+
sha = sha.start_with?("sha256:") ? sha : "sha256:#{sha}" unless sha.empty?
|
|
211
|
+
event = (v["event"] || v[:event] || "").to_s.strip
|
|
212
|
+
on_error = (v["on_error"] || v[:on_error] || "skip").to_s.strip
|
|
213
|
+
on_error = "skip" if on_error.empty?
|
|
214
|
+
priority = (v["priority"] || v[:priority] || 100).to_i
|
|
215
|
+
acc[k] = {
|
|
216
|
+
sha256: sha,
|
|
217
|
+
event: event,
|
|
218
|
+
on_error: on_error,
|
|
219
|
+
priority: priority
|
|
220
|
+
}
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "digest"
|
|
3
|
+
|
|
4
|
+
module Samagotchi
|
|
5
|
+
module MemoryBundle
|
|
6
|
+
# 3-way merge helper for bundle upgrades.
|
|
7
|
+
#
|
|
8
|
+
# Compare base (provenance snapshot), current (on-disk), incoming (new bundle)
|
|
9
|
+
# and classify per file. Used by Installer#run when provenance exists.
|
|
10
|
+
module Merger
|
|
11
|
+
# Classify a single file for upgrade.
|
|
12
|
+
# Returns :install, :keep, :fast_forward, :conflict, :noop
|
|
13
|
+
def self.classify(base_path:, current_path:, incoming_path:)
|
|
14
|
+
base_exists = base_path && File.exist?(base_path)
|
|
15
|
+
current_exists = File.exist?(current_path)
|
|
16
|
+
incoming_exists = File.exist?(incoming_path)
|
|
17
|
+
|
|
18
|
+
return :noop unless incoming_exists
|
|
19
|
+
|
|
20
|
+
unless current_exists
|
|
21
|
+
return :install
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# No base (legacy bundle without provenance or first file) -> treat as modified
|
|
25
|
+
unless base_exists
|
|
26
|
+
incoming_digest = Digest::SHA256.hexdigest(File.read(incoming_path))
|
|
27
|
+
current_digest = Digest::SHA256.hexdigest(File.read(current_path))
|
|
28
|
+
return current_digest == incoming_digest ? :noop : :conflict
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
base_digest = Digest::SHA256.hexdigest(File.read(base_path))
|
|
32
|
+
current_digest = Digest::SHA256.hexdigest(File.read(current_path))
|
|
33
|
+
incoming_digest = Digest::SHA256.hexdigest(File.read(incoming_path))
|
|
34
|
+
|
|
35
|
+
if current_digest == incoming_digest
|
|
36
|
+
return :noop
|
|
37
|
+
elsif current_digest == base_digest
|
|
38
|
+
return :fast_forward
|
|
39
|
+
elsif incoming_digest == base_digest
|
|
40
|
+
return :keep
|
|
41
|
+
else
|
|
42
|
+
return :conflict
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.current_modified?(base_path, current_path)
|
|
47
|
+
return true unless base_path && File.exist?(base_path) && File.exist?(current_path)
|
|
48
|
+
Digest::SHA256.hexdigest(File.read(base_path)) != Digest::SHA256.hexdigest(File.read(current_path))
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module MemoryBundle
|
|
5
|
+
# Detects {{placeholder}} patterns in file content.
|
|
6
|
+
# Placeholders are hints, not gates — install succeeds with unfilled ones.
|
|
7
|
+
class Placeholder
|
|
8
|
+
PLACEHOLDER_RE = /\{\{([^}]+)\}\}/
|
|
9
|
+
|
|
10
|
+
attr_reader :placeholders
|
|
11
|
+
|
|
12
|
+
def initialize(content:)
|
|
13
|
+
@placeholders = extract_placeholders(content)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.detect_in_file(path)
|
|
17
|
+
content = File.read(path)
|
|
18
|
+
new(content: content).placeholders
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns a sorted list of unique placeholder names found.
|
|
22
|
+
def unique_names
|
|
23
|
+
@placeholders.map(&:strip).uniq.sort
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def any?
|
|
27
|
+
!@placeholders.empty?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def extract_placeholders(content)
|
|
33
|
+
content.scan(PLACEHOLDER_RE).flatten
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "date"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require_relative "../memory_paths"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
module MemoryBundle
|
|
11
|
+
# Manages provenance storage at <memories>/.bundles/<name>/ (see MemoryPaths)
|
|
12
|
+
# Each installed bundle gets a directory with:
|
|
13
|
+
# manifest.json — metadata about the install (merged on reinstall)
|
|
14
|
+
# bases/ — snapshots of every file as installed (for 3-way merge later)
|
|
15
|
+
class Provenance
|
|
16
|
+
class << self
|
|
17
|
+
attr_accessor :bundles_dir_override
|
|
18
|
+
|
|
19
|
+
def bundles_dir
|
|
20
|
+
bundles_dir_override || MemoryPaths.bundles_dir
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader :name, :bundle_dir
|
|
25
|
+
|
|
26
|
+
def initialize(name:)
|
|
27
|
+
@name = name
|
|
28
|
+
@bundle_dir = File.join(self.class.bundles_dir, name)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def hooks_dir
|
|
32
|
+
File.join(@bundle_dir, "hooks")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Installed guardrail rule files (guardrails/*.yml).
|
|
36
|
+
def guardrails_dir
|
|
37
|
+
File.join(@bundle_dir, "guardrails")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The installed plugin file's dir (plugin/<file>).
|
|
41
|
+
def plugin_dir
|
|
42
|
+
File.join(@bundle_dir, "plugin")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The plugin file as installed, or nil when the bundle has none.
|
|
46
|
+
def plugin_path(data = read)
|
|
47
|
+
file = data && data[:plugin].is_a?(Hash) ? data[:plugin][:file].to_s : ""
|
|
48
|
+
file.empty? ? nil : File.join(plugin_dir, file)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The plugin's base snapshot (bases/plugin/<file>), for bundle diff.
|
|
52
|
+
def plugin_base_path(file)
|
|
53
|
+
File.join(@bundle_dir, "bases", "plugin", file.to_s)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Yields [name, data] for each installed bundle with a plugin, by
|
|
57
|
+
# name. A manifest that doesn't parse is yielded as {error:} when its
|
|
58
|
+
# bundle has a plugin/ dir.
|
|
59
|
+
def self.each_installed_with_plugin
|
|
60
|
+
return enum_for(:each_installed_with_plugin) unless block_given?
|
|
61
|
+
dir = bundles_dir
|
|
62
|
+
return unless Dir.exist?(dir)
|
|
63
|
+
Dir[File.join(dir, "*", "manifest.json")].sort.each do |mjson|
|
|
64
|
+
name = File.basename(File.dirname(mjson))
|
|
65
|
+
begin
|
|
66
|
+
data = JSON.parse(File.read(mjson), symbolize_names: true)
|
|
67
|
+
rescue JSON::ParserError, SystemCallError => e
|
|
68
|
+
yield name, { error: "manifest.json is unreadable: #{e.message}" } if Dir.exist?(File.join(File.dirname(mjson), "plugin"))
|
|
69
|
+
next
|
|
70
|
+
end
|
|
71
|
+
next unless data.is_a?(Hash) && data[:plugin].is_a?(Hash)
|
|
72
|
+
yield name, data
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Yields [name, data] for each installed bundle with guardrail rule
|
|
77
|
+
# files, by name. A manifest that doesn't parse is yielded as
|
|
78
|
+
# {error:} when its bundle has a guardrails/ dir (its rules can't be
|
|
79
|
+
# checked), and skipped otherwise.
|
|
80
|
+
def self.each_installed_with_guardrails
|
|
81
|
+
return enum_for(:each_installed_with_guardrails) unless block_given?
|
|
82
|
+
dir = bundles_dir
|
|
83
|
+
return unless Dir.exist?(dir)
|
|
84
|
+
Dir[File.join(dir, "*", "manifest.json")].sort.each do |mjson|
|
|
85
|
+
name = File.basename(File.dirname(mjson))
|
|
86
|
+
begin
|
|
87
|
+
data = JSON.parse(File.read(mjson), symbolize_names: true)
|
|
88
|
+
rescue JSON::ParserError, SystemCallError => e
|
|
89
|
+
yield name, { error: "manifest.json is unreadable: #{e.message}" } if Dir.exist?(File.join(File.dirname(mjson), "guardrails"))
|
|
90
|
+
next
|
|
91
|
+
end
|
|
92
|
+
next unless data.is_a?(Hash) && data[:guardrails].is_a?(Hash) && !data[:guardrails].empty?
|
|
93
|
+
yield name, data
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.each_installed_holding_hooks
|
|
98
|
+
return enum_for(:each_installed_holding_hooks) unless block_given?
|
|
99
|
+
dir = self.bundles_dir
|
|
100
|
+
return unless Dir.exist?(dir)
|
|
101
|
+
Dir[File.join(dir, "*", "manifest.json")].sort.each do |mjson|
|
|
102
|
+
data = JSON.parse(File.read(mjson), symbolize_names: true)
|
|
103
|
+
next unless data && data[:hooks].is_a?(Hash) && !data[:hooks].empty?
|
|
104
|
+
yield File.basename(File.dirname(mjson)), data
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Writes provenance for an installation: merges into existing manifest.json
|
|
109
|
+
# and saves base snapshots. Stale base snapshots (files removed from bundle)
|
|
110
|
+
# are pruned.
|
|
111
|
+
# @param hooks_files [Hash] basename => path for hook file snapshots (optional)
|
|
112
|
+
# @param guardrails_files [Hash] basename => installed rule file; the
|
|
113
|
+
# sha256 of each is recorded (the Engine checks it at load)
|
|
114
|
+
# @param plugin_file [String, nil] the installed plugin file; its name
|
|
115
|
+
# and sha256 are recorded (the Engine checks it at load) with a base
|
|
116
|
+
# snapshot
|
|
117
|
+
# @param requires_chi [String, nil] the manifest's requirement
|
|
118
|
+
# @param needs [Array<Hash>] the manifest's needs ({command:, why:, hint:})
|
|
119
|
+
def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
|
|
120
|
+
guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil)
|
|
121
|
+
FileUtils.mkdir_p(@bundle_dir)
|
|
122
|
+
bases_dir = File.join(@bundle_dir, "bases")
|
|
123
|
+
FileUtils.mkdir_p(bases_dir)
|
|
124
|
+
|
|
125
|
+
# Read existing manifest to merge file entries and find stale files.
|
|
126
|
+
# read symbolizes keys; stringify them so incoming string keys replace
|
|
127
|
+
# them instead of sitting next to them as duplicates.
|
|
128
|
+
existing = read
|
|
129
|
+
merged_entries = existing && existing[:files] ? existing[:files].transform_keys(&:to_s) : {}
|
|
130
|
+
|
|
131
|
+
files.each do |file_key, file_path|
|
|
132
|
+
content = File.read(file_path)
|
|
133
|
+
checksum = Digest::SHA256.hexdigest(content)
|
|
134
|
+
# Save base snapshot — use file_key as-is (it already includes .md).
|
|
135
|
+
File.write(File.join(bases_dir, file_key), content)
|
|
136
|
+
merged_entries[file_key] = { checksum: checksum }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Prune stale base snapshots for files no longer in the bundle.
|
|
140
|
+
if existing && existing[:files]
|
|
141
|
+
existing[:files].keys.each do |old_key|
|
|
142
|
+
old_key_str = old_key.to_s
|
|
143
|
+
unless files.key?(old_key_str) || files.key?(old_key)
|
|
144
|
+
old_base = File.join(bases_dir, old_key_str)
|
|
145
|
+
FileUtils.rm_f(old_base) if File.exist?(old_base)
|
|
146
|
+
merged_entries.delete(old_key_str)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Hooks provenance: persist metadata and base snapshots for hooks
|
|
152
|
+
hooks_map = {}
|
|
153
|
+
# existing hooks for pruning
|
|
154
|
+
existing_hooks = existing && existing[:hooks] ? existing[:hooks] : {}
|
|
155
|
+
# Normalize incoming hooks (basename => metadata hash)
|
|
156
|
+
normalized_hooks = {}
|
|
157
|
+
if hooks.is_a?(Hash)
|
|
158
|
+
hooks.each do |k, v|
|
|
159
|
+
next unless k.is_a?(String) && !k.empty?
|
|
160
|
+
next unless v.is_a?(Hash)
|
|
161
|
+
hv = v.transform_keys(&:to_sym)
|
|
162
|
+
sha = (hv[:sha256] || "").to_s
|
|
163
|
+
sha = sha.start_with?("sha256:") ? sha : "sha256:#{sha}" unless sha.empty?
|
|
164
|
+
normalized_hooks[k] = {
|
|
165
|
+
sha256: sha,
|
|
166
|
+
event: (hv[:event] || "").to_s,
|
|
167
|
+
on_error: (hv[:on_error] || "skip").to_s,
|
|
168
|
+
priority: (hv[:priority] || 100).to_i
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
# Prune stale hook bases
|
|
173
|
+
if existing_hooks.is_a?(Hash)
|
|
174
|
+
existing_hooks.keys.each do |old_key|
|
|
175
|
+
old_key_str = old_key.to_s
|
|
176
|
+
unless normalized_hooks.key?(old_key_str) || normalized_hooks.key?(old_key_str.to_sym)
|
|
177
|
+
old_base = File.join(bases_dir, old_key_str)
|
|
178
|
+
FileUtils.rm_f(old_base) if File.exist?(old_base)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
# Save base snapshots for hooks where a file path was provided, and
|
|
183
|
+
# record the sha256 of the installed file itself: the load-time check
|
|
184
|
+
# compares against it, and a declared sha can be wrong (installing
|
|
185
|
+
# only warns about that).
|
|
186
|
+
hooks_files = {} unless hooks_files.is_a?(Hash)
|
|
187
|
+
normalized_hooks.each do |k, meta|
|
|
188
|
+
src = hooks_files[k] || hooks_files[k.to_sym]
|
|
189
|
+
if src && File.exist?(src.to_s)
|
|
190
|
+
meta = meta.merge(sha256: "sha256:#{Digest::SHA256.hexdigest(File.read(src.to_s))}")
|
|
191
|
+
end
|
|
192
|
+
hooks_map[k] = meta.transform_keys(&:to_s)
|
|
193
|
+
if src && File.exist?(src.to_s)
|
|
194
|
+
begin
|
|
195
|
+
File.write(File.join(bases_dir, k), File.read(src.to_s))
|
|
196
|
+
rescue StandardError
|
|
197
|
+
nil
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
manifest_data = {
|
|
203
|
+
"name" => @name,
|
|
204
|
+
"version" => version.to_s,
|
|
205
|
+
"scope" => scope.to_s,
|
|
206
|
+
"source" => source_path.to_s,
|
|
207
|
+
"installed_at" => DateTime.now.iso8601,
|
|
208
|
+
"files" => merged_entries,
|
|
209
|
+
"hooks" => hooks_map
|
|
210
|
+
}
|
|
211
|
+
manifest_data["trust_level"] = trust_level.to_s if trust_level && !trust_level.to_s.empty?
|
|
212
|
+
if guardrails_files.is_a?(Hash) && !guardrails_files.empty?
|
|
213
|
+
manifest_data["guardrails"] = guardrails_files.sort.to_h do |basename, path|
|
|
214
|
+
[basename.to_s, { "sha256" => "sha256:#{Digest::SHA256.hexdigest(File.read(path.to_s))}" }]
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
manifest_data["source_commit"] = source_commit.to_s if source_commit && !source_commit.to_s.empty?
|
|
218
|
+
FileUtils.rm_rf(File.join(bases_dir, "plugin"))
|
|
219
|
+
if plugin_file
|
|
220
|
+
content = File.binread(plugin_file.to_s)
|
|
221
|
+
manifest_data["plugin"] = { "file" => File.basename(plugin_file.to_s),
|
|
222
|
+
"sha256" => "sha256:#{Digest::SHA256.hexdigest(content)}" }
|
|
223
|
+
FileUtils.mkdir_p(File.join(bases_dir, "plugin"))
|
|
224
|
+
File.binwrite(plugin_base_path(File.basename(plugin_file.to_s)), content)
|
|
225
|
+
end
|
|
226
|
+
manifest_data["requires_chi"] = requires_chi.to_s if requires_chi && !requires_chi.to_s.empty?
|
|
227
|
+
manifest_data["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } if needs.is_a?(Array) && !needs.empty?
|
|
228
|
+
|
|
229
|
+
# Write aside and rename, so a reader in another process (a parallel
|
|
230
|
+
# chi start) never parses a truncated manifest.json.
|
|
231
|
+
manifest_path = File.join(@bundle_dir, "manifest.json")
|
|
232
|
+
tmp_path = "#{manifest_path}.#{Process.pid}.tmp"
|
|
233
|
+
File.write(tmp_path, JSON.pretty_generate(manifest_data))
|
|
234
|
+
File.rename(tmp_path, manifest_path)
|
|
235
|
+
ensure
|
|
236
|
+
FileUtils.rm_f(tmp_path) if tmp_path
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Reads provenance data (returns nil if not installed).
|
|
240
|
+
def read
|
|
241
|
+
manifest_path = File.join(@bundle_dir, "manifest.json")
|
|
242
|
+
return nil unless File.exist?(manifest_path)
|
|
243
|
+
JSON.parse(File.read(manifest_path), symbolize_names: true)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Returns the path to a base snapshot for a given file key.
|
|
247
|
+
def base_path(file_key)
|
|
248
|
+
File.join(@bundle_dir, "bases", file_key)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Checks whether the bundle is installed.
|
|
252
|
+
def installed?
|
|
253
|
+
read.nil? ? false : true
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|