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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -1
  3. data/bin/chi +60 -191
  4. data/docs/architecture.md +67 -1
  5. data/docs/cli.md +190 -9
  6. data/docs/configuration.md +7 -1
  7. data/docs/guardrails.md +4 -2
  8. data/docs/internals/context-telemetry.md +6 -2
  9. data/docs/internals/engine.md +5 -2
  10. data/docs/internals/llm-context-forget.md +11 -1
  11. data/docs/internals/participants.md +137 -0
  12. data/docs/internals/remote-sessions.md +429 -0
  13. data/docs/internals/working-set.md +95 -11
  14. data/docs/plugins.md +33 -3
  15. data/docs/sessions.md +16 -7
  16. data/docs/sub-agent.md +16 -0
  17. data/docs/testing.md +68 -4
  18. data/lib/samagotchi/answer_command.rb +27 -25
  19. data/lib/samagotchi/bridge.rb +31 -7
  20. data/lib/samagotchi/bridge_client/event_stream.rb +86 -12
  21. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  22. data/lib/samagotchi/bridge_client.rb +26 -39
  23. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  25. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  27. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  28. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  30. data/lib/samagotchi/chi_invocation.rb +40 -0
  31. data/lib/samagotchi/cli/command.rb +3 -2
  32. data/lib/samagotchi/cli/flags.rb +5 -0
  33. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  34. data/lib/samagotchi/context_command.rb +80 -73
  35. data/lib/samagotchi/context_status.rb +15 -2
  36. data/lib/samagotchi/engine.rb +28 -14
  37. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  38. data/lib/samagotchi/idle_recap.rb +8 -2
  39. data/lib/samagotchi/idle_scheduler.rb +34 -9
  40. data/lib/samagotchi/kernel_loop.rb +21 -2
  41. data/lib/samagotchi/launch_mode.rb +26 -0
  42. data/lib/samagotchi/llm/chat_loop.rb +8 -1
  43. data/lib/samagotchi/llm_context_forget.rb +13 -6
  44. data/lib/samagotchi/note_command.rb +8 -7
  45. data/lib/samagotchi/owner_lock.rb +65 -5
  46. data/lib/samagotchi/parent_report.rb +55 -43
  47. data/lib/samagotchi/participants.rb +122 -0
  48. data/lib/samagotchi/remote_attach_command.rb +119 -0
  49. data/lib/samagotchi/remote_command.rb +170 -0
  50. data/lib/samagotchi/remotes.rb +130 -0
  51. data/lib/samagotchi/run_options.rb +245 -0
  52. data/lib/samagotchi/send_command/busy.rb +58 -0
  53. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  54. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  55. data/lib/samagotchi/send_command.rb +173 -108
  56. data/lib/samagotchi/session.rb +38 -9
  57. data/lib/samagotchi/session_access/contract.rb +167 -0
  58. data/lib/samagotchi/session_access/dispatch.rb +152 -0
  59. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  60. data/lib/samagotchi/session_access/local.rb +400 -0
  61. data/lib/samagotchi/session_access/remote.rb +734 -0
  62. data/lib/samagotchi/session_access/web_bridge_client.rb +236 -0
  63. data/lib/samagotchi/session_access.rb +223 -0
  64. data/lib/samagotchi/session_archive_command.rb +9 -5
  65. data/lib/samagotchi/session_commands.rb +20 -15
  66. data/lib/samagotchi/session_continue.rb +1 -1
  67. data/lib/samagotchi/session_delete_command.rb +8 -4
  68. data/lib/samagotchi/session_inbox.rb +18 -9
  69. data/lib/samagotchi/session_manager.rb +27 -6
  70. data/lib/samagotchi/session_setup.rb +11 -6
  71. data/lib/samagotchi/sessions_command.rb +36 -14
  72. data/lib/samagotchi/share_command.rb +174 -0
  73. data/lib/samagotchi/terminal_ui/attach_launcher.rb +66 -17
  74. data/lib/samagotchi/terminal_ui/attach_link.rb +114 -0
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +155 -87
  76. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  77. data/lib/samagotchi/terminal_ui/local_link.rb +135 -0
  78. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  79. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  80. data/lib/samagotchi/terminal_ui/version_lines.rb +28 -3
  81. data/lib/samagotchi/terminal_ui.rb +6 -3
  82. data/lib/samagotchi/testing/waiting.rb +92 -9
  83. data/lib/samagotchi/title_command.rb +9 -11
  84. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +530 -126
  87. data/lib/samagotchi/web/grants.rb +302 -0
  88. data/lib/samagotchi/web/participant_access.rb +286 -0
  89. data/lib/samagotchi/web/principal.rb +29 -0
  90. data/lib/samagotchi/web/public/data.js +3 -2
  91. data/lib/samagotchi/web/public/index.html +20 -4
  92. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  93. data/lib/samagotchi/web/public/stage_view.js +3 -2
  94. data/lib/samagotchi/web/public/stream_handlers.js +3 -1
  95. data/lib/samagotchi/web/public/working_set.js +159 -28
  96. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  97. data/lib/samagotchi/web/public/working_set_view.js +91 -33
  98. data/lib/samagotchi/web/server.rb +3 -1
  99. data/lib/samagotchi/web/session_access_routes.rb +459 -0
  100. data/lib/samagotchi/worker.rb +26 -5
  101. data/lib/samagotchi/worker_sidecar.rb +12 -5
  102. data/lib/samagotchi/working_set.rb +88 -14
  103. data/lib/samagotchi/working_set_command.rb +133 -30
  104. data/lib/samagotchi/working_set_pin.rb +34 -0
  105. data/lib/samagotchi/working_set_steer.rb +191 -27
  106. 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