samagotchi 0.102.0 → 0.103.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/bin/chi +23 -191
  4. data/docs/architecture.md +54 -0
  5. data/docs/cli.md +114 -7
  6. data/docs/configuration.md +7 -1
  7. data/docs/guardrails.md +2 -1
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/llm-context-forget.md +11 -1
  10. data/docs/internals/remote-sessions.md +303 -0
  11. data/docs/internals/working-set.md +95 -11
  12. data/docs/plugins.md +26 -2
  13. data/docs/sessions.md +12 -5
  14. data/docs/sub-agent.md +8 -0
  15. data/lib/samagotchi/answer_command.rb +27 -25
  16. data/lib/samagotchi/bridge.rb +24 -4
  17. data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
  18. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  19. data/lib/samagotchi/bridge_client.rb +22 -38
  20. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  22. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  24. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  25. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  26. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  27. data/lib/samagotchi/chi_invocation.rb +43 -0
  28. data/lib/samagotchi/cli/command.rb +3 -2
  29. data/lib/samagotchi/cli/flags.rb +5 -0
  30. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  31. data/lib/samagotchi/context_command.rb +80 -73
  32. data/lib/samagotchi/engine.rb +12 -8
  33. data/lib/samagotchi/idle_recap.rb +7 -1
  34. data/lib/samagotchi/kernel_loop.rb +13 -1
  35. data/lib/samagotchi/launch_mode.rb +22 -0
  36. data/lib/samagotchi/llm_context_forget.rb +13 -6
  37. data/lib/samagotchi/note_command.rb +8 -7
  38. data/lib/samagotchi/parent_report.rb +55 -43
  39. data/lib/samagotchi/remote_command.rb +170 -0
  40. data/lib/samagotchi/remotes.rb +130 -0
  41. data/lib/samagotchi/run_options.rb +233 -0
  42. data/lib/samagotchi/send_command/busy.rb +58 -0
  43. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  44. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  45. data/lib/samagotchi/send_command.rb +173 -108
  46. data/lib/samagotchi/session.rb +30 -5
  47. data/lib/samagotchi/session_access/contract.rb +129 -0
  48. data/lib/samagotchi/session_access/dispatch.rb +133 -0
  49. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  50. data/lib/samagotchi/session_access/local.rb +349 -0
  51. data/lib/samagotchi/session_access/remote.rb +638 -0
  52. data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
  53. data/lib/samagotchi/session_access.rb +141 -0
  54. data/lib/samagotchi/session_archive_command.rb +9 -5
  55. data/lib/samagotchi/session_commands.rb +20 -15
  56. data/lib/samagotchi/session_delete_command.rb +8 -4
  57. data/lib/samagotchi/session_manager.rb +7 -2
  58. data/lib/samagotchi/session_setup.rb +11 -6
  59. data/lib/samagotchi/sessions_command.rb +36 -14
  60. data/lib/samagotchi/terminal_ui/attach_launcher.rb +51 -15
  61. data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
  62. data/lib/samagotchi/terminal_ui/attached_loop.rb +80 -75
  63. data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
  64. data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
  65. data/lib/samagotchi/terminal_ui.rb +2 -2
  66. data/lib/samagotchi/title_command.rb +9 -11
  67. data/lib/samagotchi/version.rb +1 -1
  68. data/lib/samagotchi/web/app.rb +360 -69
  69. data/lib/samagotchi/web/public/data.js +3 -2
  70. data/lib/samagotchi/web/public/index.html +20 -4
  71. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  72. data/lib/samagotchi/web/public/stage_view.js +3 -2
  73. data/lib/samagotchi/web/public/stream_handlers.js +3 -1
  74. data/lib/samagotchi/web/public/working_set.js +159 -28
  75. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  76. data/lib/samagotchi/web/public/working_set_view.js +91 -33
  77. data/lib/samagotchi/web/session_access_routes.rb +343 -0
  78. data/lib/samagotchi/worker_sidecar.rb +12 -5
  79. data/lib/samagotchi/working_set.rb +88 -14
  80. data/lib/samagotchi/working_set_command.rb +133 -30
  81. data/lib/samagotchi/working_set_pin.rb +34 -0
  82. data/lib/samagotchi/working_set_steer.rb +191 -27
  83. metadata +19 -1
@@ -3,6 +3,7 @@
3
3
  require "delegate"
4
4
  require "json"
5
5
 
6
+ require_relative "chi_invocation"
6
7
  require_relative "cli/exit"
7
8
  require_relative "pending_question"
8
9
  require_relative "session"
@@ -81,7 +82,9 @@ module Samagotchi
81
82
  # @return [Hash] the --format json object
82
83
  # @param strategy [Guardrails::ParentPolicy::Strategy, nil] what a parent
83
84
  # may pick on the waited-for session (#strategy_for); nil: config.yml's
84
- def json(result, session_id:, timeout: nil, extra: {}, strategy: nil)
85
+ # @param via [ChiInvocation] how the printed commands are spelled (this
86
+ # machine's chi, or chi --remote NAME); each function here takes it
87
+ def json(result, session_id:, timeout: nil, extra: {}, strategy: nil, via: ChiInvocation::HERE)
85
88
  report = { status: status(result), session_id: session_id }
86
89
  report[:turn_id] = result.turn_id unless result.turn_id.to_s.empty?
87
90
  case report[:status]
@@ -89,10 +92,10 @@ module Samagotchi
89
92
  when "question"
90
93
  question = question(result.question, strategy: strategy)
91
94
  report[:question] = question
92
- report[:answer_with] = answer_with(session_id, question)
95
+ report[:answer_with] = answer_with(session_id, question, via: via)
93
96
  else
94
97
  report.merge!(stop_facts(result))
95
- report[:detail] = detail(result, session_id: session_id, timeout: timeout, strategy: strategy)
98
+ report[:detail] = detail(result, session_id: session_id, timeout: timeout, strategy: strategy, via: via)
96
99
  end
97
100
  report.merge(extra)
98
101
  end
@@ -110,8 +113,8 @@ module Samagotchi
110
113
  end
111
114
 
112
115
  # @return [String] the JSON object as one line
113
- def json_line(result, session_id:, timeout: nil, extra: {}, strategy: nil)
114
- JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy))
116
+ def json_line(result, session_id:, timeout: nil, extra: {}, strategy: nil, via: ChiInvocation::HERE)
117
+ JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy, via: via))
115
118
  end
116
119
 
117
120
  # Print a wait's end the way the commands do: the JSON object on
@@ -120,17 +123,19 @@ module Samagotchi
120
123
  # @param command [String] "chi send", "chi answer"
121
124
  # @param extra [Hash] more keys for the JSON object (#json)
122
125
  # @return [Integer] the exit status
123
- def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {}, strategy: nil)
126
+ def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {}, strategy: nil,
127
+ via: ChiInvocation::HERE)
124
128
  if json
125
- stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy))
129
+ stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy, via: via))
126
130
  elsif result.status == :done
127
131
  stdout.puts(result.text)
128
132
  else
129
133
  stdout.flush
130
134
  text = if result.status == :waiting_for_answer
131
- question_text(result.question, session_id: session_id, strategy: strategy)
135
+ question_text(result.question, session_id: session_id, strategy: strategy, via: via)
132
136
  else
133
- "#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id, strategy: strategy)}\n"
137
+ "#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id, strategy: strategy,
138
+ via: via)}\n"
134
139
  end
135
140
  stderr.print("#{command}: #{text}")
136
141
  end
@@ -177,16 +182,22 @@ module Samagotchi
177
182
  # lists an Allow once a parent may give). A step-limit question's
178
183
  # continues the turn, or stops it where a parent may not continue (its
179
184
  # other form is in #question_text).
180
- def answer_with(session_id, question)
181
- return "chi answer #{session_id} --question #{question[:id]} --option Deny --text WHY" if approval?(question)
182
- return stop_command(session_id, question) if continue?(question) && !may_continue?(question)
183
- return "chi answer #{session_id} --question #{question[:id]} --option Continue" if continue?(question)
185
+ def answer_with(session_id, question, via: ChiInvocation::HERE)
186
+ return answer_command(session_id, question, "Deny --text WHY", via: via) if approval?(question)
187
+ return stop_command(session_id, question, via: via) if continue?(question) && !may_continue?(question)
188
+ return answer_command(session_id, question, "Continue", via: via) if continue?(question)
184
189
 
185
190
  # The one option a parent may pick (cost-cap's Stop under hard).
186
191
  only = Array(question[:allowed_options])
187
- return "chi answer #{session_id} --question #{question[:id]} --option #{option_arg(only.first)}" if only.size == 1
192
+ return answer_command(session_id, question, option_arg(only.first), via: via) if only.size == 1
188
193
 
189
- "chi answer #{session_id} --question #{question[:id]} --option N"
194
+ answer_command(session_id, question, "N", via: via)
195
+ end
196
+
197
+ # chi answer ID --question QID --option +option+ (its words after
198
+ # --option: a label, maybe --text).
199
+ def answer_command(session_id, question, option, via: ChiInvocation::HERE)
200
+ via.command("answer #{session_id} --question #{question[:id]} --option #{option}")
190
201
  end
191
202
 
192
203
  # What a parent does with an approval it may not allow.
@@ -239,17 +250,18 @@ module Samagotchi
239
250
  question.fetch(:allowed_options, question[:options]).include?(Guardrails::ParentPolicy::CONTINUE)
240
251
  end
241
252
 
242
- def stop_command(session_id, question)
243
- "chi answer #{session_id} --question #{question[:id]} --option Stop --text WHY"
253
+ def stop_command(session_id, question, via: ChiInvocation::HERE)
254
+ answer_command(session_id, question, "Stop --text WHY", via: via)
244
255
  end
245
256
 
246
257
  def option_arg(label) = label.match?(/\A[\w.-]+\z/) ? label : label.inspect
247
258
 
248
259
  # The other way: the user answers it in chi (the web's bell and badge
249
260
  # say it waits), and a wait with no message waits past it.
250
- def leave_open(session_id)
251
- "or leave it open: tell your user it waits in chi web (session #{session_id[0, 8]}); " \
252
- "chi send --wait --format json #{session_id} waits until they answer"
261
+ def leave_open(session_id, via: ChiInvocation::HERE)
262
+ web = via.remote? ? via.web : "chi web"
263
+ "or leave it open: tell your user it waits in #{web} (session #{session_id[0, 8]}); " \
264
+ "#{via.command("send --wait --format json #{session_id}")} waits until they answer"
253
265
  end
254
266
 
255
267
  # @param question [PendingQuestion, Hash, nil] a pending question, or a
@@ -267,13 +279,13 @@ module Samagotchi
267
279
  end
268
280
 
269
281
  # What a message does to a step-limit question.
270
- def message_drops(session_id)
271
- "a message instead (chi send #{session_id} -m …) drops it and starts a new turn"
282
+ def message_drops(session_id, via: ChiInvocation::HERE)
283
+ "a message instead (#{via.command("send #{session_id} -m …")}) drops it and starts a new turn"
272
284
  end
273
285
 
274
286
  # What a wait that ended without an answer says, one line.
275
- def detail(result, session_id:, timeout: nil, turn_id: nil, strategy: nil)
276
- attach = "chi --attach #{session_id}"
287
+ def detail(result, session_id:, timeout: nil, turn_id: nil, strategy: nil, via: ChiInvocation::HERE)
288
+ shows = via.shows(session_id)
277
289
  case result.status
278
290
  when :waiting_for_answer
279
291
  q = question(result.question, strategy: strategy)
@@ -281,20 +293,20 @@ module Samagotchi
281
293
  "waiting for an approval: #{first_line(q[:text])}; #{approval_advice(q, strategy: strategy)}"
282
294
  elsif continue?(q) && !may_continue?(q)
283
295
  "waiting at the step limit: #{first_line(q[:text])}; a parent may only stop it here " \
284
- "(#{policy(strategy).label}): #{stop_command(session_id, q)}, or tell your user it waits"
296
+ "(#{policy(strategy).label}): #{stop_command(session_id, q, via: via)}, or tell your user it waits"
285
297
  elsif continue?(q)
286
298
  "waiting at the step limit: #{first_line(q[:text])}; " \
287
- "answer Continue or Stop: chi answer #{session_id} --question #{q[:id]} --option Continue"
299
+ "answer Continue or Stop: #{answer_command(session_id, q, "Continue", via: via)}"
288
300
  else
289
- "waiting for an answer: #{first_line(q[:text])}; open it: #{attach} or the web"
301
+ "waiting for an answer: #{first_line(q[:text])}; open it: #{via.open_it(session_id)}"
290
302
  end
291
- when :no_reply then "#{no_reply_line(result, session_id, strategy: strategy)}; #{attach} shows it"
292
- when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
293
- when :worker_gone then "the worker is gone; #{attach} shows what happened"
303
+ when :no_reply then "#{no_reply_line(result, session_id, strategy: strategy, via: via)}; #{shows} shows it"
304
+ when :error then "the worker failed: #{result.text}; #{shows} shows what happened"
305
+ when :worker_gone then "the worker is gone; #{shows} shows what happened"
294
306
  when :stopped then "the session was stopped (chi sessions stop)"
295
307
  else
296
- wait_again = "chi send --wait --turn #{turn_id} #{session_id}" unless turn_id.to_s.empty?
297
- hint = wait_again || attach
308
+ wait_again = via.command("send --wait --turn #{turn_id} #{session_id}") unless turn_id.to_s.empty?
309
+ hint = wait_again || shows
298
310
  timeout ? "still running after #{format("%g", timeout)} s: #{hint}" : "still running: #{hint}"
299
311
  end
300
312
  end
@@ -302,7 +314,7 @@ module Samagotchi
302
314
  # The whole question for stderr: header, text, numbered options, what
303
315
  # else it takes, and the commands that answer it.
304
316
  # @return [String] lines, newline-terminated
305
- def question_text(pending, session_id:, strategy: nil)
317
+ def question_text(pending, session_id:, strategy: nil, via: ChiInvocation::HERE)
306
318
  q = question(pending, strategy: strategy)
307
319
  lines = ["waiting for an answer (#{q[:kind]}): #{q[:header] || first_line(q[:text])}"]
308
320
  text_lines = q[:text].strip.lines.map(&:rstrip)
@@ -315,31 +327,31 @@ module Samagotchi
315
327
  lines << " allowing it is up to your user: #{DENY_AND_TELL}"
316
328
  else
317
329
  lines << " #{approval_advice(q, strategy: strategy)}"
318
- allows.each { |label| lines << " allow: chi answer #{session_id} --question #{q[:id]} --option #{option_arg(label)}" }
330
+ allows.each { |label| lines << " allow: #{answer_command(session_id, q, option_arg(label), via: via)}" }
319
331
  end
320
- lines << " deny: #{answer_with(session_id, q)}"
321
- lines << " #{leave_open(session_id)}"
332
+ lines << " deny: #{answer_with(session_id, q, via: via)}"
333
+ lines << " #{leave_open(session_id, via: via)}"
322
334
  elsif continue?(q)
323
335
  lines << if may_continue?(q)
324
- " continue: #{answer_with(session_id, q)}"
336
+ " continue: #{answer_with(session_id, q, via: via)}"
325
337
  else
326
338
  " continuing it is up to your user (#{policy(strategy).label}): stop it, or tell your user it waits"
327
339
  end
328
- lines << " stop: #{stop_command(session_id, q)} (the text is optional; the model reads it)"
329
- lines << " #{message_drops(session_id)}"
340
+ lines << " stop: #{stop_command(session_id, q, via: via)} (the text is optional; the model reads it)"
341
+ lines << " #{message_drops(session_id, via: via)}"
330
342
  else
331
343
  lines << " more than one allowed: repeat --option" if q[:multi_select]
332
344
  lines << " free text allowed: --text" if q[:allow_freeform]
333
345
  if q[:allowed_options]
334
346
  lines << " a parent may pick #{q[:allowed_options].map { |o| option_arg(o) }.join(", ")}; the others are up to your user"
335
347
  end
336
- lines << " answer: #{answer_with(session_id, q)}"
337
- lines << " or open it: chi --attach #{session_id} or the web"
348
+ lines << " answer: #{answer_with(session_id, q, via: via)}"
349
+ lines << " or open it: #{via.open_it(session_id)}"
338
350
  end
339
351
  lines.map { |line| "#{line}\n" }.join
340
352
  end
341
353
 
342
- def no_reply_line(result, session_id = nil, strategy: nil)
354
+ def no_reply_line(result, session_id = nil, strategy: nil, via: ChiInvocation::HERE)
343
355
  return command_line(result) if result.origin == TurnCheckpoint::COMMAND_ORIGIN
344
356
 
345
357
  case result.outcome
@@ -354,7 +366,7 @@ module Samagotchi
354
366
  ran_out = "the turn ran out of iterations#{" (#{result.limit} steps)" if result.limit} before it answered"
355
367
  strategy = policy(strategy)
356
368
  if strategy.allow?(Guardrails::ParentPolicy::SPEND)
357
- "#{ran_out}; chi send #{session_id} -m '/continue yes' continues it, a message drops it"
369
+ "#{ran_out}; #{via.command("send #{session_id} -m '/continue yes'")} continues it, a message drops it"
358
370
  else
359
371
  # No /continue yes hint: a parent may not continue it here.
360
372
  "#{ran_out}; continuing it is up to your user (#{strategy.label}), a message drops it"
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "cli/command"
5
+ require_relative "cli/exit"
6
+ require_relative "cli/flags"
7
+ require_relative "paths"
8
+ require_relative "remotes"
9
+ require_relative "session_access"
10
+ require_relative "session_access/http_transport"
11
+ require_relative "session_access/remote"
12
+
13
+ module Samagotchi
14
+ # `chi remote`: the other machines whose sessions chi --remote NAME (or
15
+ # CHI_REMOTE=NAME) reaches through their chi web (Remotes,
16
+ # SessionAccess::Remote). add checks the address and the token with GET
17
+ # /api/info before it saves anything; ls never prints a token.
18
+ class RemoteCommand
19
+ include CLI::Command
20
+
21
+ USAGE = <<~TEXT
22
+ Usage: chi remote <add|ls|rm> [options]
23
+ add NAME URL [--token-file FILE | --token-stdin]
24
+ a machine's chi web (http://LAN-IP:PORT, as chi web prints it with web.host: lan;
25
+ port 4567 by default), with its access token (chi web's LAN link carries it, after
26
+ ?token=). It is checked first: the chi it runs, or why it can't be reached. Saved
27
+ in #{Paths.tilde(Remotes.path)} (0600); an existing NAME is replaced.
28
+ ls [--format json] the remotes (never their tokens)
29
+ rm NAME forget one
30
+ Then: chi --remote NAME send|answer|note|context|sessions …, or CHI_REMOTE=NAME.
31
+ TEXT
32
+
33
+ SUBCOMMANDS = %w[add ls rm].freeze
34
+
35
+ FLAGS = {
36
+ "add" => CLI::Flags.new(help: HELP_WORDS) do |f|
37
+ f.value "--token-file"
38
+ f.switch "--token-stdin"
39
+ end,
40
+ "ls" => CLI::Flags.new(help: HELP_WORDS) { |f| f.value "--format" },
41
+ "rm" => CLI::Flags.new(help: HELP_WORDS)
42
+ }.freeze
43
+
44
+ # @param transport [#call, nil] (entry) → a transport for add's check;
45
+ # HTTPTransport by default
46
+ def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, path: Remotes.path, transport: nil)
47
+ @argv = argv.dup
48
+ @stdin = stdin
49
+ @stdout = stdout
50
+ @stderr = stderr
51
+ @path = path
52
+ @transport = transport || ->(entry) { SessionAccess::HTTPTransport.new(entry) }
53
+ end
54
+
55
+ # @return [Integer] exit status: 0 done, 1 refused or unreachable, 2 usage
56
+ def run
57
+ @sub = @argv[0]
58
+ if @sub.nil? || HELP_WORDS.include?(@sub)
59
+ @stdout.puts(USAGE)
60
+ return 0
61
+ end
62
+ return usage_error("unknown subcommand #{@sub}") unless SUBCOMMANDS.include?(@sub)
63
+
64
+ parsed = parse_flags(FLAGS.fetch(@sub), @argv[1..])
65
+ return parsed if parsed.is_a?(Integer)
66
+
67
+ send(:"run_#{@sub}", parsed.options, parsed.args)
68
+ rescue Remotes::Invalid => e
69
+ error_line("#{command_name}: #{e.message}")
70
+ CLI::Exit::FAILED
71
+ end
72
+
73
+ private
74
+
75
+ def command_name = @sub && SUBCOMMANDS.include?(@sub) ? "chi remote #{@sub}" : "chi remote"
76
+ def usage_on_error = nil
77
+
78
+ def run_add(options, args)
79
+ return usage_error("give NAME and URL") unless args.size == 2
80
+ return usage_error("--token-file and --token-stdin: choose one") if options[:token_file] && options[:token_stdin]
81
+
82
+ name, url = args
83
+ begin
84
+ Remotes.check_name!(name)
85
+ url = Remotes.normalize_url(url)
86
+ rescue Remotes::Invalid => e
87
+ return usage_error(e.message)
88
+ end
89
+ token = read_token(options)
90
+ return CLI::Exit::FAILED if token == false
91
+
92
+ entry = Remotes::Entry.new(name: name, url: url, token: token)
93
+ found = check(entry) or return CLI::Exit::FAILED
94
+ Remotes.put(entry, path: @path)
95
+ @stdout.puts("#{name} #{url} chi #{found["version"]}, #{lan_words(found, entry)}")
96
+ 0
97
+ end
98
+
99
+ def run_ls(options, args)
100
+ return usage_error("ls takes no arguments") unless args.empty?
101
+
102
+ format = options[:format]
103
+ return usage_error("unknown format #{format.inspect}: use --format text|json") unless [nil, "text", "json"].include?(format)
104
+
105
+ entries = Remotes.all(path: @path)
106
+ if format == "json"
107
+ @stdout.puts(JSON.generate(entries.map { |e| { name: e.name, url: e.url, token: !e.token.nil? } }))
108
+ elsif entries.empty?
109
+ @stdout.puts("no remotes (chi remote add NAME URL --token-file FILE)")
110
+ else
111
+ width = entries.map { |e| e.name.length }.max
112
+ entries.each { |e| @stdout.puts("#{e.name.ljust(width)} #{e.url}#{" (no token)" unless e.token}") }
113
+ end
114
+ 0
115
+ end
116
+
117
+ def run_rm(_options, args)
118
+ return usage_error("give one NAME") unless args.size == 1
119
+ raise Remotes::NotFound, args.first unless Remotes.remove(args.first, path: @path)
120
+
121
+ @stdout.puts("#{args.first} removed")
122
+ 0
123
+ end
124
+
125
+ # The token from its file or stdin; nil for none (a loopback chi web
126
+ # needs none), false after the error line.
127
+ def read_token(options)
128
+ text = if options[:token_file]
129
+ begin
130
+ File.read(File.expand_path(options[:token_file]))
131
+ rescue SystemCallError => e
132
+ error_line("#{command_name}: can't read #{options[:token_file]}: #{e.message.sub(/ @ .*/, "")}")
133
+ return false
134
+ end
135
+ elsif options[:token_stdin]
136
+ read_stdin.to_s
137
+ end
138
+ return nil if text.nil?
139
+
140
+ token = text.strip
141
+ return token unless token.empty? || token.match?(/\s/)
142
+
143
+ error_line("#{command_name}: #{options[:token_file] || "stdin"} holds no token (one line, chi web's web-token file)")
144
+ false
145
+ end
146
+
147
+ # GET /api/info with the token: the answer, or nil after the line that
148
+ # says why it can't be used.
149
+ def check(entry)
150
+ reply = @transport.call(entry).call(:get, "/api/info")
151
+ problem = SessionAccess::Remote.info_problem(entry, reply)
152
+ return fail_line(problem.message) if problem
153
+
154
+ reply.json
155
+ rescue SessionAccess::RemoteFailed => e
156
+ fail_line("#{e.message}: start chi web on #{entry.host} with web.host: lan")
157
+ end
158
+
159
+ def lan_words(found, entry)
160
+ return "loopback only" unless found["lan"]
161
+
162
+ found["lan"] == entry.host ? "LAN #{found["lan"]}" : "LAN #{found["lan"]} (reached as #{entry.host})"
163
+ end
164
+
165
+ def fail_line(message)
166
+ error_line("#{command_name}: #{message}")
167
+ nil
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "yaml"
5
+ require "fileutils"
6
+ require_relative "paths"
7
+ require_relative "atomic_file"
8
+
9
+ module Samagotchi
10
+ # chi's remotes: other machines' chi web, each by a name chi --remote NAME
11
+ # (or CHI_REMOTE=NAME) takes. They live in one file only you can read,
12
+ # $XDG_CONFIG_HOME/samagotchi/remotes.yml (0600), their access tokens
13
+ # inside (a token runs commands as you on that machine):
14
+ #
15
+ # remotes:
16
+ # studio:
17
+ # url: http://192.168.1.29:4567
18
+ # token: …
19
+ #
20
+ # chi remote add / ls / rm write and read it (RemoteCommand).
21
+ module Remotes
22
+ FILE = "remotes.yml"
23
+ # A name as git takes a remote's, short: letters, digits, - and _.
24
+ NAME = /\A[A-Za-z0-9][A-Za-z0-9_-]{0,39}\z/
25
+
26
+ # One remote: its +name+, chi web's +url+ (scheme, host and port, no
27
+ # path) and its access +token+ (nil for a loopback chi web, which needs
28
+ # none). #inspect leaves the token out.
29
+ Entry = Data.define(:name, :url, :token) do
30
+ def host = URI(url).host
31
+
32
+ # How a failure names it: "remote studio (http://192.168.1.29:4567)".
33
+ def label = "remote #{name} (#{url})"
34
+
35
+ def inspect = "#<Samagotchi::Remotes::Entry name=#{name.inspect} url=#{url.inspect} token=[redacted]>"
36
+ alias_method :to_s, :inspect
37
+ end
38
+
39
+ # A name or URL chi remote can't take, or a remotes.yml it can't read:
40
+ # the message says which.
41
+ class Invalid < StandardError; end
42
+
43
+ # No remote by that name: the one message chi --remote, CHI_REMOTE and
44
+ # chi remote rm give.
45
+ class NotFound < Invalid
46
+ def initialize(name)
47
+ super("no remote #{name} (chi remote ls lists them; chi remote add NAME URL adds one)")
48
+ end
49
+ end
50
+
51
+ module_function
52
+
53
+ # @return [String] $XDG_CONFIG_HOME/samagotchi/remotes.yml
54
+ def path(env: ENV) = File.join(Paths.config_home(env: env), Paths::APP_DIR, FILE)
55
+
56
+ # @return [Array<Entry>] by name
57
+ # @raise [Invalid] a file that isn't remotes.yml's shape
58
+ def all(path: self.path)
59
+ raw = File.exist?(path) ? YAML.safe_load_file(path) : nil
60
+ remotes = raw.is_a?(Hash) ? raw["remotes"] : nil
61
+ return [] if raw.nil? || (raw.is_a?(Hash) && remotes.nil?)
62
+ raise Invalid, "#{Paths.tilde(path)}: remotes: is a map of names" unless remotes.is_a?(Hash)
63
+
64
+ remotes.map do |name, fields|
65
+ unless fields.is_a?(Hash) && fields["url"].is_a?(String)
66
+ raise Invalid, "#{Paths.tilde(path)}: remote #{name} has no url"
67
+ end
68
+
69
+ Entry.new(name: name.to_s, url: fields["url"], token: fields["token"]&.to_s)
70
+ end.sort_by(&:name)
71
+ rescue Psych::Exception => e
72
+ raise Invalid, "#{Paths.tilde(path)}: #{e.message}"
73
+ end
74
+
75
+ # @return [Entry]
76
+ # @raise [NotFound]
77
+ def find(name, path: self.path)
78
+ all(path: path).find { |entry| entry.name == name } or raise NotFound, name
79
+ end
80
+
81
+ # Saves +entry+, replacing one of its name.
82
+ def put(entry, path: self.path)
83
+ write(all(path: path).reject { |other| other.name == entry.name } + [entry], path)
84
+ end
85
+
86
+ # @return [Boolean] whether there was one
87
+ def remove(name, path: self.path)
88
+ entries = all(path: path)
89
+ kept = entries.reject { |entry| entry.name == name }
90
+ return false if kept.size == entries.size
91
+
92
+ write(kept, path)
93
+ true
94
+ end
95
+
96
+ # @raise [Invalid] unless +name+ is one
97
+ def check_name!(name)
98
+ return name if NAME.match?(name.to_s)
99
+
100
+ raise Invalid, "a remote's name is letters, digits, - and _ (up to 40), not #{name.inspect}"
101
+ end
102
+
103
+ # chi web's address as chi remote add takes it: http(s)://HOST:PORT,
104
+ # the port 4567 (chi web's default) when none is given, no path.
105
+ # @return [String]
106
+ # @raise [Invalid]
107
+ def normalize_url(text)
108
+ text = text.to_s.strip
109
+ text = "http://#{text}" unless text.match?(%r{\A[a-z][a-z0-9+.-]*://}i)
110
+ uri = URI.parse(text)
111
+ unless %w[http https].include?(uri.scheme&.downcase) && uri.host && !uri.host.empty?
112
+ raise Invalid, "give chi web's address, http://HOST:PORT, not #{text}"
113
+ end
114
+ raise Invalid, "give chi web's address alone, http://HOST:PORT, without a path or query" unless ["", "/"].include?(uri.path) && uri.query.nil?
115
+
116
+ port = uri.port == uri.default_port && !text.match?(%r{://[^/]+:\d+}) ? 4567 : uri.port
117
+ host = uri.host.include?(":") && !uri.host.start_with?("[") ? "[#{uri.host}]" : uri.host
118
+ "#{uri.scheme.downcase}://#{host.downcase}:#{port}"
119
+ rescue URI::InvalidURIError
120
+ raise Invalid, "give chi web's address, http://HOST:PORT, not #{text}"
121
+ end
122
+
123
+ def write(entries, path)
124
+ FileUtils.mkdir_p(File.dirname(path))
125
+ remotes = entries.sort_by(&:name).to_h { |entry| [entry.name, { "url" => entry.url, "token" => entry.token }.compact] }
126
+ AtomicFile.write(path, YAML.dump({ "remotes" => remotes }), perm: 0o600)
127
+ end
128
+ private_class_method :write
129
+ end
130
+ end