asterism 0.8.0 → 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.
data/lib/asterism/cli.rb CHANGED
@@ -1,13 +1,19 @@
1
1
  # The command line of the gem (exe/asterism): look at an Asterism network
2
- # from CRuby, and call an object when asked to.
2
+ # from CRuby, and call an object when asked to. Its commands are a noun and
3
+ # a verb, as ros2's are:
3
4
  #
4
- # asterism nodes --router tcp/192.0.2.2:7447
5
- # asterism tree --credentials admin.tar
6
- # asterism objects 'games/games/*'
7
- # asterism watch # joins, leaves, statuses as they come
8
- # asterism ping games/games/othello1 # the time of a round trip (meta)
9
- # asterism call games/games/othello1 view '"<token>"'
10
- # asterism mcp --allow 'games/othello1.*' # an MCP server on stdio
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.
11
17
  #
12
18
  # It is one more Asterism node: it connects with Asterism.connect (as the
13
19
  # certificate's name with --credentials, as cli-<6 hex digits> otherwise)
@@ -21,56 +27,148 @@ require "securerandom"
21
27
  require_relative "cli/literal"
22
28
  require_relative "cli/survey"
23
29
  require_relative "cli/allow"
30
+ require_relative "cli/text"
31
+ require_relative "cli/hz"
32
+ require_relative "cli/complete"
24
33
 
25
34
  module Asterism
26
35
  class CLI
27
- USAGE = <<~TEXT
28
- Usage: asterism COMMAND [options]
29
-
30
- Commands (they only read, except call):
31
- nodes the nodes, their applications and profiles
32
- objects [PATTERN] the objects and their methods (PATTERN: node/app/object, * and **)
33
- tree nodes > applications (with their status) > objects (with their methods)
34
- watch nodes, applications and objects coming and going, and status changes
35
- ping PATH round trips: PATH node/app/object (its meta), or node (its profile);
36
- --call METHOD [ARG...] times a call instead (echo is the same command)
37
- call PATH METHOD [ARG...]
38
- calls a method; each ARG is JSON, else a Ruby literal, else text
39
- mcp an MCP server on stdio (needs the mcp gem): the listings as tools,
40
- and the methods --allow names
41
- version the gem's version
42
-
43
- Where the network is (one of them):
44
- --router LOCATOR a router without TLS, e.g. tcp/192.0.2.2:7447
45
- --credentials TAR a console's .tar (mutual TLS; its router and name)
46
- TEXT
47
-
48
- def self.start(argv, out: $stdout, err: $stderr)
49
- new(out: out, err: err).run(argv.dup)
50
- end
51
-
52
- def initialize(out: $stdout, err: $stderr)
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)
53
128
  @out = out
54
129
  @err = err
55
- @opts = { timeout: 2.0, settle: 1.5, json: false, count: 5, interval: 0.2, allow: [] }
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 }
56
135
  end
57
136
 
58
137
  def run(argv)
59
138
  cmd = argv.shift
60
139
  case cmd
61
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
62
145
  @out.puts USAGE
63
146
  return cmd.nil? ? 2 : 0
64
147
  when "version", "--version", "-v"
65
148
  @out.puts "asterism #{::Asterism::VERSION}"
66
149
  return 0
150
+ when "__complete"
151
+ return cmd_complete(argv)
67
152
  end
68
- unless %w[nodes objects tree watch ping echo call mcp].include?(cmd)
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))
69
167
  @err.puts "asterism: unknown command #{cmd.inspect}\n\n#{USAGE}"
70
168
  return 2
71
169
  end
72
- rest = parser(cmd).parse(argv)
73
- send("cmd_#{cmd == 'echo' ? 'ping' : cmd}", rest)
170
+ rest = parser(key).parse(argv)
171
+ send("cmd_#{key == 'echo' ? 'ping' : key.tr(' ', '_')}", rest)
74
172
  rescue OptionParser::ParseError, ArgumentError => e
75
173
  @err.puts "asterism: #{e.message}"
76
174
  2
@@ -84,18 +182,29 @@ module Asterism
84
182
  0
85
183
  end
86
184
 
87
- def parser(cmd)
185
+ # The options of a command (key: "tree", "node info", ...).
186
+ def parser(key)
88
187
  OptionParser.new do |o|
89
- o.banner = "Usage: asterism #{cmd} [options]"
90
- o.on("--router LOCATOR", "a router (tcp/HOST:7447)") { @opts[:router] = _1 }
91
- o.on("--credentials TAR", "a console's .tar") { @opts[:credentials] = _1 }
92
- o.on("--peer LOCATOR", "a direct peer link to a node listening there (no router)") { @opts[:peer] = _1 }
93
- o.on("--node NAME", "this node's ID (default: the certificate's name, or cli-<6 hex>)") { @opts[:node] = _1 }
94
- o.on("--app NAME", "this application's name (default: cli-<4 hex>)") { @opts[:app] = _1 }
95
- o.on("--json", "JSON instead of text (watch: one JSON object a line)") { @opts[:json] = true }
96
- o.on("--timeout SECONDS", Float, "time limit of each get or call (2.0)") { @opts[:timeout] = _1 }
97
- o.on("--settle SECONDS", Float, "how long to wait for the tokens at connect (1.5)") { @opts[:settle] = _1 }
98
- case cmd
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 }
99
208
  when "watch"
100
209
  o.on("--duration SECONDS", Float, "stop after this long (default: until Ctrl-C)") { @opts[:duration] = _1 }
101
210
  when "ping", "echo"
@@ -107,20 +216,78 @@ module Asterism
107
216
  when "mcp"
108
217
  o.on("--allow PATTERN", "methods the MCP client may call: [node/][app/]object.method, * as a wildcard " \
109
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 }
110
226
  end
111
227
  end
112
228
  end
113
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
+
114
269
  # ------------------------------------------------------------ connecting
115
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
+
116
284
  # Connects, starts the receiving thread, waits for the tokens and yields
117
285
  # the Net and a Survey.
118
286
  def connect(app_prefix: "cli")
119
- router = @opts[:router]
120
- creds = @opts[:credentials]
121
- peer = @opts[:peer]
287
+ router, creds, peer = network
122
288
  if router.nil? && creds.nil? && peer.nil?
123
- raise ArgumentError, "where is the network? --router tcp/HOST:7447 or --credentials FILE.tar"
289
+ raise ArgumentError, "where is the network? --router tcp/HOST:7447 or --credentials FILE.tar " \
290
+ "(or ASTERISM_CREDENTIALS / ASTERISM_ROUTER)"
124
291
  end
125
292
  node = @opts[:node]
126
293
  node ||= "#{app_prefix}-#{SecureRandom.hex(3)}" unless creds
@@ -142,8 +309,26 @@ module Asterism
142
309
  end
143
310
  end
144
311
 
145
- def emit_json(v)
146
- @out.puts JSON.pretty_generate(v)
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)))
147
332
  end
148
333
 
149
334
  # ------------------------------------------------------------ nodes
@@ -151,16 +336,17 @@ module Asterism
151
336
  def cmd_nodes(_rest)
152
337
  connect do |_net, sv|
153
338
  tree = sv.tree(meta: false)
154
- if @opts[:json]
155
- emit_json(tree.map { |n| n.merge("apps" => n["apps"].map { |a| a.except("objects") }) })
339
+ if structured?
340
+ emit(tree.map { |n| n.merge("apps" => n["apps"].map { |a| a.except("objects") }) })
156
341
  elsif tree.empty?
157
342
  @out.puts "(no other node is visible)"
158
343
  else
159
344
  tree.each do |n|
160
- @out.puts "#{n['node']}#{profile_text(n['profile'])}"
345
+ @out.puts node_line(n)
161
346
  n["apps"].each do |a|
162
- @out.puts " #{a['app']}#{" #{a['objects'].size} objects" unless a['objects'].empty?}" \
163
- "#{" status #{compact(a['status'])}" if a.key?('status')}"
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)
164
350
  end
165
351
  end
166
352
  end
@@ -175,8 +361,8 @@ module Asterism
175
361
  connect do |_net, sv|
176
362
  paths = sv.objects(pattern)
177
363
  metas = sv.metas(paths)
178
- if @opts[:json]
179
- emit_json(paths.map do |p|
364
+ if structured?
365
+ emit(paths.map do |p|
180
366
  h = { "path" => p, "methods" => metas[p] }
181
367
  h["doc"] = sv.object_doc(p) if sv.object_doc(p)
182
368
  h
@@ -184,14 +370,7 @@ module Asterism
184
370
  elsif paths.empty?
185
371
  @out.puts "(no object matches #{pattern})"
186
372
  else
187
- paths.each do |p|
188
- @out.puts "#{p} #{metas.key?(p) ? methods_text(metas[p]) : '(no meta: not answered in time, or not allowed)'}"
189
- @out.puts " #{sv.object_doc(p)}" if sv.object_doc(p)
190
- # The methods that tell what they do, one a line (doc's first line).
191
- (metas[p] || []).each do |m|
192
- @out.puts " #{signature(m)} #{m['doc'].lines.first.strip}" if m["doc"]
193
- end
194
- end
373
+ paths.each { |p| object_entry(p, metas[p], sv.object_doc(p)) }
195
374
  end
196
375
  end
197
376
  0
@@ -202,17 +381,20 @@ module Asterism
202
381
  def cmd_tree(_rest)
203
382
  connect do |_net, sv|
204
383
  tree = sv.tree
205
- if @opts[:json]
206
- emit_json(tree)
384
+ if structured?
385
+ emit(tree)
207
386
  elsif tree.empty?
208
387
  @out.puts "(no other node is visible)"
209
388
  else
210
389
  tree.each do |n|
211
- @out.puts "#{n['node']}#{profile_text(n['profile'])}"
390
+ @out.puts node_line(n)
212
391
  n["apps"].each do |a|
213
- @out.puts " #{a['app']}#{" status #{compact(a['status'])}" if a.key?('status')}"
392
+ @out.puts " #{paint(a['app'], :app)}"
393
+ status_block(a, 4)
214
394
  a["objects"].each do |o|
215
- @out.puts " #{o['name']} #{o['methods'] ? methods_text(o['methods']) : '(no meta)'}"
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?
216
398
  end
217
399
  end
218
400
  end
@@ -252,7 +434,7 @@ module Asterism
252
434
  end
253
435
  sleep 0.3
254
436
  @watch_started = true
255
- @err.puts "asterism: watching (Ctrl-C to stop)" unless @opts[:json]
437
+ @err.puts "asterism: watching (Ctrl-C to stop)" unless structured?
256
438
  deadline = @opts[:duration] && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + @opts[:duration])
257
439
  loop do
258
440
  break if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
@@ -265,25 +447,63 @@ module Asterism
265
447
 
266
448
  def event(kind, path, what: nil, status: nil, profile: nil, now: false)
267
449
  t = Time.now
268
- if @opts[:json]
450
+ if structured?
269
451
  h = { "t" => t.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"), "event" => kind, "path" => path }
270
452
  h["what"] = what if what
271
453
  h["status"] = status if status
272
454
  h["profile"] = profile if profile
273
455
  h["now"] = true if now
274
- @out.puts JSON.generate(h)
456
+ @lock.synchronize { emit_line(h) }
275
457
  else
276
- mark = { "join" => "+", "leave" => "-", "status" => "~", "profile" => "*" }[kind]
277
- text = case kind
278
- when "status" then "status #{path} #{compact(status)}#{' (now)' if now}"
279
- when "profile" then "profile #{path}#{profile_text(profile)}"
280
- else "#{what} #{path}"
281
- end
282
- @out.puts "#{t.strftime('%H:%M:%S.%L')} #{mark} #{text}"
458
+ @lock.synchronize do
459
+ text = event_text(t, kind, path, what, status, profile, now)
460
+ @out.puts text if text
461
+ end
283
462
  end
284
463
  @out.flush
285
464
  end
286
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
+
287
507
  # ------------------------------------------------------------ ping
288
508
 
289
509
  # Round trips to PATH: the meta of an object (no method runs), the
@@ -313,11 +533,11 @@ module Asterism
313
533
  end
314
534
  dt = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000
315
535
  times << dt if ok
316
- @out.puts "#{path} #{what}: #{ok ? format('%.1f ms', dt) : 'no answer'}" unless @opts[:json]
536
+ @out.puts "#{path} #{what}: #{ok ? format('%.1f ms', dt) : 'no answer'}" unless structured?
317
537
  end
318
538
  end
319
- if @opts[:json]
320
- emit_json({ "path" => path, "what" => what, "count" => @opts[:count], "answered" => times.size,
539
+ if structured?
540
+ emit({ "path" => path, "what" => what, "count" => @opts[:count], "answered" => times.size,
321
541
  "ms" => times.map { _1.round(2) } })
322
542
  elsif times.any?
323
543
  @out.puts format("%d of %d answered: min %.1f / avg %.1f / max %.1f ms", times.size, @opts[:count],
@@ -353,10 +573,10 @@ module Asterism
353
573
  px = ::Asterism[path, timeout: @opts[:timeout]]
354
574
  begin
355
575
  v = px.asterism_call(method, *args, **kwargs)
356
- @opts[:json] ? @out.puts(JSON.generate({ "value" => json_safe(v) })) : @out.puts(v.inspect)
576
+ structured? ? emit_line({ "value" => json_safe(v) }) : @out.puts(v.inspect)
357
577
  rescue ::Asterism::RemoteError => e
358
- if @opts[:json]
359
- @out.puts JSON.generate({ "error" => { "kind" => e.remote_kind, "class" => e.remote_class,
578
+ if structured?
579
+ emit_line({ "error" => { "kind" => e.remote_kind, "class" => e.remote_class,
360
580
  "message" => e.remote_message } })
361
581
  else
362
582
  @err.puts "asterism: #{path}.#{method}: #{e.remote_kind || 'error'}: #{e.message}"
@@ -390,27 +610,83 @@ module Asterism
390
610
 
391
611
  # ------------------------------------------------------------ text
392
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
+
393
625
  def profile_text(pr)
394
626
  return "" unless pr
395
627
  parts = [pr["kind"], pr["host"]].compact.join("/")
396
628
  name = pr["name"] ? " \"#{pr['name']}\"" : ""
397
- " (#{parts})#{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 }
398
641
  end
399
642
 
400
- def methods_text(list)
401
- list.map { |m| signature(m) }.join(" ")
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 }
402
647
  end
403
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
+
404
684
  # name(arg, arg) with the names the meta tells, else name(2) / name(*).
405
685
  def signature(m)
406
686
  return "#{m['name']}(#{m['args'].join(', ')}#{', *' if m['arity'].negative?})" if m["args"]
407
687
  m["arity"].negative? ? "#{m['name']}(*)" : "#{m['name']}(#{m['arity']})"
408
688
  end
409
689
 
410
- def compact(v)
411
- JSON.generate(json_safe(v))
412
- end
413
-
414
690
  # A value as JSON can hold it (bytes as a note).
415
691
  def json_safe(v)
416
692
  case v
@@ -422,3 +698,4 @@ module Asterism
422
698
  end
423
699
  end
424
700
  end
701
+ require_relative "cli/nouns"
@@ -3,5 +3,5 @@ module Asterism
3
3
  # is in asterism.gemspec (asterism-zenoh ~> 0.4.0 for 0.5.0 to 0.7.0:
4
4
  # the bindings did not change; see CHANGELOG.md). 0.7.0 speaks wire
5
5
  # protocol 1 only (Asterism::PROTOCOL, docs/protocol.md).
6
- VERSION = "0.8.0"
6
+ VERSION = "0.9.0"
7
7
  end