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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -1
- data/bin/chi +23 -191
- data/docs/architecture.md +54 -0
- data/docs/cli.md +114 -7
- data/docs/configuration.md +7 -1
- data/docs/guardrails.md +2 -1
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-forget.md +11 -1
- data/docs/internals/remote-sessions.md +303 -0
- data/docs/internals/working-set.md +95 -11
- data/docs/plugins.md +26 -2
- data/docs/sessions.md +12 -5
- data/docs/sub-agent.md +8 -0
- data/lib/samagotchi/answer_command.rb +27 -25
- data/lib/samagotchi/bridge.rb +24 -4
- data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
- data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
- data/lib/samagotchi/bridge_client.rb +22 -38
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +43 -0
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/flags.rb +5 -0
- data/lib/samagotchi/cli/parent_wait.rb +22 -13
- data/lib/samagotchi/context_command.rb +80 -73
- data/lib/samagotchi/engine.rb +12 -8
- data/lib/samagotchi/idle_recap.rb +7 -1
- data/lib/samagotchi/kernel_loop.rb +13 -1
- data/lib/samagotchi/launch_mode.rb +22 -0
- data/lib/samagotchi/llm_context_forget.rb +13 -6
- data/lib/samagotchi/note_command.rb +8 -7
- data/lib/samagotchi/parent_report.rb +55 -43
- data/lib/samagotchi/remote_command.rb +170 -0
- data/lib/samagotchi/remotes.rb +130 -0
- data/lib/samagotchi/run_options.rb +233 -0
- data/lib/samagotchi/send_command/busy.rb +58 -0
- data/lib/samagotchi/send_command/parent_gate.rb +23 -10
- data/lib/samagotchi/send_command/wait_target.rb +16 -3
- data/lib/samagotchi/send_command.rb +173 -108
- data/lib/samagotchi/session.rb +30 -5
- data/lib/samagotchi/session_access/contract.rb +129 -0
- data/lib/samagotchi/session_access/dispatch.rb +133 -0
- data/lib/samagotchi/session_access/http_transport.rb +109 -0
- data/lib/samagotchi/session_access/local.rb +349 -0
- data/lib/samagotchi/session_access/remote.rb +638 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
- data/lib/samagotchi/session_access.rb +141 -0
- data/lib/samagotchi/session_archive_command.rb +9 -5
- data/lib/samagotchi/session_commands.rb +20 -15
- data/lib/samagotchi/session_delete_command.rb +8 -4
- data/lib/samagotchi/session_manager.rb +7 -2
- data/lib/samagotchi/session_setup.rb +11 -6
- data/lib/samagotchi/sessions_command.rb +36 -14
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +51 -15
- data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +80 -75
- data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
- data/lib/samagotchi/terminal_ui.rb +2 -2
- data/lib/samagotchi/title_command.rb +9 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +360 -69
- data/lib/samagotchi/web/public/data.js +3 -2
- data/lib/samagotchi/web/public/index.html +20 -4
- data/lib/samagotchi/web/public/stage_frame.js +7 -2
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/stream_handlers.js +3 -1
- data/lib/samagotchi/web/public/working_set.js +159 -28
- data/lib/samagotchi/web/public/working_set_rail.js +39 -12
- data/lib/samagotchi/web/public/working_set_view.js +91 -33
- data/lib/samagotchi/web/session_access_routes.rb +343 -0
- data/lib/samagotchi/worker_sidecar.rb +12 -5
- data/lib/samagotchi/working_set.rb +88 -14
- data/lib/samagotchi/working_set_command.rb +133 -30
- data/lib/samagotchi/working_set_pin.rb +34 -0
- data/lib/samagotchi/working_set_steer.rb +191 -27
- 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
|
-
|
|
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
|
|
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
|
|
182
|
-
return stop_command(session_id, question) if continue?(question) && !may_continue?(question)
|
|
183
|
-
return
|
|
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
|
|
192
|
+
return answer_command(session_id, question, option_arg(only.first), via: via) if only.size == 1
|
|
188
193
|
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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: #{
|
|
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)}; #{
|
|
292
|
-
when :error then "the worker failed: #{result.text}; #{
|
|
293
|
-
when :worker_gone then "the worker is gone; #{
|
|
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 = "
|
|
297
|
-
hint = wait_again ||
|
|
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:
|
|
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:
|
|
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};
|
|
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
|