asterism 0.7.1 → 0.9.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.
@@ -0,0 +1,701 @@
1
+ # The command line of the gem (exe/asterism): look at an Asterism network
2
+ # from CRuby, and call an object when asked to. Its commands are a noun and
3
+ # a verb, as ros2's are:
4
+ #
5
+ # asterism node list --router tcp/192.0.2.2:7447
6
+ # asterism node info games --credentials admin.tar
7
+ # asterism app list / app info games/games
8
+ # asterism object list 'games/games/*' / object info games/games/othello1
9
+ # asterism object find join # the objects that have join
10
+ # asterism status get games/games # echo: as it changes, hz: how often
11
+ # asterism call games/games/othello1 view '"<token>"'
12
+ # asterism ping / watch / tree / mcp # as before
13
+ # asterism completion bash # the shell's Tab completion
14
+ #
15
+ # The older commands (nodes, objects, echo) stay, with the same output.
16
+ # `-o json` / `-o yaml` (and --json, as before) on every command that reads.
17
+ #
18
+ # It is one more Asterism node: it connects with Asterism.connect (as the
19
+ # certificate's name with --credentials, as cli-<6 hex digits> otherwise)
20
+ # and reads what docs/protocol.md lets anyone read. It calls a method only
21
+ # with `call` (and the MCP bridge only the methods --allow names). What it
22
+ # can see is what the router lets it see: with a console's .tar, its role.
23
+ require "asterism"
24
+ require "optparse"
25
+ require "json"
26
+ require "securerandom"
27
+ require_relative "cli/literal"
28
+ require_relative "cli/survey"
29
+ require_relative "cli/allow"
30
+ require_relative "cli/text"
31
+ require_relative "cli/hz"
32
+ require_relative "cli/complete"
33
+
34
+ module Asterism
35
+ class CLI
36
+ # The nouns and their verbs: verb => [its arguments, what it does].
37
+ NOUNS = {
38
+ "node" => {
39
+ "list" => ["", "the nodes, a name a line"],
40
+ "info" => ["NODE", "one node: its profile, applications (with their statuses) and objects"]
41
+ },
42
+ "app" => {
43
+ "list" => ["", "the applications, node/app a line"],
44
+ "info" => ["NODE/APP", "one application: its status and its objects"]
45
+ },
46
+ "object" => {
47
+ "list" => ["[PATTERN]", "the objects, node/app/object a line (PATTERN: * and ** for whole parts)"],
48
+ "info" => ["PATH", "one object (node/app/object): its description and its methods"],
49
+ "find" => ["METHOD", "the objects that have the method (* as a wildcard)"]
50
+ },
51
+ "status" => {
52
+ "get" => ["NODE/APP", "an application's status, now"],
53
+ "echo" => ["NODE/APP", "its status, then each change as it comes (--once: now, once)"],
54
+ "hz" => ["NODE/APP", "how often it changes: the rate, the intervals' min / max / std dev"]
55
+ }
56
+ }.freeze
57
+
58
+ # The commands without a noun: name => [its arguments, what it does].
59
+ COMMANDS = {
60
+ "call" => ["PATH METHOD [ARG...]", "calls a method; each ARG is JSON, else a Ruby literal, else text"],
61
+ "ping" => ["PATH", "round trips: PATH node/app/object (its meta) or node (its profile); " \
62
+ "--call METHOD [ARG...] times a call instead"],
63
+ "watch" => ["", "nodes, applications and objects coming and going, and status changes"],
64
+ "tree" => ["", "nodes > applications (with their status) > objects (--methods: their methods)"],
65
+ "mcp" => ["", "an MCP server on stdio (needs the mcp gem): the listings as tools, and the methods " \
66
+ "--allow names"],
67
+ "completion" => ["bash|zsh", "the shell's Tab completion: source <(asterism completion bash)"],
68
+ "version" => ["", "the gem's version"]
69
+ }.freeze
70
+
71
+ # The older names, kept with their output.
72
+ ALIASES = {
73
+ "nodes" => "the nodes, their applications (with their statuses) and profiles, in one listing",
74
+ "objects" => "[PATTERN] the objects with their descriptions and methods (object info of each)",
75
+ "echo" => "ping"
76
+ }.freeze
77
+
78
+ # The options that take a value (what __complete skips over).
79
+ VALUE_OPTIONS = %w[--router --credentials --peer --node --app -o --output --timeout --settle --duration --count
80
+ --interval --call --kwargs --allow --period --window].freeze
81
+
82
+ def self.usage
83
+ col = 30
84
+ line = lambda do |name, args, what|
85
+ Text.wrap_text(what, 80, first: " #{"#{name} #{args}".strip.ljust(col - 3)} ").join("\n")
86
+ end
87
+ nouns = NOUNS.flat_map { |n, verbs| verbs.map { |v, (a, w)| line.("#{n} #{v}", a, w) } }
88
+ cmds = COMMANDS.map { |c, (a, w)| line.(c, a, w) }
89
+ <<~TEXT
90
+ Usage: asterism NOUN VERB [ARG...] [options] (asterism NOUN: its verbs)
91
+
92
+ Commands (they only read, except call):
93
+ #{nouns.join("\n")}
94
+
95
+ #{cmds.join("\n")}
96
+
97
+ Where the network is (one of them):
98
+ --router LOCATOR a router without TLS, e.g. tcp/192.0.2.2:7447
99
+ --credentials TAR a console's .tar (mutual TLS; its router and name)
100
+ With none of --router, --credentials, --peer, the environment's
101
+ ASTERISM_CREDENTIALS and ASTERISM_ROUTER are used instead.
102
+
103
+ Output: text; -o json or -o yaml (the same content) on the commands that read
104
+ (--json is -o json). On a terminal the text has colours (NO_COLOR=1: none) and
105
+ the terminal's width; otherwise it is plain, 80 columns wide (or COLUMNS).
106
+
107
+ Older names, kept: nodes (all the nodes in one listing), objects [PATTERN]
108
+ (object info of each), echo (ping).
109
+ TEXT
110
+ end
111
+
112
+ USAGE = usage
113
+
114
+ # The verbs of a noun, for `asterism NOUN`.
115
+ def self.noun_usage(noun)
116
+ verbs = NOUNS.fetch(noun).map do |v, (a, w)|
117
+ Text.wrap_text(w, 80, first: " #{"#{noun} #{v} #{a}".strip.ljust(27)} ").join("\n")
118
+ end
119
+ "Usage: asterism #{noun} VERB [ARG...] [options]\n\n#{verbs.join("\n")}\n\n" \
120
+ "asterism #{noun} VERB --help: its options; asterism --help: every command\n"
121
+ end
122
+
123
+ def self.start(argv, out: $stdout, err: $stderr, env: ENV)
124
+ new(out: out, err: err, env: env).run(argv.dup)
125
+ end
126
+
127
+ def initialize(out: $stdout, err: $stderr, env: ENV)
128
+ @out = out
129
+ @err = err
130
+ @env = env
131
+ @lock = Mutex.new
132
+ @last_status = {}
133
+ @opts = { timeout: 2.0, settle: 1.5, format: nil, count: 5, interval: 0.2, allow: [], period: 1.0,
134
+ window: 1000 }
135
+ end
136
+
137
+ def run(argv)
138
+ cmd = argv.shift
139
+ case cmd
140
+ when nil, "help", "-h", "--help"
141
+ if cmd == "help" && NOUNS.key?(argv.first)
142
+ @out.puts CLI.noun_usage(argv.first)
143
+ return 0
144
+ end
145
+ @out.puts USAGE
146
+ return cmd.nil? ? 2 : 0
147
+ when "version", "--version", "-v"
148
+ @out.puts "asterism #{::Asterism::VERSION}"
149
+ return 0
150
+ when "__complete"
151
+ return cmd_complete(argv)
152
+ end
153
+ key = cmd
154
+ if NOUNS.key?(cmd)
155
+ verb = argv.first
156
+ if verb.nil? || %w[-h --help].include?(verb) || verb.start_with?("-")
157
+ @out.puts CLI.noun_usage(cmd)
158
+ return %w[-h --help].include?(verb) ? 0 : 2
159
+ end
160
+ unless NOUNS[cmd].key?(verb)
161
+ @err.puts "asterism: #{cmd} has no verb #{verb.inspect}\n\n#{CLI.noun_usage(cmd)}"
162
+ return 2
163
+ end
164
+ argv.shift
165
+ key = "#{cmd} #{verb}"
166
+ elsif !(COMMANDS.key?(cmd) || ALIASES.key?(cmd))
167
+ @err.puts "asterism: unknown command #{cmd.inspect}\n\n#{USAGE}"
168
+ return 2
169
+ end
170
+ rest = parser(key).parse(argv)
171
+ send("cmd_#{key == 'echo' ? 'ping' : key.tr(' ', '_')}", rest)
172
+ rescue OptionParser::ParseError, ArgumentError => e
173
+ @err.puts "asterism: #{e.message}"
174
+ 2
175
+ rescue ::Asterism::Disconnected => e
176
+ @err.puts "asterism: cannot reach the network: #{e.message}"
177
+ 1
178
+ rescue ::Asterism::Error => e
179
+ @err.puts "asterism: #{e.class}: #{e.message}"
180
+ 1
181
+ rescue Interrupt
182
+ 0
183
+ end
184
+
185
+ # The options of a command (key: "tree", "node info", ...).
186
+ def parser(key)
187
+ OptionParser.new do |o|
188
+ args = (NOUNS.dig(*key.split(" ")) || COMMANDS[key] || [""])[0]
189
+ o.banner = "Usage: asterism #{key} #{args} [options]".squeeze(" ")
190
+ unless key == "completion"
191
+ o.on("--router LOCATOR", "a router (tcp/HOST:7447)") { @opts[:router] = _1 }
192
+ o.on("--credentials TAR", "a console's .tar") { @opts[:credentials] = _1 }
193
+ o.on("--peer LOCATOR", "a direct peer link to a node listening there (no router)") { @opts[:peer] = _1 }
194
+ o.on("--node NAME", "this node's ID (default: the certificate's name, or cli-<6 hex>)") { @opts[:node] = _1 }
195
+ o.on("--app NAME", "this application's name (default: cli-<4 hex>)") { @opts[:app] = _1 }
196
+ unless key == "mcp"
197
+ o.on("-o", "--output FORMAT", %w[json yaml], "json or yaml instead of text (the same content)") do
198
+ @opts[:format] = _1.to_sym
199
+ end
200
+ o.on("--json", "JSON instead of text (-o json)") { @opts[:format] = :json }
201
+ end
202
+ o.on("--timeout SECONDS", Float, "time limit of each get or call (2.0)") { @opts[:timeout] = _1 }
203
+ o.on("--settle SECONDS", Float, "how long to wait for the tokens at connect (1.5)") { @opts[:settle] = _1 }
204
+ end
205
+ case key
206
+ when "tree"
207
+ o.on("--methods", "list each object's methods (else how many)") { @opts[:methods] = true }
208
+ when "watch"
209
+ o.on("--duration SECONDS", Float, "stop after this long (default: until Ctrl-C)") { @opts[:duration] = _1 }
210
+ when "ping", "echo"
211
+ o.on("--count N", Integer, "round trips (5)") { @opts[:count] = _1 }
212
+ o.on("--interval SECONDS", Float, "pause between them (0.2)") { @opts[:interval] = _1 }
213
+ o.on("--call METHOD", "time a call of METHOD (with the ARGs after PATH) instead of the meta") { @opts[:call] = _1 }
214
+ when "call"
215
+ o.on("--kwargs VALUE", "keyword arguments: a JSON object or a Ruby Hash literal") { @opts[:kwargs] = _1 }
216
+ when "mcp"
217
+ o.on("--allow PATTERN", "methods the MCP client may call: [node/][app/]object.method, * as a wildcard " \
218
+ "(repeatable)") { @opts[:allow] << _1 }
219
+ when "status echo"
220
+ o.on("--once", "the status now, once, then stop") { @opts[:once] = true }
221
+ o.on("--duration SECONDS", Float, "stop after this long (default: until Ctrl-C)") { @opts[:duration] = _1 }
222
+ when "status hz"
223
+ o.on("--period SECONDS", Float, "how often to print (1.0)") { @opts[:period] = _1 }
224
+ o.on("--window N", Integer, "how many of the last intervals count (1000)") { @opts[:window] = _1 }
225
+ o.on("--duration SECONDS", Float, "stop after this long (default: until Ctrl-C)") { @opts[:duration] = _1 }
226
+ end
227
+ end
228
+ end
229
+
230
+ # ------------------------------------------------------------ the tables, for __complete
231
+
232
+ def noun?(w)
233
+ NOUNS.key?(w)
234
+ end
235
+
236
+ def nouns
237
+ NOUNS.keys
238
+ end
239
+
240
+ def verbs(noun)
241
+ NOUNS.fetch(noun).keys
242
+ end
243
+
244
+ def command_words
245
+ NOUNS.keys + COMMANDS.keys + ALIASES.keys + ["help"]
246
+ end
247
+
248
+ # [key, its arguments] of the positional words (nil when they name no
249
+ # command).
250
+ def command_key(pos)
251
+ return [nil, []] if pos.empty?
252
+ if NOUNS.key?(pos[0])
253
+ return [nil, []] unless pos[1] && NOUNS[pos[0]].key?(pos[1])
254
+ return ["#{pos[0]} #{pos[1]}", pos[2..]]
255
+ end
256
+ return [pos[0], pos[1..]] if COMMANDS.key?(pos[0]) || ALIASES.key?(pos[0]) || pos[0] == "help"
257
+ [nil, []]
258
+ end
259
+
260
+ def option_takes_value?(w)
261
+ VALUE_OPTIONS.include?(w)
262
+ end
263
+
264
+ def options_for(key)
265
+ return [] if key == "help"
266
+ parser(key).top.list.flat_map { |sw| sw.respond_to?(:long) ? sw.long + sw.short : [] } + ["--help"]
267
+ end
268
+
269
+ # ------------------------------------------------------------ connecting
270
+
271
+ # [router, credentials, peer]: the command line's, or, when it names
272
+ # none of them, ASTERISM_ROUTER and ASTERISM_CREDENTIALS from the
273
+ # environment (all or nothing, so a router given on the line never meets
274
+ # a .tar from the environment).
275
+ def network(env = @env)
276
+ given = [@opts[:router], @opts[:credentials], @opts[:peer]]
277
+ return given if given.any?
278
+
279
+ router = env["ASTERISM_ROUTER"].to_s
280
+ creds = env["ASTERISM_CREDENTIALS"].to_s
281
+ [router.empty? ? nil : router, creds.empty? ? nil : creds, nil]
282
+ end
283
+
284
+ # Connects, starts the receiving thread, waits for the tokens and yields
285
+ # the Net and a Survey.
286
+ def connect(app_prefix: "cli")
287
+ router, creds, peer = network
288
+ if router.nil? && creds.nil? && peer.nil?
289
+ raise ArgumentError, "where is the network? --router tcp/HOST:7447 or --credentials FILE.tar " \
290
+ "(or ASTERISM_CREDENTIALS / ASTERISM_ROUTER)"
291
+ end
292
+ node = @opts[:node]
293
+ node ||= "#{app_prefix}-#{SecureRandom.hex(3)}" unless creds
294
+ app = @opts[:app] || "#{app_prefix}-#{SecureRandom.hex(2)}"
295
+ kw = { node: node, app: app }
296
+ kw[:credentials] = creds if creds
297
+ locator = router
298
+ if peer
299
+ locator = peer
300
+ kw[:mode] = :peer
301
+ end
302
+ ::Asterism.profile(name: app_prefix == "mcp" ? "asterism MCP" : "asterism CLI")
303
+ ::Asterism.connect(locator, **kw) do |net|
304
+ net.on_error { |e, where| @err.puts "asterism: #{where}: #{e.class}: #{e.message}" }
305
+ net.start
306
+ survey = Survey.new(timeout: @opts[:timeout])
307
+ survey.settle(@opts[:settle])
308
+ yield net, survey
309
+ end
310
+ end
311
+
312
+ # -o json or -o yaml (or --json) given: the data instead of the text.
313
+ def structured?
314
+ !@opts[:format].nil?
315
+ end
316
+
317
+ # The data as -o asks: JSON (pretty, as --json always was) or YAML (the
318
+ # same content: what the JSON holds, as YAML).
319
+ def emit(v)
320
+ @opts[:format] == :yaml ? @out.write(yaml(v)) : @out.puts(JSON.pretty_generate(v))
321
+ end
322
+
323
+ # One record of a stream (watch, status echo / hz, call): a JSON line,
324
+ # or a YAML document ("---" before each).
325
+ def emit_line(v)
326
+ @opts[:format] == :yaml ? @out.write(yaml(v)) : @out.puts(JSON.generate(v))
327
+ end
328
+
329
+ def yaml(v)
330
+ require "yaml"
331
+ YAML.dump(JSON.parse(JSON.generate(v)))
332
+ end
333
+
334
+ # ------------------------------------------------------------ nodes
335
+
336
+ def cmd_nodes(_rest)
337
+ connect do |_net, sv|
338
+ tree = sv.tree(meta: false)
339
+ if structured?
340
+ emit(tree.map { |n| n.merge("apps" => n["apps"].map { |a| a.except("objects") }) })
341
+ elsif tree.empty?
342
+ @out.puts "(no other node is visible)"
343
+ else
344
+ tree.each do |n|
345
+ @out.puts node_line(n)
346
+ n["apps"].each do |a|
347
+ k = a["objects"].size
348
+ @out.puts " #{paint(a['app'], :app)}#{paint(" #{count(k, 'object')}", :note) unless k.zero?}"
349
+ status_block(a, 4)
350
+ end
351
+ end
352
+ end
353
+ end
354
+ 0
355
+ end
356
+
357
+ # ------------------------------------------------------------ objects
358
+
359
+ def cmd_objects(rest)
360
+ pattern = rest.shift || "**"
361
+ connect do |_net, sv|
362
+ paths = sv.objects(pattern)
363
+ metas = sv.metas(paths)
364
+ if structured?
365
+ emit(paths.map do |p|
366
+ h = { "path" => p, "methods" => metas[p] }
367
+ h["doc"] = sv.object_doc(p) if sv.object_doc(p)
368
+ h
369
+ end)
370
+ elsif paths.empty?
371
+ @out.puts "(no object matches #{pattern})"
372
+ else
373
+ paths.each { |p| object_entry(p, metas[p], sv.object_doc(p)) }
374
+ end
375
+ end
376
+ 0
377
+ end
378
+
379
+ # ------------------------------------------------------------ tree
380
+
381
+ def cmd_tree(_rest)
382
+ connect do |_net, sv|
383
+ tree = sv.tree
384
+ if structured?
385
+ emit(tree)
386
+ elsif tree.empty?
387
+ @out.puts "(no other node is visible)"
388
+ else
389
+ tree.each do |n|
390
+ @out.puts node_line(n)
391
+ n["apps"].each do |a|
392
+ @out.puts " #{paint(a['app'], :app)}"
393
+ status_block(a, 4)
394
+ a["objects"].each do |o|
395
+ ms = o["methods"]
396
+ @out.puts " #{paint(o['name'], :object)} #{paint(ms ? count(ms.size, 'method') : '(no meta)', :note)}"
397
+ method_list(ms, 6) if ms && @opts[:methods] && !ms.empty?
398
+ end
399
+ end
400
+ end
401
+ end
402
+ end
403
+ 0
404
+ end
405
+
406
+ # ------------------------------------------------------------ watch
407
+
408
+ # Prints the changes until Ctrl-C (or --duration): nodes (Asterism's
409
+ # on_join / on_leave), applications and objects (their liveliness
410
+ # tokens), statuses (their puts) and profiles (their puts). The
411
+ # statuses there at the start come first.
412
+ def cmd_watch(_rest)
413
+ connect do |net, sv|
414
+ own = "#{net.node_id}/#{net.app}"
415
+ sv.statuses.sort.each { |path, st| event("status", path, status: st, now: true) }
416
+ net.on_join { |n| event("join", n, what: "node") }
417
+ net.on_leave { |n| event("leave", n, what: "node") }
418
+ net.session.liveliness_watch("asterism/**") do |key, alive|
419
+ # The tokens alive at the start come first, in one burst: not news.
420
+ next unless @watch_started
421
+ path = key.delete_prefix("asterism/")
422
+ next if path == own || path.start_with?("#{own}/")
423
+ what = { 2 => "app", 3 => "object" }[path.count("/") + 1]
424
+ event(alive ? "join" : "leave", path, what: what) if what
425
+ end
426
+ net.session.subscribe(Survey::STATUS_ALL) do |sample|
427
+ got = ::Asterism::Status.parse(sample.key, sample.payload)
428
+ event("status", "#{got[0]}/#{got[1]}", status: got[2]) if got && "#{got[0]}/#{got[1]}" != own
429
+ end
430
+ net.session.subscribe(Survey::PROFILES_ALL) do |sample|
431
+ node = sample.key.split("/")[1]
432
+ pr = ::Asterism::Profile.check(::Asterism::Codec.unpack(sample.payload))
433
+ event("profile", node, profile: pr) if pr && node != net.node_id
434
+ end
435
+ sleep 0.3
436
+ @watch_started = true
437
+ @err.puts "asterism: watching (Ctrl-C to stop)" unless structured?
438
+ deadline = @opts[:duration] && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + @opts[:duration])
439
+ loop do
440
+ break if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
441
+ break unless net.connected?
442
+ sleep 0.05
443
+ end
444
+ end
445
+ 0
446
+ end
447
+
448
+ def event(kind, path, what: nil, status: nil, profile: nil, now: false)
449
+ t = Time.now
450
+ if structured?
451
+ h = { "t" => t.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"), "event" => kind, "path" => path }
452
+ h["what"] = what if what
453
+ h["status"] = status if status
454
+ h["profile"] = profile if profile
455
+ h["now"] = true if now
456
+ @lock.synchronize { emit_line(h) }
457
+ else
458
+ @lock.synchronize do
459
+ text = event_text(t, kind, path, what, status, profile, now)
460
+ @out.puts text if text
461
+ end
462
+ end
463
+ @out.flush
464
+ end
465
+
466
+ # The lines of one event (nil: nothing to tell). A status is told whole
467
+ # (indented under the event) the first time it is seen, else only what
468
+ # changed, a line for each change. An application or a node that leaves
469
+ # takes its statuses along, so they are told whole when they are back.
470
+ def event_text(t, kind, path, what, status, profile, now)
471
+ head = "#{paint(t.strftime('%H:%M:%S.%L'), :time)} #{paint(EVENT_MARKS[kind], kind.to_sym)} "
472
+ case kind
473
+ when "status"
474
+ status = json_safe(status)
475
+ old = @last_status[path]
476
+ @last_status[path] = status
477
+ label = "status #{paint(path, :app)}"
478
+ if old.nil? || !old.is_a?(Hash) || !status.is_a?(Hash)
479
+ lines = Text.status_lines(status, style.width, Text.width(head))
480
+ return "#{head}#{label}#{paint(' (now)', :note) if now}\n#{lines.join("\n")}"
481
+ end
482
+ changes = Text.status_diff(old, status)
483
+ return nil if changes.empty?
484
+ changes.map { |c| "#{head}#{label} #{change_text(c)}" }.join("\n")
485
+ when "profile" then "#{head}profile #{paint(path, :node)}#{profile_text(profile)}"
486
+ else
487
+ forget_statuses(path) if kind == "leave"
488
+ "#{head}#{what} #{paint(path, WHAT_ROLES.fetch(what, :object))}"
489
+ end
490
+ end
491
+
492
+ EVENT_MARKS = { "join" => "+", "leave" => "-", "status" => "~", "profile" => "*" }.freeze
493
+ WHAT_ROLES = { "node" => :node, "app" => :app, "object" => :object }.freeze
494
+
495
+ def change_text(c)
496
+ case c[0]
497
+ when "+" then paint(c, :join)
498
+ when "-" then paint(c, :leave)
499
+ else c
500
+ end
501
+ end
502
+
503
+ def forget_statuses(path)
504
+ @last_status.delete_if { |k, _| k == path || k.start_with?("#{path}/") }
505
+ end
506
+
507
+ # ------------------------------------------------------------ ping
508
+
509
+ # Round trips to PATH: the meta of an object (no method runs), the
510
+ # profile of a node, or with --call a method (with the ARGs given).
511
+ def cmd_ping(rest)
512
+ path = rest.shift or raise ArgumentError, "ping: which node or object? (asterism ping node/app/object)"
513
+ args = rest.map { Literal.parse(_1) }
514
+ chunks = path.split("/")
515
+ raise ArgumentError, "ping: PATH is node or node/app/object" unless [1, 3].include?(chunks.size)
516
+ raise ArgumentError, "ping: --call needs node/app/object" if @opts[:call] && chunks.size != 3
517
+ what = @opts[:call] ? "call #{@opts[:call]}" : (chunks.size == 3 ? "meta" : "profile")
518
+ times = []
519
+ connect do |_net, _sv|
520
+ ms = (@opts[:timeout] * 1000).round
521
+ @opts[:count].times do |i|
522
+ sleep @opts[:interval] if i.positive?
523
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
524
+ ok = begin
525
+ ping_once(path, chunks, args, ms)
526
+ true
527
+ rescue ::Asterism::RemoteError => e
528
+ @err.puts "asterism: #{path}: #{e.remote_kind || 'error'}: #{e.message}" if i.zero?
529
+ true # it answered
530
+ rescue ::Asterism::TimeoutError => e
531
+ @err.puts "asterism: #{e.message}"
532
+ false
533
+ end
534
+ dt = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000
535
+ times << dt if ok
536
+ @out.puts "#{path} #{what}: #{ok ? format('%.1f ms', dt) : 'no answer'}" unless structured?
537
+ end
538
+ end
539
+ if structured?
540
+ emit({ "path" => path, "what" => what, "count" => @opts[:count], "answered" => times.size,
541
+ "ms" => times.map { _1.round(2) } })
542
+ elsif times.any?
543
+ @out.puts format("%d of %d answered: min %.1f / avg %.1f / max %.1f ms", times.size, @opts[:count],
544
+ times.min, times.sum / times.size, times.max)
545
+ end
546
+ times.empty? ? 1 : 0
547
+ end
548
+
549
+ def ping_once(path, chunks, args, ms)
550
+ if @opts[:call]
551
+ ::Asterism[path, timeout_ms: ms].asterism_call(@opts[:call], *args)
552
+ elsif chunks.size == 3
553
+ raw = ::Asterism.meta(path, ms)
554
+ raise ::Asterism::Error, "malformed meta from #{path}" unless raw.is_a?(Hash)
555
+ else
556
+ ::Asterism.profile_async(path, false, nil, ms).raw_value
557
+ end
558
+ end
559
+
560
+ # ------------------------------------------------------------ call
561
+
562
+ def cmd_call(rest)
563
+ path = rest.shift
564
+ method = rest.shift
565
+ raise ArgumentError, "call: asterism call node/app/object METHOD [ARG...]" unless path && method
566
+ ::Asterism.split_path(path)
567
+ args = rest.map { Literal.parse(_1) }
568
+ kwargs = @opts[:kwargs] ? Literal.parse(@opts[:kwargs]) : {}
569
+ raise ArgumentError, "--kwargs: expected a JSON object or a Ruby Hash" unless kwargs.is_a?(Hash)
570
+ kwargs = kwargs.transform_keys(&:to_sym)
571
+ code = 0
572
+ connect do |_net, _sv|
573
+ px = ::Asterism[path, timeout: @opts[:timeout]]
574
+ begin
575
+ v = px.asterism_call(method, *args, **kwargs)
576
+ structured? ? emit_line({ "value" => json_safe(v) }) : @out.puts(v.inspect)
577
+ rescue ::Asterism::RemoteError => e
578
+ if structured?
579
+ emit_line({ "error" => { "kind" => e.remote_kind, "class" => e.remote_class,
580
+ "message" => e.remote_message } })
581
+ else
582
+ @err.puts "asterism: #{path}.#{method}: #{e.remote_kind || 'error'}: #{e.message}"
583
+ end
584
+ code = 1
585
+ rescue ::Asterism::TimeoutError => e
586
+ @err.puts "asterism: #{e.message}" \
587
+ "#{"\n (with a console's .tar, the router refuses a call its role does not allow; " \
588
+ 'it looks the same as no answer)' if @opts[:credentials]}"
589
+ code = 1
590
+ end
591
+ end
592
+ code
593
+ end
594
+
595
+ # ------------------------------------------------------------ mcp
596
+
597
+ def cmd_mcp(_rest)
598
+ begin
599
+ require_relative "cli/mcp"
600
+ rescue LoadError => e
601
+ raise unless e.message.include?("mcp")
602
+ @err.puts "asterism mcp needs the mcp gem: gem install mcp (#{e.message})"
603
+ return 1
604
+ end
605
+ connect(app_prefix: "mcp") do |net, sv|
606
+ MCPBridge.new(net, sv, allow: @opts[:allow], log: @err).run
607
+ end
608
+ 0
609
+ end
610
+
611
+ # ------------------------------------------------------------ text
612
+
613
+ def style
614
+ @style ||= Style.new(@out, @env)
615
+ end
616
+
617
+ def paint(text, *styles)
618
+ style.paint(text, *styles)
619
+ end
620
+
621
+ def count(n, what)
622
+ "#{n} #{what}#{'s' unless n == 1}"
623
+ end
624
+
625
+ def profile_text(pr)
626
+ return "" unless pr
627
+ parts = [pr["kind"], pr["host"]].compact.join("/")
628
+ name = pr["name"] ? " \"#{pr['name']}\"" : ""
629
+ " #{paint("(#{parts})", :note)}#{name}"
630
+ end
631
+
632
+ def node_line(n)
633
+ "#{paint(n['node'], :node)}#{profile_text(n['profile'])}"
634
+ end
635
+
636
+ # An application's status (when it has one) under "status:", indented.
637
+ def status_block(app, indent)
638
+ return unless app.key?("status")
639
+ @out.puts "#{' ' * indent}#{paint('status:', :note)}"
640
+ Text.status_lines(json_safe(app["status"]), style.width, indent + 2).each { @out.puts _1 }
641
+ end
642
+
643
+ # The methods' signatures wrapped to the width, indented.
644
+ def method_list(list, indent)
645
+ sigs = list.map { paint(signature(_1), :method) }
646
+ Text.wrap(sigs, style.width, first: " " * indent, sep: " ").each { @out.puts _1 }
647
+ end
648
+
649
+ # One object of `objects`: its path, its description, then its methods:
650
+ # one a line with their descriptions, the descriptions in one column and
651
+ # wrapped (a signature too long for the column has its description on
652
+ # the next line); or, when none has one, the signatures wrapped.
653
+ def object_entry(path, methods, doc)
654
+ @out.puts paint(path, :object)
655
+ Text.wrap_text(doc, style.width, first: " ").each { @out.puts _1 } if doc
656
+ unless methods
657
+ @out.puts " #{paint('(no meta: not answered in time, or not allowed)', :note)}"
658
+ return
659
+ end
660
+ return method_list(methods, 2) unless methods.any? { _1["doc"] }
661
+
662
+ sigs = methods.map { signature(_1) }
663
+ col = 2 + [sigs.map { Text.width(_1) }.max, SIGNATURE_COLUMN].min + 2
664
+ methods.zip(sigs).each do |m, sig|
665
+ head = " #{paint(sig, :method)}"
666
+ unless m["doc"]
667
+ @out.puts head
668
+ next
669
+ end
670
+ sw = 2 + Text.width(sig)
671
+ if sw + 2 > col
672
+ @out.puts head
673
+ head = " " * col
674
+ else
675
+ head += " " * (col - sw)
676
+ end
677
+ Text.wrap_text(m["doc"], style.width, first: head, rest: " " * col).each { @out.puts _1 }
678
+ end
679
+ end
680
+
681
+ # The widest signature the description column makes room for.
682
+ SIGNATURE_COLUMN = 24
683
+
684
+ # name(arg, arg) with the names the meta tells, else name(2) / name(*).
685
+ def signature(m)
686
+ return "#{m['name']}(#{m['args'].join(', ')}#{', *' if m['arity'].negative?})" if m["args"]
687
+ m["arity"].negative? ? "#{m['name']}(*)" : "#{m['name']}(#{m['arity']})"
688
+ end
689
+
690
+ # A value as JSON can hold it (bytes as a note).
691
+ def json_safe(v)
692
+ case v
693
+ when Hash then v.to_h { |k, x| [k.to_s, json_safe(x)] }
694
+ when Array then v.map { json_safe(_1) }
695
+ when String then v.encoding == Encoding::BINARY && !v.ascii_only? ? "(#{v.bytesize} bytes)" : v
696
+ else v
697
+ end
698
+ end
699
+ end
700
+ end
701
+ require_relative "cli/nouns"