samagotchi 0.88.0 → 0.89.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.
@@ -10,7 +10,7 @@ module Samagotchi
10
10
  # subcommands have their own flags).
11
11
  class BundleCommand
12
12
  USAGE = <<~TEXT
13
- Usage: chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check> [options]
13
+ Usage: chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check|try> [options]
14
14
 
15
15
  install <source> [--scope system|project] [--force]
16
16
  Install a memory bundle from a directory, zip, tar archive, or git URL.
@@ -52,17 +52,26 @@ module Samagotchi
52
52
  check [PATH] [--fix] [--set KEY=VALUE]
53
53
  Check a bundle you're writing (default: the current dir): manifest, files, sha256 lines,
54
54
  requires_chi, and an install + load in a sandbox. --fix rewrites stale sha256 lines.
55
+
56
+ try PATH -p TEXT [options]
57
+ Run one non-interactive model turn with a bundle installed in an isolated sandbox.
55
58
  TEXT
56
59
 
57
60
  # Each subcommand's --help (and only --help: -h and help are taken as
58
61
  # the source or name).
59
62
  INSTALL_HELP = <<~TEXT
60
63
  Usage: chi bundle install <source> [--scope system|project] [--force]
64
+ chi bundle install --dev PATH
61
65
 
62
66
  install <source> [--scope system|project] [--force]
63
67
  Install a memory bundle from a directory, zip, tar archive, or git URL,
64
68
  or a bundle shipped with chi by name (see: chi bundle list).
65
69
  --force overwrites existing entries; default skips them.
70
+ install --dev PATH
71
+ Install the bundle in the local directory PATH as a link to it: its plugin, hooks, rules and scripts
72
+ are PATH's own files (an edit applies at the next worker or chi web restart, with no re-install) and
73
+ their sha256 isn't checked, for this bundle only. Its memories are copied; re-install with --force to
74
+ pick up memory edits. Upgrade refuses it; uninstall removes the link, never PATH's files.
66
75
  TEXT
67
76
  UPGRADE_HELP = "Usage: chi bundle upgrade <source> [--scope system|project] [--force] [--dry-run] [--agent|--no-agent]"
68
77
  UNINSTALL_HELP = "Usage: chi bundle uninstall <bundle> [--scope system|project] [--force]"
@@ -108,6 +117,19 @@ module Samagotchi
108
117
  --set KEY=VALUE a setting for the load (config.yml bundles: <name>: KEY), repeatable
109
118
  Exit status: 0 no errors, 1 errors, 2 usage.
110
119
  TEXT
120
+ TRY_HELP = <<~TEXT
121
+ Usage: chi bundle try PATH -p TEXT [--model NAME] [--memory NAME] [--mute NAME] [--toolkit NAME]
122
+ [--thinking LEVEL] [--set KEY=VALUE] [--json] [--keep] [--timeout SECONDS]
123
+
124
+ Install PATH and run one non-interactive turn in an isolated temporary config, state and work directory.
125
+ The user's hosts, keys and ordinary runtime settings are retained; their bundles, hooks, guardrails,
126
+ memories, prompt additions, sessions and attached context sources are not. Shipped guardrails and a
127
+ $1 cost cap are installed for the run. --set KEY=VALUE sets the tried bundle's config.yml bundles entry;
128
+ --set cost-cap.cap=N overrides the cap (unlike chi --set BUNDLE.KEY=VALUE, these are config settings).
129
+ --memory and --mute accept comma-separated names and repeat. --keep retains the sandbox and debug log.
130
+ --json prints the experimental machine-readable trace.
131
+ Exit status: child turn's status; 1 on install failure or timeout; 2 for usage; 130 on Ctrl-C.
132
+ TEXT
111
133
 
112
134
  # The subcommands' flags: --help only; a word not starting with -- is
113
135
  # a positional (install's source, -h included). --scope takes whatever
@@ -115,8 +137,12 @@ module Samagotchi
115
137
  INSTALL_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
116
138
  f.value "--scope"
117
139
  f.switch "--force"
140
+ f.switch "--dev"
141
+ end
142
+ UNINSTALL_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
143
+ f.value "--scope"
144
+ f.switch "--force"
118
145
  end
119
- UNINSTALL_FLAGS = INSTALL_FLAGS
120
146
  UPGRADE_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
121
147
  f.value "--scope"
122
148
  f.switch "--force"
@@ -135,6 +161,19 @@ module Samagotchi
135
161
  f.switch "--fix"
136
162
  f.value "--set", key: :set, repeat: true
137
163
  end
164
+ TRY_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A-/) do |f|
165
+ f.value "-p", "--prompt", key: :prompt
166
+ f.value "--model"
167
+ f.value "--memory", repeat: true
168
+ f.value "--mute", repeat: true
169
+ f.value "--toolkit"
170
+ f.value "--thinking"
171
+ f.value "--set", repeat: true
172
+ f.switch "--json"
173
+ f.switch "--keep"
174
+ f.value "--timeout"
175
+ f.refuse "--attach", "--shared", "chi bundle try can't use --attach or --shared"
176
+ end
138
177
  TRASH_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
139
178
  f.switch "--empty"
140
179
  f.switch "--dry-run"
@@ -169,8 +208,9 @@ module Samagotchi
169
208
  when "new" then new_bundle(@argv[1..])
170
209
  when "test" then test_bundle(@argv[1..])
171
210
  when "check" then check(@argv[1..])
211
+ when "try" then try_bundle(@argv[1..])
172
212
  else
173
- @stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build, trash, new, test, check"
213
+ @stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build, trash, new, test, check, try"
174
214
  CLI::Exit::USAGE
175
215
  end
176
216
  end
@@ -185,11 +225,21 @@ module Samagotchi
185
225
  source = parsed.args.last
186
226
  scope = opts[:scope]
187
227
  force = opts.fetch(:force, false)
228
+ dev = opts.fetch(:dev, false)
188
229
  if source.nil? || source.empty?
189
230
  @stderr.puts "Usage: chi bundle install <source> [--scope system|project] [--force]"
190
231
  return CLI::Exit::USAGE
191
232
  end
192
- expanded_source = expand_source(source)
233
+ if dev && (Samagotchi::MemoryBundle::SourceNormalizer.git_url?(source) || !File.directory?(source))
234
+ @stderr.puts "chi bundle install --dev takes a local directory (the bundle's source), not #{source}"
235
+ return CLI::Exit::USAGE
236
+ end
237
+ expanded_source = dev ? File.realpath(source) : expand_source(source)
238
+ if dev && File.dirname(expanded_source) == File.realpath(Samagotchi::MemoryBundle::SourceNormalizer::SHIPPED_DIR)
239
+ @stderr.puts "chi bundle install --dev cannot be used with chi-shipped bundles or profiles; " \
240
+ "use chi bundle install #{File.basename(expanded_source)} instead"
241
+ return CLI::Exit::USAGE
242
+ end
193
243
  if Samagotchi::MemoryBundle::Profile.shipped_meta?(expanded_source)
194
244
  return install_profile(expanded_source, scope: scope, force: force, dry_run: false, word: "Install")
195
245
  end
@@ -198,7 +248,7 @@ module Samagotchi
198
248
  return 1 unless prepared
199
249
  return 1 if installed_under_another_name?(expanded_source, bundle_name, prepared, "Install")
200
250
 
201
- installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force)
251
+ installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force, dev: dev)
202
252
  run_installer(installer, failed: "Install") do |manifest|
203
253
  print_hooks_and_plugin(manifest)
204
254
  print_provenance(bundle_name, manifest)
@@ -457,7 +507,7 @@ module Samagotchi
457
507
  @stdout.puts "Bundle '#{name}' not installed."
458
508
  return 0
459
509
  end
460
- @stdout.puts "Bundle: #{name} v#{st[:provenance].version} scope=#{status_scope(st)} installed=#{st[:provenance].installed_at}"
510
+ @stdout.puts "Bundle: #{name} v#{st[:provenance].version}#{dev_label(st[:provenance])} scope=#{status_scope(st)} installed=#{st[:provenance].installed_at}"
461
511
  if st[:scope_error]
462
512
  @stdout.puts "Target: (unknown scope: this chi can't resolve it; upgrade chi or reinstall the bundle)"
463
513
  else
@@ -519,12 +569,19 @@ module Samagotchi
519
569
  plugin_info = plugin ? " plugin=#{plugin[:file]}" : ""
520
570
  includes = st[:provenance].includes
521
571
  includes_info = includes ? " includes=#{includes.join(",")}" : ""
522
- @stdout.puts " #{bname} v#{st[:provenance].version} scope=#{status_scope(st)} files=#{st[:files].size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
572
+ @stdout.puts " #{bname} v#{st[:provenance].version}#{dev_label(st[:provenance])} scope=#{status_scope(st)} files=#{st[:files].size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
523
573
  end
524
574
  end
525
575
  0
526
576
  end
527
577
 
578
+ # " dev → /path (missing)" when a dev install's source isn't there, else "".
579
+ def dev_label(record)
580
+ return "" unless record.dev
581
+
582
+ " dev → #{record.source}#{" (missing)" unless File.directory?(record.source)}"
583
+ end
584
+
528
585
  # "team (unknown)" for a scope this chi can't resolve (Status#bundle_status).
529
586
  def status_scope(st) = st[:scope_error] ? "#{st[:scope]} (unknown)" : st[:scope]
530
587
 
@@ -844,7 +901,7 @@ module Samagotchi
844
901
  parsed = parse_flags(CHECK_FLAGS, rest, "check", help: CHECK_HELP)
845
902
  return parsed if parsed.is_a?(Integer)
846
903
 
847
- settings = check_settings(parsed.options.fetch(:set, [])) or return CLI::Exit::USAGE
904
+ settings = check_settings(parsed.options.fetch(:set, []), label: "check") or return CLI::Exit::USAGE
848
905
  if parsed.args.size > 1
849
906
  @stderr.puts "chi bundle check takes one PATH (#{parsed.args.join(" ")})"
850
907
  return CLI::Exit::USAGE
@@ -858,14 +915,163 @@ module Samagotchi
858
915
  Check.new(dir, fix: parsed.options.fetch(:fix, false), settings: settings, stdout: @stdout).run
859
916
  end
860
917
 
918
+ def try_bundle(rest)
919
+ parsed = parse_flags(TRY_FLAGS, rest, "try", help: TRY_HELP)
920
+ return parsed if parsed.is_a?(Integer)
921
+
922
+ options = parsed.options
923
+ if options[:prompt].nil? || parsed.args.size != 1
924
+ @stderr.puts "Usage: chi bundle try PATH -p TEXT [options]"
925
+ return CLI::Exit::USAGE
926
+ end
927
+ path = parsed.args.first
928
+ unless File.directory?(path)
929
+ @stderr.puts "chi bundle try: #{path} isn't a folder"
930
+ return CLI::Exit::USAGE
931
+ end
932
+ if options.values_at(:model, :toolkit, :thinking, :memory, :mute).flatten.compact.any?(&:empty?)
933
+ @stderr.puts "chi bundle try: model, toolkit, thinking, memory and mute values cannot be empty"
934
+ return CLI::Exit::USAGE
935
+ end
936
+ require_relative "thinking"
937
+ if options[:thinking] && !Thinking::LEVELS.map(&:to_s).include?(options[:thinking])
938
+ @stderr.puts "chi bundle try: --thinking takes #{Thinking::LEVELS.join(", ")}, not #{options[:thinking]}"
939
+ return CLI::Exit::USAGE
940
+ end
941
+
942
+ timeout = parse_try_timeout(options[:timeout])
943
+ return CLI::Exit::USAGE unless timeout
944
+
945
+ set_pairs = []
946
+ cost_cap = 1
947
+ cost_cap_override = false
948
+ require_relative "bundle_session_settings"
949
+ Array(options[:set]).each do |pair|
950
+ key, sep, value = pair.partition("=")
951
+ if sep.empty?
952
+ @stderr.puts "chi bundle try: --set takes KEY=VALUE, not #{pair}"
953
+ return CLI::Exit::USAGE
954
+ end
955
+ if key == "cost-cap.cap"
956
+ cost_cap = BundleSessionSettings.typed(value)
957
+ cost_cap_override = true
958
+ unless (cost_cap.is_a?(Numeric) && cost_cap >= 0) || cost_cap == false
959
+ @stderr.puts "chi bundle try: --set cost-cap.cap takes a non-negative number or off"
960
+ return CLI::Exit::USAGE
961
+ end
962
+ else
963
+ set_pairs << pair
964
+ end
965
+ end
966
+ settings = check_settings(set_pairs, label: "try")
967
+ return CLI::Exit::USAGE unless settings
968
+
969
+ require_relative "bundle_command/try/sandbox"
970
+ require_relative "bundle_command/try/run"
971
+ require_relative "bundle_command/try/trace"
972
+ sandbox = nil
973
+ begin
974
+ sandbox = Try::Sandbox.new(path, bundle_settings: settings, cost_cap: cost_cap,
975
+ cost_cap_override: cost_cap_override)
976
+ sandbox.install
977
+ result = Try::Run.new(sandbox).run(prompt: options[:prompt], options: options, timeout: timeout)
978
+ trace = Try::TraceReader.new(state_home: sandbox.state_home, session_id: result.session_id)
979
+ .read(exit_code: result.exit_status, answer: result.stdout, stderr: result.stderr,
980
+ sandbox: options[:keep] ? sandbox.root : nil)
981
+ render_try_trace(trace, json: options[:json], child_stderr: result.stderr)
982
+ @stderr.puts "chi bundle try: timed out after #{timeout} seconds" if result.timed_out
983
+ result.exit_status
984
+ rescue Try::Sandbox::Error => e
985
+ @stderr.puts e.message
986
+ e.status
987
+ ensure
988
+ report_try_sandbox(sandbox) if options[:keep] && sandbox
989
+ sandbox&.cleanup unless options[:keep]
990
+ end
991
+ end
992
+
993
+ def render_try_trace(trace, json:, child_stderr:)
994
+ error_lines = child_stderr.lines.reject { |line| line.start_with?("Session: ") }
995
+ @stderr.write(error_lines.join)
996
+ if json
997
+ render_trace_lines(trace, @stderr, include_answer: false)
998
+ @stdout.puts JSON.generate(trace.to_h.transform_keys(&:to_s))
999
+ else
1000
+ render_trace_lines(trace, @stdout, include_answer: true)
1001
+ end
1002
+ end
1003
+
1004
+ def report_try_sandbox(sandbox)
1005
+ @stderr.puts "sandbox: #{sandbox.root}"
1006
+ @stderr.puts "debug log: #{File.join(sandbox.state_home, "samagotchi", "samagotchi.log")}"
1007
+ end
1008
+
1009
+ def render_trace_lines(trace, output, include_answer:)
1010
+ output.puts "tools:"
1011
+ trace.tools.each do |tool|
1012
+ output.puts format(" %d %-16s %-7s %dms %s", tool[:index], tool[:name], tool[:status],
1013
+ tool[:duration_ms], JSON.generate(tool[:args].to_s[0, 60]))
1014
+ end
1015
+ trace.notices.each { |notice| output.puts "notices: [#{notice["hook"] || "hook"}] #{notice["text"]}" }
1016
+ trace.cards.each do |card|
1017
+ output.puts "card: #{[card["title"], card["body"]].compact.join(" — ")}"
1018
+ end
1019
+ trace.questions.each do |entry|
1020
+ question = entry.dig("pending_question", "question").to_s
1021
+ outcome = if entry["cancelled"] && entry["reason"] == "non_interactive"
1022
+ "unanswerable (non_interactive)"
1023
+ elsif entry["cancelled"]
1024
+ "cancelled (#{entry["reason"]})"
1025
+ elsif entry["answer"]
1026
+ "answered"
1027
+ else
1028
+ "unanswered"
1029
+ end
1030
+ output.puts "questions: #{JSON.generate(question)} -> #{outcome}"
1031
+ end
1032
+ if trace.interactive_blocked
1033
+ count = trace.denied.size + trace.questions.count { |question| question["cancelled"] && question["reason"] == "non_interactive" }
1034
+ output.puts "warn: #{count} question(s)/call(s) couldn't be answered"
1035
+ end
1036
+ if include_answer
1037
+ answer_lines = trace.answer.lines
1038
+ output.puts "answer:"
1039
+ answer_lines.first(10).each { |line| output.puts " #{line.chomp}" }
1040
+ output.puts " … (truncated)" if answer_lines.size > 10
1041
+ end
1042
+ tokens = trace.tokens
1043
+ prompt = tokens[:prompt] || "unknown"
1044
+ completion = tokens[:completion] || "unknown"
1045
+ cost = if trace.cost
1046
+ "#{"~" if trace.cost_is_estimate}$#{format("%.4f", trace.cost)}"
1047
+ else
1048
+ "unknown"
1049
+ end
1050
+ output.puts "tokens: #{prompt} in / #{completion} out cost #{cost} exit #{trace.exit_code}" \
1051
+ "#{" (#{trace.session_id})" if trace.session_id}"
1052
+ end
1053
+
1054
+ def parse_try_timeout(value)
1055
+ return 300 unless value
1056
+
1057
+ timeout = Float(value)
1058
+ return timeout if timeout.positive? && timeout.finite?
1059
+
1060
+ @stderr.puts "chi bundle try: --timeout must be a positive number"
1061
+ nil
1062
+ rescue ArgumentError
1063
+ @stderr.puts "chi bundle try: --timeout must be a positive number"
1064
+ nil
1065
+ end
1066
+
861
1067
  # --set KEY=VALUE values as typed (BundleSessionSettings.typed), or nil
862
1068
  # after saying what's wrong.
863
- def check_settings(pairs)
1069
+ def check_settings(pairs, label:)
864
1070
  require_relative "bundle_session_settings"
865
1071
  pairs.each_with_object({}) do |pair, out|
866
1072
  key, sep, value = pair.partition("=")
867
1073
  unless !sep.empty? && key.match?(BundleSessionSettings::KEY)
868
- @stderr.puts "chi bundle check: --set takes KEY=VALUE, not #{pair}"
1074
+ @stderr.puts "chi bundle #{label}: --set takes KEY=VALUE, not #{pair}"
869
1075
  return nil
870
1076
  end
871
1077
  out[key] = BundleSessionSettings.typed(value)
@@ -176,6 +176,8 @@ module Samagotchi
176
176
  file = File.join(dir, name, "plugin", bundle.plugin.file.to_s)
177
177
  return "the file is missing" unless File.file?(file)
178
178
 
179
+ return nil if MemoryBundle::Provenance.unchecked?(bundle.plugin.sha256)
180
+
179
181
  expected = MemoryBundle::Provenance.recorded_sha(bundle.plugin.sha256)
180
182
  return "no sha256 recorded for it (reinstall the bundle)" if expected.empty?
181
183
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.88.0
3
+ version: 0.89.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -134,8 +134,7 @@ module Samagotchi
134
134
 
135
135
  record.scripts.each do |file, sha|
136
136
  path = File.join(provenance.scripts_dir, file)
137
- recorded = MemoryBundle::Provenance.recorded_sha(sha)
138
- next if File.file?(path) && MemoryBundle::Provenance.file_sha(path) == recorded
137
+ next if File.file?(path) && MemoryBundle::Provenance.sha_matches?(path, sha)
139
138
 
140
139
  raise Invalid, "bundle #{source.provider}'s scripts/#{file} differs from the installed one (reinstall the bundle)"
141
140
  end
@@ -126,6 +126,8 @@ module Samagotchi
126
126
  # Why the file doesn't match the sha256 recorded at install, or nil.
127
127
  # With none recorded, only a required hook counts as a mismatch.
128
128
  def sha_mismatch(file, recorded, required:)
129
+ return nil if MemoryBundle::Provenance.unchecked?(recorded)
130
+
129
131
  expected = MemoryBundle::Provenance.recorded_sha(recorded)
130
132
  return (required ? "no sha256 recorded for it (reinstall the bundle)" : nil) if expected.empty?
131
133
 
@@ -29,6 +29,17 @@ module Samagotchi
29
29
  File.file?(path) ? path : nil
30
30
  end
31
31
 
32
+ # What Bundler changed in ENV, mapped back to the values from before it
33
+ # (nil: unset); {} outside Bundler. Child chi commands use this in gem
34
+ # mode so they load the same installed gem without inheriting this app's
35
+ # bundle.
36
+ def unbundled_env_changes(env = ENV.to_h)
37
+ return {} unless defined?(::Bundler) && ::Bundler.respond_to?(:unbundled_env)
38
+
39
+ clean = ::Bundler.unbundled_env
40
+ (env.keys | clean.keys).each_with_object({}) { |key, changes| changes[key] = clean[key] unless clean[key] == env[key] }
41
+ end
42
+
32
43
  def real_path(path)
33
44
  File.realpath(path)
34
45
  rescue SystemCallError
@@ -17,6 +17,8 @@ module Samagotchi
17
17
  # scripts/*. Each replaces what an earlier version installed. Installer
18
18
  # runs it after the memories; it adds to the installer's results and
19
19
  # warnings, and #run returns what the record (Provenance#write) keeps.
20
+ # A dev install (+dev+, `chi bundle install --dev`) links each file to
21
+ # the source instead of copying it, and warns of no checksum mismatch.
20
22
  class AssetInstaller
21
23
  # What was installed: the paths Provenance#write records (each
22
24
  # empty / nil on a dry run).
@@ -32,7 +34,7 @@ module Samagotchi
32
34
  # @param results, warnings, conflicts the Installer's (results and
33
35
  # warnings are added to)
34
36
  def initialize(name:, source_dir:, manifest:, provenance:, existing:, results:, warnings:, conflicts:,
35
- force: false, strict: true, upgrade: false, dry_run: false)
37
+ force: false, strict: true, upgrade: false, dry_run: false, dev: false)
36
38
  @name = name
37
39
  @bundle_dir = source_dir
38
40
  @manifest = manifest
@@ -45,6 +47,7 @@ module Samagotchi
45
47
  @strict = strict
46
48
  @upgrade = upgrade
47
49
  @dry_run = dry_run
50
+ @dev = dev
48
51
  end
49
52
 
50
53
  # @return [Installed]
@@ -52,8 +55,8 @@ module Samagotchi
52
55
  # script the bundle doesn't have
53
56
  def run
54
57
  install_hooks
55
- prune_dropped_hooks if @upgrade && @existing && !@dry_run
56
- verify_hook_checksums if @manifest && @strict
58
+ prune_dropped_hooks if (@upgrade || (@dev && @existing&.dev)) && @existing && !@dry_run
59
+ verify_hook_checksums if @manifest && @strict && !@dev
57
60
  incoming_rules = Dir.glob(File.join(@bundle_dir, "guardrails", "*.yml")).sort
58
61
  .reject { |src| linked_out?(src, "guardrails/#{File.basename(src)}") }
59
62
  guardrail_files = install_guardrails(incoming_rules)
@@ -109,7 +112,7 @@ module Samagotchi
109
112
 
110
113
  FileUtils.mkdir_p(@provenance.hooks_dir)
111
114
  unchanged = File.exist?(dest) && FileUtils.identical?(src, dest)
112
- FileUtils.cp(src, dest)
115
+ place(src, dest)
113
116
  @results[basename] = if unchanged
114
117
  { status: "skipped", reason: "already up to date" }
115
118
  elsif @upgrade && @existing
@@ -132,7 +135,7 @@ module Samagotchi
132
135
  else
133
136
  FileUtils.mkdir_p(@provenance.hooks_dir)
134
137
  dest = File.join(@provenance.hooks_dir, basename)
135
- FileUtils.cp(src, dest)
138
+ place(src, dest)
136
139
  @results[basename] = { status: "installed" }
137
140
  @hook_files[basename] = dest
138
141
  end
@@ -140,13 +143,13 @@ module Samagotchi
140
143
  end
141
144
 
142
145
  # An upgrade removes the hooks the previous version shipped and this
143
- # one doesn't (a local file too, with a warning).
146
+ # one doesn't (a local file too, with a warning; a dev link silently).
144
147
  def prune_dropped_hooks
145
148
  @existing.hooks.each_key do |old_key_str|
146
149
  next if @bundle_hooks.include?(old_key_str)
147
150
 
148
151
  old_dest = File.join(@provenance.hooks_dir, old_key_str)
149
- if File.exist?(old_dest) && !@force
152
+ if File.exist?(old_dest) && !@force && !File.symlink?(old_dest)
150
153
  @warnings << "Bundle no longer includes hook #{old_key_str} but local file exists — removing"
151
154
  end
152
155
  FileUtils.rm_f(old_dest)
@@ -193,7 +196,7 @@ module Samagotchi
193
196
  FileUtils.mkdir_p(target)
194
197
  incoming_rules.to_h do |src|
195
198
  dest = File.join(target, File.basename(src))
196
- FileUtils.cp(src, dest)
199
+ place(src, dest)
197
200
  @results["guardrails/#{File.basename(src)}"] =
198
201
  unchanged.include?(src) ? { status: "skipped", reason: "already up to date" } : { status: "installed" }
199
202
  [File.basename(src), dest]
@@ -227,7 +230,7 @@ module Samagotchi
227
230
  unchanged = File.file?(dest) && FileUtils.identical?(src, dest)
228
231
  FileUtils.rm_rf(@provenance.plugin_dir)
229
232
  FileUtils.mkdir_p(@provenance.plugin_dir)
230
- FileUtils.cp(src, dest)
233
+ place(src, dest)
231
234
  @results[plugin[:file]] = if unchanged
232
235
  { status: "skipped", reason: "already up to date" }
233
236
  elsif @upgrade && @provenance.installed?
@@ -237,7 +240,7 @@ module Samagotchi
237
240
  end
238
241
  expected = @manifest.checksum_for_plugin
239
242
  actual = Digest::SHA256.hexdigest(File.binread(dest))
240
- if @strict && expected && actual != expected
243
+ if @strict && !@dev && expected && actual != expected
241
244
  @warnings << "Checksum mismatch for plugin #{plugin[:file]}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
242
245
  end
243
246
  dest
@@ -253,6 +256,8 @@ module Samagotchi
253
256
  src = File.join(@bundle_dir, "scripts", file)
254
257
  raise Installer::InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have" unless File.file?(src)
255
258
  raise Installer::InstallError, "scripts/#{file} is a symlink (not installed)" if linked?(src)
259
+
260
+ @warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src)
256
261
  end
257
262
  return {} if @dry_run
258
263
 
@@ -266,21 +271,28 @@ module Samagotchi
266
271
  FileUtils.mkdir_p(@provenance.scripts_dir)
267
272
  scripts.to_h do |file, declared|
268
273
  dest = File.join(@provenance.scripts_dir, file)
269
- FileUtils.cp(File.join(@bundle_dir, "scripts", file), dest)
274
+ place(File.join(@bundle_dir, "scripts", file), dest)
270
275
  @results["scripts/#{file}"] = if unchanged.include?(file) then { status: "skipped", reason: "already up to date" }
271
276
  elsif @upgrade && @provenance.installed? then { status: "updated" }
272
277
  else { status: "installed" }
273
278
  end
274
- FileUtils.chmod(0o755, dest)
279
+ FileUtils.chmod(0o755, dest) unless @dev
275
280
  actual = Digest::SHA256.hexdigest(File.binread(dest))
276
281
  expected = declared.to_s.delete_prefix("sha256:")
277
- if @strict && !expected.empty? && actual != expected
282
+ if @strict && !@dev && !expected.empty? && actual != expected
278
283
  @warnings << "Checksum mismatch for script #{file}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
279
284
  end
280
285
  [file, dest]
281
286
  end
282
287
  end
283
288
 
289
+ # Copies +src+ to +dest+, or (a dev install) links +dest+ to it. An
290
+ # existing link goes first: a copy over it would write into the source.
291
+ def place(src, dest)
292
+ FileUtils.rm_f(dest) if File.symlink?(dest)
293
+ @dev ? File.symlink(File.realpath(src), dest) : FileUtils.cp(src, dest)
294
+ end
295
+
284
296
  # Whether the bundle file +src+ reads through a symlink: it is one, or
285
297
  # it (or its dir, hooks/ say) resolves outside the bundle dir. A tar,
286
298
  # zip or git repo keeps links; copying through one would install any
@@ -22,13 +22,15 @@ module Samagotchi
22
22
  # includes a profile's recorded members, nil for a plain bundle
23
23
  # session_settings
24
24
  # the keys a session may set (chi --set), [] when none
25
+ # dev a dev install (`chi bundle install --dev`): its source is
26
+ # the folder it links to and its files carry Provenance::DEV_SHA
25
27
  # error why manifest.json couldn't be read (the rest empty), else nil
26
28
  class InstalledBundle < Data.define(:name, :version, :scope, :source, :installed_at, :trust_level, :source_commit,
27
29
  :requires_chi, :files, :hooks, :guardrails, :scripts, :plugin, :needs,
28
- :includes, :context_providers, :session_settings, :error)
30
+ :includes, :context_providers, :session_settings, :dev, :error)
29
31
  OPTIONAL = { version: nil, scope: nil, source: nil, installed_at: nil, trust_level: nil, source_commit: nil,
30
32
  requires_chi: nil, files: {}, hooks: {}, guardrails: {}, scripts: {}, plugin: nil, needs: nil,
31
- includes: nil, context_providers: nil, session_settings: [], error: nil }.freeze
33
+ includes: nil, context_providers: nil, session_settings: [], dev: false, error: nil }.freeze
32
34
 
33
35
  def initialize(name:, **fields)
34
36
  super(name: name, **OPTIONAL, **fields)
@@ -60,7 +62,8 @@ module Samagotchi
60
62
  scripts: mapping(h["scripts"]) { |meta| sha(meta) },
61
63
  plugin: PluginRef.parse(h["plugin"]), needs: h["needs"], context_providers: h["context_providers"],
62
64
  includes: h["includes"].is_a?(Array) ? h["includes"].map(&:to_s) : nil,
63
- session_settings: h["session_settings"].is_a?(Array) ? h["session_settings"].map(&:to_s) : [])
65
+ session_settings: h["session_settings"].is_a?(Array) ? h["session_settings"].map(&:to_s) : [],
66
+ dev: h["dev"] == true)
64
67
  end
65
68
 
66
69
  def self.string(value) = value&.to_s
@@ -40,7 +40,7 @@ module Samagotchi
40
40
  # installs from a dir in it only, unless force); ShippedUpdate,
41
41
  # Profile and SystemBundle pass the one they install from.
42
42
  def initialize(source:, name:, scope: nil, force: false, strict: true, upgrade: false, dry_run: false, prepared: nil,
43
- shipped_dir: SourceNormalizer::SHIPPED_DIR)
43
+ shipped_dir: SourceNormalizer::SHIPPED_DIR, dev: false)
44
44
  @source = source
45
45
  @shipped_dir = shipped_dir
46
46
  @prepared = prepared
@@ -50,6 +50,7 @@ module Samagotchi
50
50
  @strict = strict
51
51
  @upgrade = upgrade
52
52
  @dry_run = dry_run
53
+ @dev = dev
53
54
  @results = {}
54
55
  @warnings = []
55
56
  @placeholder_warnings = []
@@ -98,7 +99,7 @@ module Samagotchi
98
99
  installed = AssetInstaller.new(
99
100
  name: @name, source_dir: @bundle_dir, manifest: @manifest, provenance: @provenance, existing: @existing,
100
101
  results: @results, warnings: @warnings, conflicts: @conflicts,
101
- force: @force, strict: @strict, upgrade: @upgrade, dry_run: @dry_run
102
+ force: @force, strict: @strict, upgrade: @upgrade, dry_run: @dry_run, dev: @dev
102
103
  ).run
103
104
 
104
105
  # Files the previous version shipped and this one doesn't.
@@ -172,10 +173,14 @@ module Samagotchi
172
173
  def check_source
173
174
  refuse_case_twin
174
175
  refuse_shipped_name
176
+ refuse_dev_mismatch
175
177
  now = incoming_identity
176
178
  return if now.nil?
177
179
 
178
180
  was = @existing && SourceIdentity.of(@existing.source)
181
+ if @dev && @existing&.dev && was != now
182
+ raise InstallError, "#{@name} is a dev install of #{@existing.source}; uninstall it before installing from #{@source}"
183
+ end
179
184
  return if was.nil? || was == now
180
185
  return @source_change = [@existing.source, @source.to_s] if @upgrade
181
186
 
@@ -191,6 +196,19 @@ module Samagotchi
191
196
  raise InstallError, "#{@name} differs only in case from the installed #{twin}" if twin
192
197
  end
193
198
 
199
+ # A dev install (--dev, linked to its source) and a normal one don't
200
+ # replace each other: neither an upgrade nor a plain install over a
201
+ # dev bundle, nor --dev over a normal one.
202
+ def refuse_dev_mismatch
203
+ return if @existing.nil? || @existing.dev == @dev
204
+
205
+ if @existing.dev
206
+ raise InstallError, "#{@name} is a dev install of #{@existing.source}: it follows its source already " \
207
+ "(to leave dev mode: chi bundle uninstall #{@name}, then chi bundle install without --dev)"
208
+ end
209
+ raise InstallError, "#{@name} is installed normally; `chi bundle uninstall #{@name}` first, then chi bundle install --dev"
210
+ end
211
+
194
212
  # A shipped name (in any case) from anything but this chi's own
195
213
  # shipped dir. A recorded source may be an older gem's (SourceIdentity
196
214
  # matches the path); an incoming one must be a dir in SHIPPED_DIR, so
@@ -421,7 +439,8 @@ module Samagotchi
421
439
  scripts_files: installed.script_files,
422
440
  context_providers: @manifest.context_providers,
423
441
  file_descriptions: @manifest.file_descriptions,
424
- session_settings: @manifest.session_settings
442
+ session_settings: @manifest.session_settings,
443
+ dev: @dev
425
444
  )
426
445
  end
427
446