samagotchi 0.157.0 → 0.159.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 +47 -1
- data/README.md +1 -1
- data/bin/chi +5 -1
- data/docs/architecture.md +14 -12
- data/docs/bundle-authoring.md +5 -5
- data/docs/bundles.md +2 -1
- data/docs/cli.md +76 -17
- data/docs/configuration.md +16 -5
- data/docs/context.md +12 -4
- data/docs/guardrails.md +11 -8
- data/docs/hooks.md +47 -80
- data/docs/internals/bridge.md +2 -1
- data/docs/internals/bundles.md +51 -36
- data/docs/internals/engine.md +7 -5
- data/docs/memory.md +43 -9
- data/docs/plugins.md +13 -9
- data/docs/releasing.md +6 -5
- data/docs/scheduling.md +228 -0
- data/docs/sessions.md +30 -6
- data/docs/testing.md +4 -4
- data/lib/samagotchi/bridge/card_places.rb +5 -13
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
- data/lib/samagotchi/bridge/turn_drawings.rb +7 -1
- data/lib/samagotchi/bridge.rb +26 -2
- data/lib/samagotchi/bundle_command/check.rb +23 -10
- data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
- data/lib/samagotchi/bundle_command/try/run.rb +4 -1
- data/lib/samagotchi/bundle_command.rb +53 -44
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +6 -6
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/client_id.rb +13 -3
- data/lib/samagotchi/config.rb +12 -0
- data/lib/samagotchi/context_command.rb +7 -5
- data/lib/samagotchi/context_move.rb +74 -0
- data/lib/samagotchi/context_sources.rb +4 -2
- data/lib/samagotchi/engine.rb +1 -6
- data/lib/samagotchi/extension_load.rb +20 -41
- data/lib/samagotchi/guardrails/load_failures.rb +3 -2
- data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
- data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
- data/lib/samagotchi/guardrails/review_paths.rb +1 -5
- data/lib/samagotchi/hook_routes.rb +2 -2
- data/lib/samagotchi/hooks.rb +2 -3
- data/lib/samagotchi/memory_bundle/asset_installer.rb +112 -211
- data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
- data/lib/samagotchi/memory_bundle/builder.rb +10 -34
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
- data/lib/samagotchi/memory_bundle/file_actions.rb +51 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installed_copy.rb +11 -18
- data/lib/samagotchi/memory_bundle/installed_manifest.rb +2 -16
- data/lib/samagotchi/memory_bundle/installer.rb +32 -14
- data/lib/samagotchi/memory_bundle/manifest.rb +37 -54
- data/lib/samagotchi/memory_bundle/provenance.rb +11 -51
- data/lib/samagotchi/memory_bundle/remerge.rb +7 -3
- data/lib/samagotchi/memory_bundle/shas.rb +5 -8
- data/lib/samagotchi/memory_bundle/shipped_update.rb +41 -41
- data/lib/samagotchi/memory_bundle/status.rb +30 -9
- data/lib/samagotchi/memory_bundle/system_bundle.rb +16 -5
- data/lib/samagotchi/memory_bundle/trash.rb +7 -12
- data/lib/samagotchi/memory_bundle/uninstaller.rb +10 -4
- data/lib/samagotchi/memory_orphans.rb +4 -4
- data/lib/samagotchi/memory_paths.rb +102 -13
- data/lib/samagotchi/plugin/api.rb +1 -4
- data/lib/samagotchi/plugin/loader.rb +42 -5
- data/lib/samagotchi/project_command.rb +167 -0
- data/lib/samagotchi/project_init.rb +69 -0
- data/lib/samagotchi/project_scope.rb +6 -4
- data/lib/samagotchi/prompt_files.rb +10 -7
- data/lib/samagotchi/recap_place.rb +3 -4
- data/lib/samagotchi/run_options.rb +2 -0
- data/lib/samagotchi/schedule_command.rb +252 -0
- data/lib/samagotchi/schedules.rb +948 -0
- data/lib/samagotchi/session.rb +44 -18
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +20 -0
- data/lib/samagotchi/session_field.rb +5 -4
- data/lib/samagotchi/session_manager/spawn.rb +25 -11
- data/lib/samagotchi/session_manager/worker_command.rb +13 -13
- data/lib/samagotchi/steer.rb +10 -0
- data/lib/samagotchi/system_prompt.rb +20 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
- data/lib/samagotchi/terminal_ui/join_replay.rb +6 -1
- data/lib/samagotchi/testing/bundle.rb +5 -12
- data/lib/samagotchi/testing/install.rb +5 -5
- data/lib/samagotchi/testing/recorder.rb +1 -1
- data/lib/samagotchi/testing.rb +1 -1
- data/lib/samagotchi/tool_declarations.rb +44 -1
- data/lib/samagotchi/toolkit_binding.rb +1 -4
- data/lib/samagotchi/toolkit_command.rb +1 -4
- data/lib/samagotchi/tools/builtin_calls.rb +2 -0
- data/lib/samagotchi/tools/builtins.rb +17 -0
- data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
- data/lib/samagotchi/tools/delegate.rb +12 -8
- data/lib/samagotchi/tools/list_schedules.rb +43 -0
- data/lib/samagotchi/tools/schedule_task.rb +39 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +35 -0
- data/lib/samagotchi/web/project_list.rb +3 -2
- data/lib/samagotchi/web/public/chains.js +32 -8
- data/lib/samagotchi/web/public/index.html +10 -2
- data/lib/samagotchi/web/public/info_bar.js +4 -3
- data/lib/samagotchi/web/public/project_picker.js +86 -4
- data/lib/samagotchi/web/public/turn_events.js +4 -0
- data/lib/samagotchi/web/session_hub.rb +20 -0
- data/lib/samagotchi/worker.rb +24 -6
- data/lib/samagotchi/worker_boot.rb +3 -4
- data/lib/samagotchi/worker_inbound.rb +23 -0
- data/lib/samagotchi/worker_link.rb +5 -0
- data/lib/samagotchi/worker_wakes.rb +9 -1
- metadata +10 -2
- data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
|
@@ -214,7 +214,11 @@ module Samagotchi
|
|
|
214
214
|
missing = MemoryFiles.select(GEM_BUNDLE_DIR, gem_manifest).reject { |key| File.exist?(File.join(target_dir, key)) }
|
|
215
215
|
missing = restored_rows(missing, target_dir) unless missing.empty?
|
|
216
216
|
return result(:up_to_date, from: version, to: version) if missing.empty?
|
|
217
|
-
|
|
217
|
+
|
|
218
|
+
if dry_run
|
|
219
|
+
warnings = missing.filter_map { |key| restore_mismatch(key, gem_manifest) }
|
|
220
|
+
return result(:restored, from: version, to: version, warnings: warnings)
|
|
221
|
+
end
|
|
218
222
|
|
|
219
223
|
warnings = missing.flat_map { |key| restore_file(key, gem_manifest) }
|
|
220
224
|
result(:restored, from: version, to: version, warnings: warnings)
|
|
@@ -238,7 +242,7 @@ module Samagotchi
|
|
|
238
242
|
description = gem_manifest.file_descriptions[key]
|
|
239
243
|
FileUtils.cp(source, target)
|
|
240
244
|
Provenance.new(name: BUNDLE_NAME).record_restored(key, source, description: description)
|
|
241
|
-
warnings = [
|
|
245
|
+
warnings = [restore_mismatch(key, gem_manifest)].compact
|
|
242
246
|
begin
|
|
243
247
|
IndexUpdater.update_index(SCOPE, key.delete_suffix(".md"), File.size(target), description, source: BUNDLE_NAME)
|
|
244
248
|
rescue StandardError => e
|
|
@@ -247,6 +251,10 @@ module Samagotchi
|
|
|
247
251
|
warnings
|
|
248
252
|
end
|
|
249
253
|
|
|
254
|
+
def restore_mismatch(key, gem_manifest)
|
|
255
|
+
FileDigest.mismatch(key, File.join(GEM_BUNDLE_DIR, key), gem_manifest.checksum_for(key))
|
|
256
|
+
end
|
|
257
|
+
|
|
250
258
|
# An up-to-date bundle's marked files, retried. No marks: nothing is read.
|
|
251
259
|
def remerge(bundle, verified, dry_run)
|
|
252
260
|
done = Remerge.new(name: BUNDLE_NAME, scope: SCOPE, target_dir: MemoryPaths.system_dir, source_dir: GEM_BUNDLE_DIR, record: bundle)
|
|
@@ -264,7 +272,10 @@ module Samagotchi
|
|
|
264
272
|
owned = provenance.record&.files&.keys || []
|
|
265
273
|
forecast = Merger::Upgrade.new(provenance: provenance, target_dir: MemoryPaths.system_dir, source_dir: GEM_BUNDLE_DIR)
|
|
266
274
|
.forecast(gem_manifest.files.keys.map(&:to_s).sort & owned, version: to)
|
|
267
|
-
|
|
275
|
+
installer = upgrade_installer(dry_run: true)
|
|
276
|
+
installer.run
|
|
277
|
+
warnings = installer.warnings.grep(/\AChecksum mismatch/)
|
|
278
|
+
result(:updated, from: from, to: to, merged: forecast.merged, behind: forecast.behind, warnings: warnings)
|
|
268
279
|
end
|
|
269
280
|
|
|
270
281
|
# Provenance#adopt_manifest from the shipped dir, for a record without a
|
|
@@ -276,9 +287,9 @@ module Samagotchi
|
|
|
276
287
|
Provenance.new(name: BUNDLE_NAME).adopt_manifest(GEM_BUNDLE_DIR)
|
|
277
288
|
end
|
|
278
289
|
|
|
279
|
-
def upgrade_installer
|
|
290
|
+
def upgrade_installer(dry_run: false)
|
|
280
291
|
Installer.new(source: GEM_BUNDLE_DIR, name: BUNDLE_NAME, scope: SCOPE, force: false, strict: true, upgrade: true,
|
|
281
|
-
shipped_dir: File.dirname(GEM_BUNDLE_DIR))
|
|
292
|
+
dry_run: dry_run, shipped_dir: File.dirname(GEM_BUNDLE_DIR))
|
|
282
293
|
end
|
|
283
294
|
private_class_method :with_lock, :note_newer_installed, :install_fresh, :keep_manifest, :upgrade_existing, :verify_files_present, :restore_file, :remerge,
|
|
284
295
|
:upgrade_installer, :result, :planned_update
|
|
@@ -99,24 +99,19 @@ module Samagotchi
|
|
|
99
99
|
dest
|
|
100
100
|
end
|
|
101
101
|
|
|
102
|
-
# Copies
|
|
103
|
-
# that would lose something: its bytes are neither what
|
|
104
|
-
# was installed (no sha recorded: unknown, so it counts
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
-
copy(path, as: as) if self.class.edit?(path, recorded: recorded, incoming: incoming)
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
# copy_edit for every file under +dir+, which is about to go, at any depth
|
|
112
|
-
# (kept as <label>/<its path in dir>). A linked dir isn't followed.
|
|
102
|
+
# Copies every file under +dir+, which is about to be replaced or
|
|
103
|
+
# removed, when that would lose something: its bytes are neither what
|
|
104
|
+
# the record says was installed (no sha recorded: unknown, so it counts
|
|
105
|
+
# as edited) nor the incoming source's, at any depth (kept as
|
|
106
|
+
# <label>/<its path in dir>). A link (a dev install's) points at its
|
|
107
|
+
# source, which nothing here touches; a linked dir isn't followed.
|
|
113
108
|
# @param recorded [Hash, nil] path in dir => its recorded sha256
|
|
114
109
|
# @param incoming [Hash] path in dir => the source replacing it
|
|
115
110
|
def copy_edits(dir, label, recorded = nil, incoming = {})
|
|
116
111
|
self.class.edited_in(dir, recorded, incoming).each { |file| copy(File.join(dir, file), as: "#{label}/#{file}") }
|
|
117
112
|
end
|
|
118
113
|
|
|
119
|
-
# Whether #
|
|
114
|
+
# Whether #copy_edits would copy the file at +path+ (read-only): the rule
|
|
120
115
|
# ShippedUpdate's plan names its replaced files by.
|
|
121
116
|
def self.edit?(path, recorded: nil, incoming: nil)
|
|
122
117
|
return false if File.symlink?(path) || !File.file?(path)
|
|
@@ -4,6 +4,7 @@ require "fileutils"
|
|
|
4
4
|
require "digest"
|
|
5
5
|
require_relative "../memory_paths"
|
|
6
6
|
require_relative "provenance"
|
|
7
|
+
require_relative "asset_state"
|
|
7
8
|
require_relative "file_actions"
|
|
8
9
|
require_relative "index_updater"
|
|
9
10
|
require_relative "trash"
|
|
@@ -144,12 +145,17 @@ module Samagotchi
|
|
|
144
145
|
private
|
|
145
146
|
|
|
146
147
|
# Copies each edited file of the bundle's own folder to the trash: the
|
|
147
|
-
# rm_rf below takes them, --force or not.
|
|
148
|
+
# rm_rf below takes them, --force or not. Rules, the plugin and
|
|
149
|
+
# scripts by their source-less AssetState and ASSET_UNINSTALL's
|
|
150
|
+
# save_first; an older chi's hooks/ by Trash#copy_edits.
|
|
148
151
|
def save_edits(provenance, bundle)
|
|
149
152
|
@trash.copy_edits(provenance.hooks_dir, "hooks", bundle.hooks.transform_values(&:sha256))
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
+
{ rule: [provenance.guardrails_dir, bundle.guardrails], plugin: [provenance.plugin_dir, bundle.plugin_shas],
|
|
154
|
+
script: [provenance.scripts_dir, bundle.scripts] }.each do |kind_of_file, (dir, recorded)|
|
|
155
|
+
AssetState.in(dir, kind_of_file: kind_of_file, recorded: recorded).each do |asset|
|
|
156
|
+
@trash.copy(asset.path, as: asset.label) if FileActions.for_asset(:uninstall, asset)&.save_first
|
|
157
|
+
end
|
|
158
|
+
end
|
|
153
159
|
end
|
|
154
160
|
|
|
155
161
|
# A kept shared file's index line names the bundle that still has it
|
|
@@ -19,14 +19,14 @@ module Samagotchi
|
|
|
19
19
|
|
|
20
20
|
module_function
|
|
21
21
|
|
|
22
|
-
# The orphan candidates for +cwd+'s project, or nil: only inside a
|
|
23
|
-
#
|
|
24
|
-
# match), and only while the computed folder holds no *.md (a
|
|
22
|
+
# The orphan candidates for +cwd+'s project, or nil: only inside a
|
|
23
|
+
# project, a git repository or a marked folder (outside both the root is
|
|
24
|
+
# cwd, and every src/ or tmp/ would match), and only while the computed folder holds no *.md (a
|
|
25
25
|
# memory_write's mkdir_p or a project-scope bundle install alone must
|
|
26
26
|
# not silence it).
|
|
27
27
|
# @return [Found, nil]
|
|
28
28
|
def find(env: ENV, cwd: Dir.pwd)
|
|
29
|
-
return nil unless MemoryPaths.
|
|
29
|
+
return nil unless MemoryPaths.in_project?(cwd)
|
|
30
30
|
|
|
31
31
|
projects = MemoryPaths.projects_dir(env: env)
|
|
32
32
|
computed = MemoryPaths.project_dir(env: env, cwd: cwd)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require_relative "config"
|
|
5
|
+
require_relative "guardrails/path_resolve"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
# Where memories live: <config dir>/memories, i.e. $XDG_CONFIG_HOME/samagotchi/memories
|
|
@@ -9,11 +10,61 @@ module Samagotchi
|
|
|
9
10
|
# ConfigFile.global_path, so an XDG_CONFIG_HOME set after load (specs, smoke runs,
|
|
10
11
|
# a fixture chi) moves memories together with config.yml.
|
|
11
12
|
#
|
|
12
|
-
# Project memories are keyed by the project root (see
|
|
13
|
-
#
|
|
13
|
+
# Project memories are keyed by the project root (see #lookup): one folder
|
|
14
|
+
# per project root, a git repository (every worktree and subdirectory of it
|
|
15
|
+
# shares one folder) or a folder marked by `chi project init` (and its
|
|
16
|
+
# subfolders).
|
|
14
17
|
module MemoryPaths
|
|
18
|
+
# The file that makes a folder in no git repository a chi project
|
|
19
|
+
# (`chi project init`). The file, not the bare .chi/ dir, is the marker.
|
|
20
|
+
MARKER = File.join(".chi", "project.yml").freeze
|
|
21
|
+
|
|
22
|
+
# How a folder's project was found (#lookup): +kind+ is :git, :marker or
|
|
23
|
+
# :none; +root+ is what memories are keyed on (#project_root: git's
|
|
24
|
+
# common-dir checkout, the marked folder's realpath, or the folder
|
|
25
|
+
# itself); +top+ is the folder a session reads its AGENTS.md chain from
|
|
26
|
+
# (the work tree's top, the marked root; nil for :none).
|
|
27
|
+
ProjectLookup = Data.define(:kind, :root, :top) do
|
|
28
|
+
def project? = kind != :none
|
|
29
|
+
def git? = kind == :git
|
|
30
|
+
def marker? = kind == :marker
|
|
31
|
+
end
|
|
32
|
+
|
|
15
33
|
module_function
|
|
16
34
|
|
|
35
|
+
# The project +cwd+ belongs to, looked up once: a .git above it wins
|
|
36
|
+
# (git is the identity wherever it exists); only when there is none, the
|
|
37
|
+
# nearest MARKER above it, ignoring one in $HOME or any folder above it
|
|
38
|
+
# (and /), so a marker in ~ never makes every folder under home one
|
|
39
|
+
# project. The marker walk starts from +cwd+'s real path and the marked
|
|
40
|
+
# root is a realpath, as git's root is, so every spelling of a folder
|
|
41
|
+
# (/tmp, /var on macOS, a symlink into or out of a marked tree) gets the
|
|
42
|
+
# answer the worker's physical Dir.pwd gets.
|
|
43
|
+
# @param home [String, nil] $HOME by default; nil or blank ignores only /
|
|
44
|
+
# @return [ProjectLookup]
|
|
45
|
+
def lookup(cwd = Dir.pwd, home: default_home)
|
|
46
|
+
dot_git = find_dot_git(cwd)
|
|
47
|
+
return ProjectLookup.new(kind: :git, root: git_root(dot_git), top: File.dirname(dot_git)) if dot_git
|
|
48
|
+
|
|
49
|
+
marked = nearest_marker(cwd, home: home)
|
|
50
|
+
return ProjectLookup.new(kind: :marker, root: marked, top: marked) if marked
|
|
51
|
+
|
|
52
|
+
ProjectLookup.new(kind: :none, root: cwd, top: nil)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Whether a marker at +dir+ is ignored: +dir+ is / or holds $HOME (both
|
|
56
|
+
# resolved; Guardrails::PathResolve.home_root?).
|
|
57
|
+
def above_home?(dir, home: default_home)
|
|
58
|
+
Guardrails::PathResolve.real(dir) == "/" || Guardrails::PathResolve.home_root?(dir, home)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# $HOME, or nil when it can't be found (no HOME, no passwd entry).
|
|
62
|
+
def default_home
|
|
63
|
+
Dir.home
|
|
64
|
+
rescue ArgumentError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
17
68
|
def system_dir(env: ENV)
|
|
18
69
|
File.join(ConfigFile.config_dir(env: env), "memories")
|
|
19
70
|
end
|
|
@@ -28,16 +79,31 @@ module Samagotchi
|
|
|
28
79
|
# #project_key for a root already resolved (a session's project_root).
|
|
29
80
|
def key_for_root(root) = "#{File.basename(root)}_#{Digest::MD5.hexdigest(root)[0..7]}"
|
|
30
81
|
|
|
31
|
-
# The
|
|
32
|
-
# worktree shares it. Returns the
|
|
33
|
-
#
|
|
34
|
-
# --separate-git-dir, a submodule's
|
|
35
|
-
# the "project root" is a git
|
|
36
|
-
#
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
82
|
+
# The project root (#lookup): in a repository, its common git directory
|
|
83
|
+
# identifies the project; every linked worktree shares it. Returns the
|
|
84
|
+
# checkout holding it when it is named .git, otherwise the git dir itself
|
|
85
|
+
# (a bare repo, a proj/.bare layout, --separate-git-dir, a submodule's
|
|
86
|
+
# .git/modules/<name>), so for those layouts the "project root" is a git
|
|
87
|
+
# dir, not a checkout. Outside any repository, the marked folder holding
|
|
88
|
+
# +cwd+, else cwd. No git subprocess: at most two small files are read.
|
|
89
|
+
def project_root(cwd = Dir.pwd) = lookup(cwd).root
|
|
90
|
+
|
|
91
|
+
# Is +cwd+ in a project: a git repository or a marked folder?
|
|
92
|
+
def in_project?(cwd = Dir.pwd) = lookup(cwd).project?
|
|
93
|
+
|
|
94
|
+
# The marked folder (realpath) +cwd+'s project is, or nil (in a git
|
|
95
|
+
# repository, or in no project).
|
|
96
|
+
def marked_root(cwd = Dir.pwd)
|
|
97
|
+
found = lookup(cwd)
|
|
98
|
+
found.marker? ? found.root : nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The top of +cwd+'s project: the git work tree's top (#work_tree_root) or
|
|
102
|
+
# the marked root; nil in no project.
|
|
103
|
+
def project_top(cwd = Dir.pwd) = lookup(cwd).top
|
|
40
104
|
|
|
105
|
+
# The project root of the checkout whose .git is +dot_git+ (#project_root).
|
|
106
|
+
def git_root(dot_git)
|
|
41
107
|
holder = File.dirname(dot_git)
|
|
42
108
|
gitdir = File.directory?(dot_git) ? dot_git : gitdir_from_file(dot_git)
|
|
43
109
|
return holder unless gitdir
|
|
@@ -78,7 +144,8 @@ module Samagotchi
|
|
|
78
144
|
File.directory?(dot_git) ? dot_git : gitdir_from_file(dot_git)
|
|
79
145
|
end
|
|
80
146
|
|
|
81
|
-
# Is +cwd+ inside a git repository (a .git somewhere above it)?
|
|
147
|
+
# Is +cwd+ inside a git repository (a .git somewhere above it)? Git only:
|
|
148
|
+
# a marked folder is not a repository (#in_project?).
|
|
82
149
|
def in_repo?(cwd = Dir.pwd)
|
|
83
150
|
!find_dot_git(cwd).nil?
|
|
84
151
|
end
|
|
@@ -96,6 +163,28 @@ module Samagotchi
|
|
|
96
163
|
end
|
|
97
164
|
end
|
|
98
165
|
|
|
166
|
+
# The realpath of the nearest folder at or above +start+ holding MARKER,
|
|
167
|
+
# git or not (#lookup asks only outside a repository; `chi project` tells
|
|
168
|
+
# of one a repository makes ignored), or nil. A marker at or above +home+
|
|
169
|
+
# (#above_home?) ends the walk with nil: every folder above it is above
|
|
170
|
+
# home too.
|
|
171
|
+
def nearest_marker(start, home: default_home)
|
|
172
|
+
dir = Guardrails::PathResolve.real(start)
|
|
173
|
+
loop do
|
|
174
|
+
if File.file?(File.join(dir, MARKER))
|
|
175
|
+
return nil if above_home?(dir, home: home)
|
|
176
|
+
|
|
177
|
+
return File.realpath(dir)
|
|
178
|
+
end
|
|
179
|
+
parent = File.dirname(dir)
|
|
180
|
+
return nil if parent == dir
|
|
181
|
+
|
|
182
|
+
dir = parent
|
|
183
|
+
end
|
|
184
|
+
rescue SystemCallError
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
99
188
|
# A ".git" file holds "gitdir: <path>", relative to the file's directory.
|
|
100
189
|
def gitdir_from_file(path)
|
|
101
190
|
line = File.read(path, 4096).to_s[/\Agitdir:\s*(.+)$/, 1]&.strip
|
|
@@ -115,7 +204,7 @@ module Samagotchi
|
|
|
115
204
|
rescue SystemCallError
|
|
116
205
|
gitdir
|
|
117
206
|
end
|
|
118
|
-
private_class_method :find_dot_git, :gitdir_from_file, :common_dir
|
|
207
|
+
private_class_method :find_dot_git, :git_root, :gitdir_from_file, :common_dir
|
|
119
208
|
|
|
120
209
|
def project_dir(env: ENV, cwd: Dir.pwd)
|
|
121
210
|
File.join(projects_dir(env: env), project_key(cwd))
|
|
@@ -32,10 +32,7 @@ module Samagotchi
|
|
|
32
32
|
# plugin writes it (ApiCalls#keywords: `ctx.sessions.send(…,
|
|
33
33
|
# delivery:)` is seen, a `sessions = ctx.sessions` alias isn't).
|
|
34
34
|
# Events aren't listed: chi.on takes any symbol and an older chi never
|
|
35
|
-
# fires one it doesn't know (harmless; chi bundle check warns on it)
|
|
36
|
-
# and a manifest hook's events: list (BundleHook) needs the bundle's
|
|
37
|
-
# requires_chi set by hand (docs/hooks.md, Bundle Hooks): no source
|
|
38
|
-
# scan covers manifest.yml.
|
|
35
|
+
# fires one it doesn't know (harmless; chi bundle check warns on it).
|
|
39
36
|
SINCE = {
|
|
40
37
|
"ctx.steer" => "0.3.0", "ctx.stop_turn" => "0.3.0", "ctx.stop_generation" => "0.6.0",
|
|
41
38
|
"Samagotchi::AtomicFile" => "0.8.0", "ctx.model" => "0.17.0", "ctx.model_key" => "0.17.0",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
3
4
|
require_relative "api"
|
|
4
5
|
require_relative "context"
|
|
5
6
|
require_relative "../hooks"
|
|
@@ -10,6 +11,11 @@ require_relative "../memory_bundle/manifest"
|
|
|
10
11
|
require_relative "../memory_bundle/provenance"
|
|
11
12
|
|
|
12
13
|
module Samagotchi
|
|
14
|
+
# Where installed bundles' plugin code is evaluated: one module per
|
|
15
|
+
# bundle (Plugin::Loader.namespace_for), so two bundles defining the same
|
|
16
|
+
# class name can't collide on a top-level constant.
|
|
17
|
+
module Bundles; end
|
|
18
|
+
|
|
13
19
|
module Plugin
|
|
14
20
|
# What a plugin registers into: an Engine's own registries.
|
|
15
21
|
# +context_for+ is (bundle_name, settings, label) → the Plugin::Context
|
|
@@ -36,7 +42,7 @@ module Samagotchi
|
|
|
36
42
|
#
|
|
37
43
|
# For each bundle, by name: the file must match the sha256 recorded at
|
|
38
44
|
# install and chi must meet the bundle's requires_chi. The file is
|
|
39
|
-
# module_eval'd into the bundle's namespace (
|
|
45
|
+
# module_eval'd into the bundle's namespace (#namespace_for), the
|
|
40
46
|
# class named like the file (plugin.rb → Plugin) is built with the
|
|
41
47
|
# bundle's settings, and its #register gets a Plugin::Api. What it
|
|
42
48
|
# registered takes effect only when #register returns: a plugin that
|
|
@@ -140,14 +146,45 @@ module Samagotchi
|
|
|
140
146
|
return failure if failure
|
|
141
147
|
return "the file is missing" unless file && File.file?(file)
|
|
142
148
|
|
|
143
|
-
|
|
149
|
+
sha_mismatch(file, bundle.plugin.sha256)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Why +file+ doesn't match the sha256 recorded at install (+recorded+,
|
|
153
|
+
# "sha256:<hex>" or bare hex), or nil. None recorded is a mismatch;
|
|
154
|
+
# Provenance.unchecked? (a dev install) is none.
|
|
155
|
+
def sha_mismatch(file, recorded)
|
|
156
|
+
return nil if MemoryBundle::Provenance.unchecked?(recorded)
|
|
157
|
+
|
|
158
|
+
expected = MemoryBundle::Provenance.recorded_sha(recorded)
|
|
159
|
+
return "no sha256 recorded for it (reinstall the bundle)" if expected.empty?
|
|
160
|
+
|
|
161
|
+
actual = MemoryBundle::Provenance.file_sha(file)
|
|
162
|
+
return nil if actual == expected
|
|
163
|
+
|
|
164
|
+
"its sha256 #{actual[0, 12]}… differs from the installed #{expected[0, 12]}… (edited after install? reinstall the bundle)"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The (lazily created) namespace module for a bundle, under
|
|
168
|
+
# Samagotchi::Bundles. The name is sanitized and made injective by a
|
|
169
|
+
# short digest, so `my-bundle` and `my_bundle` don't collide.
|
|
170
|
+
def namespace_for(bundle_name)
|
|
171
|
+
raw = bundle_name.to_s
|
|
172
|
+
base = raw.gsub(/[^A-Za-z0-9_]/, "_")
|
|
173
|
+
base = "B_#{base}" unless base.match?(/\A[A-Z]/)
|
|
174
|
+
ns_name = :"#{base}_#{Digest::MD5.hexdigest(raw)[0, 4]}"
|
|
175
|
+
if Samagotchi::Bundles.const_defined?(ns_name, false)
|
|
176
|
+
Samagotchi::Bundles.const_get(ns_name)
|
|
177
|
+
else
|
|
178
|
+
Samagotchi::Bundles.const_set(ns_name, Module.new)
|
|
179
|
+
end
|
|
144
180
|
end
|
|
145
181
|
|
|
146
182
|
# module_eval the file into a fresh module in the bundle's namespace
|
|
147
183
|
# (Samagotchi::Bundles::<bundle>::PluginLoad<n>: each load gets new
|
|
148
184
|
# classes, not the last load's reopened) and build its class
|
|
149
|
-
# (plugin.rb → Plugin; my_plugin.rb → MyPlugin) as hooks are
|
|
150
|
-
# an initialize that takes an argument
|
|
185
|
+
# (plugin.rb → Plugin; my_plugin.rb → MyPlugin) as config hooks are
|
|
186
|
+
# built (Hooks.build_plugin): an initialize that takes an argument
|
|
187
|
+
# gets the settings.
|
|
151
188
|
def instantiate(bundle_name, file, settings, namespace: nil)
|
|
152
189
|
namespace ||= fresh_namespace(bundle_name)
|
|
153
190
|
namespace.module_eval(File.read(file), file, 1)
|
|
@@ -163,7 +200,7 @@ module Samagotchi
|
|
|
163
200
|
def fresh_namespace(bundle_name)
|
|
164
201
|
LOADS.synchronize do
|
|
165
202
|
@loads = (@loads || 0) + 1
|
|
166
|
-
|
|
203
|
+
namespace_for(bundle_name).const_set(:"PluginLoad#{@loads}", Module.new)
|
|
167
204
|
end
|
|
168
205
|
end
|
|
169
206
|
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cli/command"
|
|
4
|
+
require_relative "cli/exit"
|
|
5
|
+
require_relative "cli/flags"
|
|
6
|
+
require_relative "guardrails/path_resolve"
|
|
7
|
+
require_relative "memory_paths"
|
|
8
|
+
require_relative "paths"
|
|
9
|
+
require_relative "project_init"
|
|
10
|
+
|
|
11
|
+
module Samagotchi
|
|
12
|
+
# `chi project`: which project this folder is in (MemoryPaths.lookup: a
|
|
13
|
+
# git repository, a folder marked with `.chi/project.yml`, or none) and
|
|
14
|
+
# its project memories folder; `chi project init [DIR]` marks a folder in
|
|
15
|
+
# no git repository as a project. Git wins: a repository is already the
|
|
16
|
+
# project, so init writes nothing there. Machine-local
|
|
17
|
+
# (SessionAccess::Contract::MACHINE).
|
|
18
|
+
class ProjectCommand
|
|
19
|
+
include CLI::Command
|
|
20
|
+
|
|
21
|
+
USAGE = <<~TEXT
|
|
22
|
+
Usage: chi project [init [DIR]]
|
|
23
|
+
(none) this folder's project: how it was found (git, marker or none), its root, its
|
|
24
|
+
top and its project memories folder
|
|
25
|
+
init [DIR] make DIR (default: this folder; created when missing) a chi project: writes
|
|
26
|
+
DIR/#{MemoryPaths::MARKER}, so DIR and every subfolder share one project memories
|
|
27
|
+
folder and group their sessions. Not in a git repository (it already is the
|
|
28
|
+
project), nor $HOME or a folder above it
|
|
29
|
+
TEXT
|
|
30
|
+
|
|
31
|
+
SUBCOMMANDS = %w[init].freeze
|
|
32
|
+
FLAGS = {
|
|
33
|
+
nil => CLI::Flags.new(help: HELP_WORDS, args: false),
|
|
34
|
+
"init" => CLI::Flags.new(help: HELP_WORDS)
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
# @param cwd [String] the folder asked about (Dir.pwd)
|
|
38
|
+
# @param home [String, nil] $HOME (markers at or above it are ignored)
|
|
39
|
+
# @param sessions [#call, nil] () → this machine's SessionRows (for
|
|
40
|
+
# init's restart note; SessionManager.session_rows by default)
|
|
41
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr, cwd: Dir.pwd, env: ENV, home: MemoryPaths.default_home,
|
|
42
|
+
sessions: nil)
|
|
43
|
+
@argv = argv.dup
|
|
44
|
+
@stdout = stdout
|
|
45
|
+
@stderr = stderr
|
|
46
|
+
@cwd = cwd
|
|
47
|
+
@env = env
|
|
48
|
+
@home = home
|
|
49
|
+
@sessions = sessions || lambda {
|
|
50
|
+
require_relative "session_manager"
|
|
51
|
+
SessionManager.session_rows
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Integer] 0, 1 (a refusal), 2 usage
|
|
56
|
+
def run
|
|
57
|
+
@sub = SUBCOMMANDS.include?(@argv.first) ? @argv.first : nil
|
|
58
|
+
parsed = parse_flags(FLAGS.fetch(@sub), @sub ? @argv[1..] : @argv)
|
|
59
|
+
return parsed if parsed.is_a?(Integer)
|
|
60
|
+
|
|
61
|
+
return run_show unless @sub
|
|
62
|
+
return usage_error("init takes one folder") if parsed.args.size > 1
|
|
63
|
+
|
|
64
|
+
run_init(parsed.args.first || @cwd)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def command_name = @sub ? "chi project #{@sub}" : "chi project"
|
|
70
|
+
def usage_on_error = nil
|
|
71
|
+
|
|
72
|
+
def lookup(dir) = MemoryPaths.lookup(dir, home: @home)
|
|
73
|
+
|
|
74
|
+
def run_show
|
|
75
|
+
found = lookup(@cwd)
|
|
76
|
+
row("kind", found.kind.to_s)
|
|
77
|
+
if found.project?
|
|
78
|
+
row("root", found.root)
|
|
79
|
+
row("top", found.top)
|
|
80
|
+
end
|
|
81
|
+
row("memories", memories_line(found.root))
|
|
82
|
+
notes(found).each { |note| row("note", note) }
|
|
83
|
+
CLI::Exit::OK
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def row(label, value) = @stdout.puts("#{label.ljust(9)}#{value}")
|
|
87
|
+
|
|
88
|
+
def memories_line(root)
|
|
89
|
+
dir = File.join(MemoryPaths.projects_dir(env: @env), MemoryPaths.key_for_root(root))
|
|
90
|
+
count = memory_count(dir)
|
|
91
|
+
"#{Paths.tilde(dir)}#{" (#{count} #{count == 1 ? "memory" : "memories"})" if count.positive?}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def memory_count(dir)
|
|
95
|
+
return 0 unless File.directory?(dir)
|
|
96
|
+
|
|
97
|
+
Dir.children(dir).count { |name| name.end_with?(".md") && name != "index.md" }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def notes(found)
|
|
101
|
+
case found.kind
|
|
102
|
+
when :git
|
|
103
|
+
list = []
|
|
104
|
+
list << "#{Paths.tilde(found.top)} is $HOME, a git repository: every folder under it is in that repository" if home_repo?(found)
|
|
105
|
+
marker = MemoryPaths.nearest_marker(@cwd, home: @home)
|
|
106
|
+
list << "#{Paths.tilde(File.join(marker, MemoryPaths::MARKER))} is ignored: the git repository is the project" if marker
|
|
107
|
+
list
|
|
108
|
+
when :none then ["no git repository or chi project marker here: chi project init makes this folder a project"]
|
|
109
|
+
else []
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def home_repo?(found)
|
|
114
|
+
!@home.to_s.empty? && Guardrails::PathResolve.real(found.top) == Guardrails::PathResolve.real(@home)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def run_init(dir)
|
|
118
|
+
dir = File.expand_path(dir, @cwd)
|
|
119
|
+
outcome = ProjectInit.call(dir, home: @home)
|
|
120
|
+
case outcome.kind
|
|
121
|
+
when :git
|
|
122
|
+
found = MemoryPaths::ProjectLookup.new(kind: :git, root: outcome.root, top: outcome.top)
|
|
123
|
+
note = home_repo?(found) ? " ($HOME is a git repository: every folder under it is in it)" : ""
|
|
124
|
+
@stdout.puts("#{outcome.message}#{note}")
|
|
125
|
+
CLI::Exit::OK
|
|
126
|
+
when :refused
|
|
127
|
+
error_line("#{command_name}: #{outcome.message}")
|
|
128
|
+
CLI::Exit::FAILED
|
|
129
|
+
when :already_marked
|
|
130
|
+
@stdout.puts(outcome.message)
|
|
131
|
+
CLI::Exit::OK
|
|
132
|
+
when :created
|
|
133
|
+
report_init(outcome.root, nested_in: outcome.nested_in)
|
|
134
|
+
CLI::Exit::OK
|
|
135
|
+
end
|
|
136
|
+
rescue SystemCallError => e
|
|
137
|
+
error_line("#{command_name}: couldn't write #{Paths.tilde(File.join(dir, MemoryPaths::MARKER))}: #{e.message}")
|
|
138
|
+
CLI::Exit::FAILED
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def report_init(root, nested_in:)
|
|
142
|
+
nested = nested_in ? ", its own project nested in #{Paths.tilde(nested_in)}" : ""
|
|
143
|
+
@stdout.puts("#{Paths.tilde(root)} is now a chi project#{nested} (#{Paths.tilde(File.join(root, MemoryPaths::MARKER))})")
|
|
144
|
+
row("memories", memories_line(root))
|
|
145
|
+
sessions_note(root)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Sessions in the new project (not one under DIR that a nested
|
|
149
|
+
# repository or marked folder keeps): a running one keeps its old project in its
|
|
150
|
+
# prompt (the memories folder line, the AGENTS.md chain) until it
|
|
151
|
+
# restarts; a running chi web groups the older ones after its restart
|
|
152
|
+
# (SessionHub looked their project up once).
|
|
153
|
+
def sessions_note(root)
|
|
154
|
+
rows = @sessions.call.select { |r| !r.cwd.to_s.empty? && lookup(r.cwd.to_s).root == root }
|
|
155
|
+
return if rows.empty?
|
|
156
|
+
|
|
157
|
+
live = rows.select(&:live)
|
|
158
|
+
unless live.empty?
|
|
159
|
+
@stdout.puts("running sessions in it keep the old project in their prompt until restarted " \
|
|
160
|
+
"(chi sessions restart ID): #{live.map { |r| r.id[0, 8] }.join(", ")}")
|
|
161
|
+
end
|
|
162
|
+
@stdout.puts("#{rows.size} saved session(s) in it join the project in a running chi web after its restart")
|
|
163
|
+
rescue StandardError => e
|
|
164
|
+
error_line("#{command_name}: couldn't list this machine's sessions (#{e.message})")
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "guardrails/path_resolve"
|
|
5
|
+
require_relative "memory_paths"
|
|
6
|
+
require_relative "paths"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
# The shared decisions for `chi project init` and chi web's project picker.
|
|
10
|
+
module ProjectInit
|
|
11
|
+
Outcome = Data.define(:kind, :reason, :root, :top, :nested_in, :message)
|
|
12
|
+
|
|
13
|
+
MARKER_TEXT = "# chi project marker: this folder is a chi project (docs/memory.md)\n"
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def call(dir, home:)
|
|
18
|
+
dir = File.expand_path(dir.to_s)
|
|
19
|
+
found = MemoryPaths.lookup(dir, home: home)
|
|
20
|
+
if found.git?
|
|
21
|
+
message = "#{Paths.tilde(dir)} is in the git repository #{Paths.tilde(found.top)}: the repository already is " \
|
|
22
|
+
"the project, nothing written"
|
|
23
|
+
return Outcome.new(kind: :git, reason: nil, root: found.root, top: found.top, nested_in: nil, message: message)
|
|
24
|
+
end
|
|
25
|
+
if MemoryPaths.above_home?(dir, home: home)
|
|
26
|
+
message = "#{Paths.tilde(dir)} is $HOME, a folder above it or /: it can't be a chi project " \
|
|
27
|
+
"(every folder under it would be one); mark a folder inside it"
|
|
28
|
+
return Outcome.new(kind: :refused, reason: :above_home, root: found.root, top: found.top, nested_in: nil,
|
|
29
|
+
message: message)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The marked project this folder sits in, if any: the folder itself (already marked) or its parent project.
|
|
33
|
+
enclosing = found.marker? ? found.root : nil
|
|
34
|
+
if enclosing == Guardrails::PathResolve.real(dir)
|
|
35
|
+
return Outcome.new(kind: :already_marked, reason: nil, root: enclosing, top: found.top, nested_in: nil,
|
|
36
|
+
message: "#{Paths.tilde(enclosing)} is already a chi project")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
write_marker(dir)
|
|
40
|
+
root = File.realpath(dir)
|
|
41
|
+
# No message: each surface words its own success (the CLI's report, the web's navigation).
|
|
42
|
+
Outcome.new(kind: :created, reason: nil, root: root, top: root, nested_in: enclosing, message: nil)
|
|
43
|
+
rescue SystemCallError => e
|
|
44
|
+
marker = File.join(dir, MemoryPaths::MARKER)
|
|
45
|
+
write_failed("couldn't write #{Paths.tilde(marker)}: #{e.message}")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# chi web opens a folder in a git repository without marking it, so the folder has to exist.
|
|
49
|
+
# @return [Outcome, nil] a refused (:write_failed) outcome when it can't be made, else nil
|
|
50
|
+
def ensure_folder(dir)
|
|
51
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
|
52
|
+
nil
|
|
53
|
+
rescue SystemCallError => e
|
|
54
|
+
write_failed("couldn't create #{Paths.tilde(dir)}: #{e.message}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def write_failed(message)
|
|
58
|
+
Outcome.new(kind: :refused, reason: :write_failed, root: nil, top: nil, nested_in: nil, message: message)
|
|
59
|
+
end
|
|
60
|
+
private_class_method :write_failed
|
|
61
|
+
|
|
62
|
+
def write_marker(dir)
|
|
63
|
+
marker = File.join(dir, MemoryPaths::MARKER)
|
|
64
|
+
FileUtils.mkdir_p(File.dirname(marker))
|
|
65
|
+
File.write(marker, MARKER_TEXT)
|
|
66
|
+
end
|
|
67
|
+
private_class_method :write_marker
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -4,9 +4,10 @@ require_relative "memory_paths"
|
|
|
4
4
|
|
|
5
5
|
module Samagotchi
|
|
6
6
|
# Which project a folder belongs to, for scoping session lists (web and
|
|
7
|
-
# terminal alike): the project root memories use (MemoryPaths.
|
|
8
|
-
#
|
|
9
|
-
# folder
|
|
7
|
+
# terminal alike): the project root memories use (MemoryPaths.lookup), so
|
|
8
|
+
# every worktree and subfolder of one repository, or every subfolder of a
|
|
9
|
+
# folder marked by `chi project init`, is one project. A folder in neither
|
|
10
|
+
# has no project (nil): its scope is "all".
|
|
10
11
|
module ProjectScope
|
|
11
12
|
module_function
|
|
12
13
|
|
|
@@ -17,7 +18,8 @@ module Samagotchi
|
|
|
17
18
|
return nil if dir.empty?
|
|
18
19
|
return cache[dir] if cache&.key?(dir)
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
found = MemoryPaths.lookup(File.expand_path(dir))
|
|
22
|
+
root = found.project? ? found.root : nil
|
|
21
23
|
cache[dir] = root if cache
|
|
22
24
|
root
|
|
23
25
|
end
|