samagotchi 0.102.0 → 0.104.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 +93 -1
- data/bin/chi +60 -191
- data/docs/architecture.md +67 -1
- data/docs/cli.md +190 -9
- data/docs/configuration.md +7 -1
- data/docs/guardrails.md +4 -2
- data/docs/internals/context-telemetry.md +6 -2
- data/docs/internals/engine.md +5 -2
- data/docs/internals/llm-context-forget.md +11 -1
- data/docs/internals/participants.md +137 -0
- data/docs/internals/remote-sessions.md +429 -0
- data/docs/internals/working-set.md +95 -11
- data/docs/plugins.md +33 -3
- data/docs/sessions.md +16 -7
- data/docs/sub-agent.md +16 -0
- data/docs/testing.md +68 -4
- data/lib/samagotchi/answer_command.rb +27 -25
- data/lib/samagotchi/bridge.rb +31 -7
- data/lib/samagotchi/bridge_client/event_stream.rb +86 -12
- data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
- data/lib/samagotchi/bridge_client.rb +26 -39
- 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 +40 -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/context_status.rb +15 -2
- data/lib/samagotchi/engine.rb +28 -14
- data/lib/samagotchi/guardrail_wiring.rb +7 -1
- data/lib/samagotchi/idle_recap.rb +8 -2
- data/lib/samagotchi/idle_scheduler.rb +34 -9
- data/lib/samagotchi/kernel_loop.rb +21 -2
- data/lib/samagotchi/launch_mode.rb +26 -0
- data/lib/samagotchi/llm/chat_loop.rb +8 -1
- data/lib/samagotchi/llm_context_forget.rb +13 -6
- data/lib/samagotchi/note_command.rb +8 -7
- data/lib/samagotchi/owner_lock.rb +65 -5
- data/lib/samagotchi/parent_report.rb +55 -43
- data/lib/samagotchi/participants.rb +122 -0
- data/lib/samagotchi/remote_attach_command.rb +119 -0
- data/lib/samagotchi/remote_command.rb +170 -0
- data/lib/samagotchi/remotes.rb +130 -0
- data/lib/samagotchi/run_options.rb +245 -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 +38 -9
- data/lib/samagotchi/session_access/contract.rb +167 -0
- data/lib/samagotchi/session_access/dispatch.rb +152 -0
- data/lib/samagotchi/session_access/http_transport.rb +109 -0
- data/lib/samagotchi/session_access/local.rb +400 -0
- data/lib/samagotchi/session_access/remote.rb +734 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +236 -0
- data/lib/samagotchi/session_access.rb +223 -0
- data/lib/samagotchi/session_archive_command.rb +9 -5
- data/lib/samagotchi/session_commands.rb +20 -15
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_delete_command.rb +8 -4
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +27 -6
- data/lib/samagotchi/session_setup.rb +11 -6
- data/lib/samagotchi/sessions_command.rb +36 -14
- data/lib/samagotchi/share_command.rb +174 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +66 -17
- data/lib/samagotchi/terminal_ui/attach_link.rb +114 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +155 -87
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +135 -0
- data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
- data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +28 -3
- data/lib/samagotchi/terminal_ui.rb +6 -3
- data/lib/samagotchi/testing/waiting.rb +92 -9
- data/lib/samagotchi/title_command.rb +9 -11
- data/lib/samagotchi/tools/task_runtime.rb +17 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +530 -126
- data/lib/samagotchi/web/grants.rb +302 -0
- data/lib/samagotchi/web/participant_access.rb +286 -0
- data/lib/samagotchi/web/principal.rb +29 -0
- 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/server.rb +3 -1
- data/lib/samagotchi/web/session_access_routes.rb +459 -0
- data/lib/samagotchi/worker.rb +26 -5
- 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 +28 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "atomic_file"
|
|
7
|
+
require_relative "archive_store"
|
|
8
|
+
require_relative "session"
|
|
9
|
+
require_relative "web/grants"
|
|
10
|
+
|
|
11
|
+
module Samagotchi
|
|
12
|
+
# A session's participants: the people (or agents) its owner invited to
|
|
13
|
+
# review it (`chi share`, the owner's participants routes), each through a
|
|
14
|
+
# Web::Grants grant. Two files in the session dir, neither in the session
|
|
15
|
+
# JSON (chi web never saves that, and a worker would overwrite a field it
|
|
16
|
+
# added):
|
|
17
|
+
# - participants.json, the sidecar: who is or was invited, never a token
|
|
18
|
+
# or a digest. Written by whoever changes a grant (chi web, chi share);
|
|
19
|
+
# the worker reads it (Participants.read; P3's system-prompt line). A
|
|
20
|
+
# session nobody was invited to has none, so its files stay as they were.
|
|
21
|
+
# - participants.jsonl, the audit trail (0600, append-only): grants issued,
|
|
22
|
+
# revoked and expired, a participant's first use and each new peer,
|
|
23
|
+
# every route refused to them.
|
|
24
|
+
# Neither is copied to a continue, a fork or a delegate. See
|
|
25
|
+
# docs/internals/participants.md.
|
|
26
|
+
module Participants
|
|
27
|
+
FILE = "participants.json"
|
|
28
|
+
LOG = "participants.jsonl"
|
|
29
|
+
|
|
30
|
+
# One participant as the sidecar has them. +removed_at+ when the grant
|
|
31
|
+
# was revoked (nil while it works, or once it simply expired:
|
|
32
|
+
# +expires_at+ says when).
|
|
33
|
+
Entry = Data.define(:id, :kind, :name, :role, :added_at, :expires_at, :removed_at) do
|
|
34
|
+
def self.from_h(hash) = new(**members.to_h { |key| [key, hash[key.to_s]] })
|
|
35
|
+
|
|
36
|
+
# Still in the review at +now+.
|
|
37
|
+
def active?(now)
|
|
38
|
+
removed_at.nil? && Time.iso8601(expires_at.to_s) > now
|
|
39
|
+
rescue ArgumentError
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
# @return [Array<Entry>] the session's participants, [] without any
|
|
47
|
+
def read(session_id, state_dir: Session.default_state_dir)
|
|
48
|
+
file = File.join(Session.session_dir(session_id, state_dir: state_dir), FILE)
|
|
49
|
+
return [] unless File.exist?(file)
|
|
50
|
+
|
|
51
|
+
data = JSON.parse(File.read(file))
|
|
52
|
+
Array(data.is_a?(Hash) ? data["participants"] : nil).filter_map { |hash| Entry.from_h(hash) if hash.is_a?(Hash) }
|
|
53
|
+
rescue JSON::ParserError, SystemCallError
|
|
54
|
+
[]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The sidecar rewritten from +grants+ (that session's Web::Grants::Grant
|
|
58
|
+
# list). Nothing is written for a session without grants, nor for one
|
|
59
|
+
# whose dir is gone.
|
|
60
|
+
def write(session_id, grants, state_dir: Session.default_state_dir)
|
|
61
|
+
return if grants.empty?
|
|
62
|
+
|
|
63
|
+
dir = Session.session_dir(session_id, state_dir: state_dir)
|
|
64
|
+
return unless File.directory?(dir)
|
|
65
|
+
|
|
66
|
+
entries = grants.map do |grant|
|
|
67
|
+
{ "id" => grant.id, "kind" => grant.kind, "name" => grant.name, "role" => grant.role,
|
|
68
|
+
"added_at" => grant.created_at, "expires_at" => grant.expires_at, "removed_at" => grant.revoked_at }
|
|
69
|
+
end
|
|
70
|
+
AtomicFile.write(File.join(dir, FILE), "#{JSON.pretty_generate({ "participants" => entries })}\n", perm: 0o600)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# One audit line: {"at", "event", …fields}. Nothing for a session whose
|
|
74
|
+
# dir is gone (deleted, pruned).
|
|
75
|
+
def log(session_id, event, state_dir: Session.default_state_dir, at: Time.now, **fields)
|
|
76
|
+
dir = Session.session_dir(session_id, state_dir: state_dir)
|
|
77
|
+
return unless File.directory?(dir)
|
|
78
|
+
|
|
79
|
+
line = JSON.generate({ "at" => at.utc.iso8601(3), "event" => event.to_s }.merge(fields.transform_keys(&:to_s)))
|
|
80
|
+
File.open(File.join(dir, LOG), File::WRONLY | File::APPEND | File::CREAT, 0o600) { |io| io.write("#{line}\n") }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Invites a participant to +session_id+: the grant (+grants+, a
|
|
84
|
+
# Web::Grants), the sidecar and the audit line.
|
|
85
|
+
# @return [Web::Grants::Issued]
|
|
86
|
+
# @raise [Web::Grants::Invalid] an archived session, or bad values
|
|
87
|
+
# @raise [ArgumentError] no such session
|
|
88
|
+
def invite(grants, session_id:, name:, kind: "human", ttl: nil, state_dir: Session.default_state_dir)
|
|
89
|
+
raise ArgumentError, "Session not found: #{session_id}" unless File.exist?(Session.session_file(session_id, state_dir: state_dir))
|
|
90
|
+
if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: state_dir))
|
|
91
|
+
raise Web::Grants::Invalid, "session #{session_id[0, 8]} is archived: unarchive it to invite anyone"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
issued = grants.issue(session_id: session_id, name: name, kind: kind, **{ ttl: ttl }.compact)
|
|
95
|
+
write(session_id, grants.list(session_id: session_id), state_dir: state_dir)
|
|
96
|
+
grant = issued.grant
|
|
97
|
+
log(session_id, :issued, state_dir: state_dir, participant: grant.id, name: grant.name, kind: grant.kind,
|
|
98
|
+
role: grant.role, expires_at: grant.expires_at)
|
|
99
|
+
issued
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Revokes grants (Web::Grants#revoke's arguments) and brings each
|
|
103
|
+
# affected session's sidecar and audit trail along.
|
|
104
|
+
# @return [Array<Web::Grants::Grant>] the grants it revoked
|
|
105
|
+
def revoke(grants, session_id: nil, id: nil, by: "owner", state_dir: Session.default_state_dir)
|
|
106
|
+
revoked = grants.revoke(session_id: session_id, id: id, by: by)
|
|
107
|
+
revoked.group_by(&:session_id).each do |sid, ended|
|
|
108
|
+
write(sid, grants.list(session_id: sid), state_dir: state_dir)
|
|
109
|
+
ended.each { |grant| log(sid, :revoked, state_dir: state_dir, participant: grant.id, by: by) }
|
|
110
|
+
end
|
|
111
|
+
revoked
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Ends the grants of sessions that were archived or deleted
|
|
115
|
+
# (SessionManager): +by+ "archive" or "delete". Nothing is written for a
|
|
116
|
+
# session nobody was invited to.
|
|
117
|
+
def end_sessions(session_ids, by:, state_dir: Session.default_state_dir)
|
|
118
|
+
grants = Web::Grants.new(Web::Grants.path(state_dir: state_dir))
|
|
119
|
+
session_ids.each { |sid| revoke(grants, session_id: sid, by: by, state_dir: state_dir) }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "session"
|
|
4
|
+
require_relative "session_access"
|
|
5
|
+
require_relative "session_access/remote"
|
|
6
|
+
require_relative "launch_mode"
|
|
7
|
+
require_relative "model_profile"
|
|
8
|
+
require_relative "cli/exit"
|
|
9
|
+
require_relative "terminal_ui"
|
|
10
|
+
require_relative "terminal_ui/attach_launcher"
|
|
11
|
+
require_relative "terminal_ui/remote_connector"
|
|
12
|
+
|
|
13
|
+
module Samagotchi
|
|
14
|
+
# chi --remote NAME --attach ID | --resume ID | --dir DIR [-p TEXT]: the
|
|
15
|
+
# attached TUI on a session of another machine, through its chi web
|
|
16
|
+
# (bin/chi branches here after RunOptions.parse, before its own checks,
|
|
17
|
+
# which are this machine's). The run flags come parsed; the ones that
|
|
18
|
+
# run this machine's REPL or read its files were refused already
|
|
19
|
+
# (SessionAccess::Contract::RUN). The setup flags go as typed: that
|
|
20
|
+
# machine checks them against its own config and bundles.
|
|
21
|
+
class RemoteAttachCommand
|
|
22
|
+
# @param options [Hash] RunOptions.parse's
|
|
23
|
+
# @param access [SessionAccess::Remote]
|
|
24
|
+
def initialize(options, access:, stderr: $stderr, launcher: TerminalUI::AttachLauncher,
|
|
25
|
+
connector: TerminalUI::RemoteConnector)
|
|
26
|
+
@options = options
|
|
27
|
+
@access = access
|
|
28
|
+
@stderr = stderr
|
|
29
|
+
@launcher = launcher
|
|
30
|
+
@connector = connector
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Integer] the exit status: AttachLauncher.exit_status's, 1 a
|
|
34
|
+
# launch that failed, 2 a usage error, 130 a Ctrl-C before the TUI
|
|
35
|
+
# ran (the wake, a wait for a leaving worker there)
|
|
36
|
+
def run
|
|
37
|
+
if (conflict = LaunchMode.attached_conflict(@options))
|
|
38
|
+
return failed(conflict)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
existing = @options[:attach] || @options[:resume]
|
|
42
|
+
return usage(new_needs_dir) if !existing && !absolute_dir?
|
|
43
|
+
|
|
44
|
+
@access.require_feature!(SessionAccess::Remote::ATTACH_FEATURE)
|
|
45
|
+
id = existing ? @access.resolve(existing) : create
|
|
46
|
+
return id if id.is_a?(Integer)
|
|
47
|
+
|
|
48
|
+
ignored(id) if existing
|
|
49
|
+
launch(id, existing: existing)
|
|
50
|
+
rescue TerminalUI::AttachLauncher::Error, SessionAccess::RemoteFailed, SessionAccess::Refused,
|
|
51
|
+
SessionManager::OwnedByTUI, ModelProfile::MissingModel, ArgumentError => e
|
|
52
|
+
e.is_a?(SessionAccess::BadRequest) ? usage(e.message) : failed(e.message)
|
|
53
|
+
rescue Interrupt
|
|
54
|
+
CLI::Exit::INTERRUPTED
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def launch(id, existing:)
|
|
60
|
+
model = @options[:model]
|
|
61
|
+
ended = @launcher.run(attach: (id if @options[:attach]), resume: (id if @options[:resume]), prompt: @options[:prompt],
|
|
62
|
+
model: (model if existing), no_interrupt: !!@options[:no_interrupt],
|
|
63
|
+
default_input: !@options[:no_default_input],
|
|
64
|
+
llm_context: (existing && @options[:llm_context]) || {},
|
|
65
|
+
thinking: (@options[:thinking] if existing),
|
|
66
|
+
connector: @connector.new(@access, session_id: id))
|
|
67
|
+
@launcher.exit_status(ended)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A new session there, idle (its -p goes in once attached), as chi send
|
|
71
|
+
# --new makes one: the create's warnings printed as it prints them.
|
|
72
|
+
# @return [String, Integer] its id, or the exit status of a refusal
|
|
73
|
+
def create
|
|
74
|
+
settings = Array(@options[:sets]).to_h do |pair|
|
|
75
|
+
key, sep, value = pair.partition("=")
|
|
76
|
+
return usage("--set takes KEY=VALUE, not #{pair}") if sep.empty? || key.strip.empty?
|
|
77
|
+
|
|
78
|
+
[key.strip, value]
|
|
79
|
+
end
|
|
80
|
+
llm = @options[:llm_context] || {}
|
|
81
|
+
words = SessionAccess::SetupWords.new(
|
|
82
|
+
llm_context: (llm unless llm.empty?), thinking: @options[:thinking]&.to_s, toolkit: @options[:toolkit],
|
|
83
|
+
tools: Array(@options[:tools]), no_tools: Array(@options[:no_tools]),
|
|
84
|
+
settings: (settings unless settings.empty?), parent_answers: @options[:parent_answers]
|
|
85
|
+
)
|
|
86
|
+
created = @access.create(SessionAccess::NewSession.new(prompt: nil, idle: true, working_directory: @options[:dir],
|
|
87
|
+
model_name: @options[:model], setup: words,
|
|
88
|
+
client_id: @connector.client_id))
|
|
89
|
+
@stderr.puts("chi: warning: #{created.model_warning}") if created.model_warning
|
|
90
|
+
@stderr.puts("chi: warning: --parent-answers #{created.parent_answers_warning}") if created.parent_answers_warning
|
|
91
|
+
created.id
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# An existing session keeps its own setup: the line says which flags
|
|
95
|
+
# went unused, as chi --attach here says it.
|
|
96
|
+
def ignored(id)
|
|
97
|
+
flags = [("--dir" if @options[:dir]), ("--toolkit" if @options[:toolkit]), ("--tool" if @options[:tools]),
|
|
98
|
+
("--no-tool" if @options[:no_tools]), ("--set" if @options[:sets]),
|
|
99
|
+
("--parent-answers" if @options[:parent_answers])].compact
|
|
100
|
+
TerminalUI::AttachLauncher.warn_memory_flags_ignored(id, [], [], @stderr, toolkit_flags: flags)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def absolute_dir? = @options[:dir].to_s.start_with?("/")
|
|
104
|
+
|
|
105
|
+
def new_needs_dir
|
|
106
|
+
"a new session on #{@access.name} needs --dir, that machine's absolute path (or --attach ID / --resume ID)"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def failed(message)
|
|
110
|
+
@stderr.puts("Error: #{message}")
|
|
111
|
+
CLI::Exit::FAILED
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def usage(message)
|
|
115
|
+
@stderr.puts("Error: #{message}")
|
|
116
|
+
CLI::Exit::USAGE
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -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
|