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,272 @@
1
+ # `asterism mcp`: Asterism as tools of an MCP client (an AI assistant),
2
+ # over stdio, with the mcp gem (an optional dependency: `gem install mcp`;
3
+ # the rest of the command line does not need it).
4
+ #
5
+ # asterism mcp --router tcp/192.0.2.2:7447 --allow 'games/othello1.*'
6
+ #
7
+ # Tools:
8
+ # asterism_nodes the nodes, their applications (with their statuses)
9
+ # and objects (always; reads)
10
+ # asterism_objects the objects and their methods (meta) (always; reads)
11
+ # asterism_status the applications' statuses (always; reads)
12
+ # <node>.<app>.<object>.<method>
13
+ # one per method that an --allow pattern names, made
14
+ # from the object's meta: the arguments' names the
15
+ # meta tells (docs/protocol.md 5) are the inputs and
16
+ # its doc the description; without names, arity n
17
+ # gives arg1 .. argN (any JSON value), arity -1 an
18
+ # array "args"; "kwargs" (an object) is the keyword
19
+ # arguments. Nothing is callable without --allow.
20
+ #
21
+ # The call tools follow the network: when objects appear or go, the list is
22
+ # made again and the client is told (notifications/tools/list_changed).
23
+ require "mcp"
24
+
25
+ module Asterism
26
+ class CLI
27
+ class MCPBridge
28
+ REFRESH_S = 2.0
29
+ INSTRUCTIONS = <<~TEXT.freeze
30
+ These tools look at an Asterism network: Ruby programs and boards that expose objects to each other over
31
+ Zenoh. Paths are <node>/<app>/<object>. asterism_nodes lists the nodes and their applications (with the
32
+ status each application tells about itself, a JSON object of its own), asterism_objects the objects and
33
+ their methods, asterism_status the statuses. The other tools call one method each, on the object their
34
+ name says (<node>.<app>.<object>.<method>); only the methods this server was started to allow are there.
35
+ A call's answer is {"value": ...}; an error says its kind (raised, not_exposed, arguments, ...).
36
+ TEXT
37
+
38
+ def initialize(net, survey, allow:, log: $stderr)
39
+ @net = net
40
+ @survey = survey
41
+ @allow = Allow.new(allow)
42
+ @log = log
43
+ @call_tools = {} # tool name => [path, method, arity, meta entry]
44
+ @signature = nil
45
+ @lock = Mutex.new
46
+ end
47
+
48
+ def run
49
+ @server = ::MCP::Server.new(name: "asterism", version: ::Asterism::VERSION, instructions: INSTRUCTIONS,
50
+ tools: read_tools)
51
+ @transport = ::MCP::Server::Transports::StdioTransport.new(@server)
52
+ @server.transport = @transport
53
+ refresh
54
+ @log.puts "asterism mcp: #{@net.node_id}/#{@net.app}; #{@call_tools.size} call tools" \
55
+ "#{@allow.empty? ? ' (no --allow: read-only)' : ''}"
56
+ @refresher = Thread.new { refresh_loop }
57
+ @transport.open
58
+ ensure
59
+ @refresher&.kill
60
+ end
61
+
62
+ # ------------------------------------------------------------ the list
63
+
64
+ def refresh_loop
65
+ loop do
66
+ sleep REFRESH_S
67
+ refresh
68
+ rescue StandardError => e
69
+ @log.puts "asterism mcp: refresh: #{e.class}: #{e.message}"
70
+ end
71
+ end
72
+
73
+ # Makes the call tools again when the objects changed; tells the
74
+ # client when the list did.
75
+ def refresh
76
+ return if @allow.empty?
77
+ paths = @survey.objects.select { @allow.object?(_1) }
78
+ sig = paths.sort
79
+ return if sig == @signature
80
+ metas = @survey.metas(paths)
81
+ tools = {}
82
+ metas.sort.each do |path, methods|
83
+ methods.each do |m|
84
+ next unless @allow.method?(path, m["name"])
85
+ name = unique_name(tool_name(path, m["name"]), tools)
86
+ tools[name] = [path, m["name"], m["arity"], m]
87
+ end
88
+ end
89
+ # Keep trying the objects whose meta did not come.
90
+ @signature = (paths - metas.keys).empty? ? sig : nil
91
+ changed = @lock.synchronize do
92
+ next false if tools == @call_tools
93
+ @call_tools = tools
94
+ list = read_tools + tools.map { |name, (path, _method, _arity, m)| call_tool(name, path, m) }
95
+ @server.tools = list.to_h { [_1.name_value, _1] }
96
+ @server.instance_variable_set(:@tool_names, @server.tools.keys)
97
+ true
98
+ end
99
+ return unless changed
100
+ @log.puts "asterism mcp: call tools: #{tools.keys.join(', ')}"
101
+ @server.notify_tools_list_changed
102
+ end
103
+
104
+ def tool_name(path, method)
105
+ "#{path.tr('/', '.')}.#{method}".gsub(/[^A-Za-z0-9_.-]/, "_")[0, 120]
106
+ end
107
+
108
+ def unique_name(name, taken)
109
+ return name unless taken.key?(name)
110
+ n = 2
111
+ n += 1 while taken.key?("#{name}_#{n}")
112
+ "#{name}_#{n}"
113
+ end
114
+
115
+ # ------------------------------------------------------------ tools
116
+
117
+ def read_tools
118
+ sv = @survey
119
+ bridge = self
120
+ [
121
+ ::MCP::Tool.define(
122
+ name: "asterism_nodes",
123
+ description: "The nodes of the Asterism network, each with its profile (what runs it, a display name), " \
124
+ "its applications with their statuses, and the names of their objects.",
125
+ input_schema: { properties: {} },
126
+ annotations: { read_only_hint: true }
127
+ ) do |server_context:|
128
+ bridge.answer { sv.tree(meta: false) }
129
+ end,
130
+ ::MCP::Tool.define(
131
+ name: "asterism_objects",
132
+ description: "The exposed objects (<node>/<app>/<object>) and their methods with the number of " \
133
+ "arguments (-1: any), and the arguments' names and what each method does when the object " \
134
+ "tells them (args, doc). callable_as names the tool that calls a method, when this server " \
135
+ "allows it.",
136
+ input_schema: { properties: { pattern: { type: "string",
137
+ description: "node/app/object with * (one part) and ** " \
138
+ "(any parts); default **" } } },
139
+ annotations: { read_only_hint: true }
140
+ ) do |server_context:, pattern: "**"|
141
+ bridge.answer { bridge.objects(pattern) }
142
+ end,
143
+ ::MCP::Tool.define(
144
+ name: "asterism_status",
145
+ description: "The statuses the applications tell about themselves (asterism/<node>/<app>/@status): " \
146
+ "{\"<node>/<app>\": {...}}. An application without one is not listed.",
147
+ input_schema: { properties: { node: { type: "string" }, app: { type: "string" } } },
148
+ annotations: { read_only_hint: true }
149
+ ) do |server_context:, node: nil, app: nil|
150
+ bridge.answer do
151
+ sv.statuses.select do |k, _|
152
+ n, a = k.split("/")
153
+ (node.nil? || node == n) && (app.nil? || app == a)
154
+ end
155
+ end
156
+ end
157
+ ]
158
+ end
159
+
160
+ def objects(pattern)
161
+ paths = @survey.objects(pattern)
162
+ metas = @survey.metas(paths)
163
+ by = @lock.synchronize { @call_tools.to_h { |name, (path, m, _)| [[path, m], name] } }
164
+ paths.map do |p|
165
+ methods = metas[p]&.map do |m|
166
+ name = by[[p, m["name"]]]
167
+ name ? m.merge("callable_as" => name) : m
168
+ end
169
+ h = { "path" => p, "methods" => methods }
170
+ h["doc"] = @survey.object_doc(p) if @survey.object_doc(p)
171
+ h
172
+ end
173
+ end
174
+
175
+ def call_tool(name, path, meta)
176
+ method = meta["name"]
177
+ arity = meta["arity"]
178
+ names = input_names(meta)
179
+ props = {}
180
+ required = []
181
+ if names
182
+ names.each do |n|
183
+ props[n.to_sym] = { description: "the argument #{n}" }
184
+ required << n if arity >= 0
185
+ end
186
+ props[:args] = { type: "array", description: "more positional arguments, if any" } if arity.negative?
187
+ elsif arity.negative?
188
+ props[:args] = { type: "array", description: "the positional arguments (any number)" }
189
+ else
190
+ arity.times do |i|
191
+ props[:"arg#{i + 1}"] = { description: "argument #{i + 1} (any JSON value)" }
192
+ required << "arg#{i + 1}"
193
+ end
194
+ end
195
+ props[:kwargs] = { type: "object", description: "keyword arguments, if the method takes any" }
196
+ schema = { properties: props }
197
+ schema[:required] = required unless required.empty?
198
+ about = [meta["doc"], (d = @survey.object_doc(path)) && "(#{path}: #{d})"].compact
199
+ count = names ? "" : " (#{arity.negative? ? 'any number of' : arity} arguments)"
200
+ bridge = self
201
+ ::MCP::Tool.define(
202
+ name: name,
203
+ description: "#{about.empty? ? '' : "#{about.join(' ')}\n"}Calls #{method} on the Asterism object " \
204
+ "#{path}#{count} and returns {\"value\": ...}.",
205
+ input_schema: schema
206
+ ) do |server_context:, **args|
207
+ bridge.invoke(path, method, arity, args, names)
208
+ end
209
+ end
210
+
211
+ # The meta's argument names as inputs, or nil (none, or a name the
212
+ # inputs cannot take: "args", "kwargs", a repeated one).
213
+ def input_names(meta)
214
+ names = meta["args"]
215
+ return nil unless names.is_a?(Array) && !names.empty?
216
+ return nil if names.uniq.size != names.size || names.any? { %w[args kwargs].include?(_1) }
217
+ names
218
+ end
219
+
220
+ # A call tool's call.
221
+ def invoke(path, method, arity, args, names = nil)
222
+ pos = if names
223
+ names.map { args[_1.to_sym] } + (arity.negative? ? Array(args[:args]) : [])
224
+ elsif arity.negative?
225
+ Array(args[:args])
226
+ else
227
+ (1..arity).map { args[:"arg#{_1}"] }
228
+ end
229
+ if names && arity.negative?
230
+ # Optional named arguments: the trailing ones not given are left out.
231
+ pos.pop while !pos.empty? && pos.last.nil? && args[:args].nil?
232
+ end
233
+ kw = (args[:kwargs] || {}).to_h.transform_keys(&:to_sym)
234
+ unless @lock.synchronize { @call_tools.any? { |_, t| t[0] == path && t[1] == method } }
235
+ return error("#{path}.#{method} is not callable here (not allowed, or gone)")
236
+ end
237
+ v = ::Asterism[path, timeout: @survey.timeout].asterism_call(method, *pos, **kw)
238
+ text({ "value" => v })
239
+ rescue ::Asterism::RemoteError => e
240
+ error({ "error" => { "kind" => e.remote_kind, "class" => e.remote_class, "message" => e.remote_message } })
241
+ rescue ::Asterism::Error => e
242
+ error({ "error" => { "kind" => "asterism", "class" => e.class.name, "message" => e.message } })
243
+ end
244
+
245
+ # The value of the block as a JSON text; an Asterism error as an
246
+ # error result.
247
+ def answer
248
+ text(yield)
249
+ rescue ::Asterism::Error => e
250
+ error("#{e.class}: #{e.message}")
251
+ end
252
+
253
+ def text(v)
254
+ ::MCP::Tool::Response.new([{ type: "text", text: JSON.generate(json_safe(v)) }])
255
+ end
256
+
257
+ def error(v)
258
+ ::MCP::Tool::Response.new([{ type: "text", text: v.is_a?(String) ? v : JSON.generate(json_safe(v)) }],
259
+ error: true)
260
+ end
261
+
262
+ def json_safe(v)
263
+ case v
264
+ when Hash then v.to_h { |k, x| [k.to_s, json_safe(x)] }
265
+ when Array then v.map { json_safe(_1) }
266
+ when String then v.encoding == Encoding::BINARY && !v.ascii_only? ? "(#{v.bytesize} bytes)" : v
267
+ else v
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,396 @@
1
+ # The noun-verb commands of the command line (node, app, object, status:
2
+ # lib/asterism/cli.rb's NOUNS), `completion` and the hidden `__complete`
3
+ # (lib/asterism/cli/complete.rb).
4
+ module Asterism
5
+ class CLI
6
+ # ------------------------------------------------------------ node
7
+
8
+ def cmd_node_list(_rest)
9
+ connect do |_net, sv|
10
+ names = sv.nodes
11
+ structured? ? emit(names) : names.each { @out.puts paint(_1, :node) }
12
+ end
13
+ 0
14
+ end
15
+
16
+ def cmd_node_info(rest)
17
+ node = rest.shift
18
+ raise ArgumentError, "node info: which node? (asterism node info NODE)" unless node && !node.empty?
19
+ raise ArgumentError, "node info: NODE is a node's name (no /)" if node.include?("/")
20
+ code = 0
21
+ connect do |_net, sv|
22
+ entry = sv.tree(node: node).first
23
+ if entry.nil?
24
+ @err.puts "asterism: no node #{node} is visible"
25
+ code = 1
26
+ elsif structured?
27
+ emit(entry)
28
+ else
29
+ @out.puts paint(entry["node"], :node)
30
+ if entry["profile"]
31
+ pr = entry["profile"].except("icon")
32
+ Text.wrap(pr.map { |k, v| Text.pair(k, v) }, style.width, first: " #{paint('profile:', :note)} ",
33
+ rest: " ", break_long: false)
34
+ .each { @out.puts _1 }
35
+ end
36
+ entry["apps"].each { |a| app_block(a["app"], a, 2) }
37
+ end
38
+ end
39
+ code
40
+ end
41
+
42
+ # ------------------------------------------------------------ app
43
+
44
+ def cmd_app_list(_rest)
45
+ connect do |_net, sv|
46
+ names = sv.apps
47
+ structured? ? emit(names) : names.each { @out.puts paint_path(_1) }
48
+ end
49
+ 0
50
+ end
51
+
52
+ def cmd_app_info(rest)
53
+ node, app = app_path!(rest, "app info").split("/")
54
+ code = 0
55
+ connect do |_net, sv|
56
+ a = sv.tree(node: node, app: app).first&.dig("apps")&.find { _1["app"] == app }
57
+ if a.nil?
58
+ @err.puts "asterism: no application #{node}/#{app} is visible"
59
+ code = 1
60
+ elsif structured?
61
+ emit({ "node" => node }.merge(a))
62
+ else
63
+ app_block("#{node}/#{app}", a, 0)
64
+ end
65
+ end
66
+ code
67
+ end
68
+
69
+ # ------------------------------------------------------------ object
70
+
71
+ def cmd_object_list(rest)
72
+ pattern = rest.shift || "**"
73
+ connect do |_net, sv|
74
+ paths = sv.objects(pattern)
75
+ structured? ? emit(paths) : paths.each { @out.puts paint_path(_1) }
76
+ end
77
+ 0
78
+ end
79
+
80
+ def cmd_object_info(rest)
81
+ path = rest.shift
82
+ unless path && path.split("/", -1).size == 3 && path.split("/").none? { _1.empty? || _1.include?("*") }
83
+ raise ArgumentError, "object info: PATH is node/app/object (a pattern: asterism object list PATTERN)"
84
+ end
85
+ code = 0
86
+ connect do |_net, sv|
87
+ if sv.objects(path).empty?
88
+ @err.puts "asterism: no object #{path} is visible"
89
+ code = 1
90
+ next
91
+ end
92
+ methods = sv.metas([path])[path]
93
+ if structured?
94
+ h = { "path" => path, "methods" => methods }
95
+ h["doc"] = sv.object_doc(path) if sv.object_doc(path)
96
+ emit(h)
97
+ else
98
+ object_entry(path, methods, sv.object_doc(path))
99
+ end
100
+ end
101
+ code
102
+ end
103
+
104
+ def cmd_object_find(rest)
105
+ want = rest.shift
106
+ raise ArgumentError, "object find: which method? (asterism object find METHOD; * as a wildcard)" unless want
107
+ found = []
108
+ connect do |_net, sv|
109
+ paths = sv.objects
110
+ metas = sv.metas(paths)
111
+ paths.each do |p|
112
+ ms = (metas[p] || []).select { File.fnmatch?(want, _1["name"]) }
113
+ found << { "path" => p, "methods" => ms } unless ms.empty?
114
+ end
115
+ structured? ? emit(found) : found.each { @out.puts paint_path(_1["path"]) }
116
+ end
117
+ 0
118
+ end
119
+
120
+ # ------------------------------------------------------------ status
121
+
122
+ def cmd_status_get(rest)
123
+ path = app_path!(rest, "status get")
124
+ code = 0
125
+ connect do |_net, sv|
126
+ st = sv.statuses([path])[path]
127
+ if st.nil?
128
+ @err.puts no_status(path)
129
+ code = 1
130
+ elsif structured?
131
+ emit(json_safe(st))
132
+ else
133
+ Text.status_lines(json_safe(st), style.width).each { @out.puts _1 }
134
+ end
135
+ end
136
+ code
137
+ end
138
+
139
+ # The status, then each change as it comes (the text: what changed, as
140
+ # watch tells it); --once: the status now, once.
141
+ def cmd_status_echo(rest)
142
+ path = app_path!(rest, "status echo")
143
+ code = 0
144
+ connect do |net, sv|
145
+ q = Queue.new
146
+ subscribe_status(net, path) { |st| q << [Time.now, st] } unless @opts[:once]
147
+ st = sv.statuses([path])[path]
148
+ if st
149
+ echo_event(Time.now, path, st, true)
150
+ elsif @opts[:once]
151
+ @err.puts no_status(path)
152
+ code = 1
153
+ end
154
+ next if @opts[:once]
155
+
156
+ until_stopped(net) do |left|
157
+ got = q.pop(timeout: [left || 0.2, 0.2].min)
158
+ echo_event(got[0], path, got[1], false) if got
159
+ end
160
+ end
161
+ code
162
+ end
163
+
164
+ # How often the status changes: every --period, when a change came, the
165
+ # average rate and the intervals (Hz).
166
+ def cmd_status_hz(rest)
167
+ path = app_path!(rest, "status hz")
168
+ raise ArgumentError, "--window: 1 or more" unless @opts[:window].positive?
169
+ raise ArgumentError, "--period: more than 0" unless @opts[:period].positive?
170
+ connect do |net, sv|
171
+ hz = Hz.new(window: @opts[:window])
172
+ lock = Mutex.new
173
+ subscribe_status(net, path) { |st| lock.synchronize { hz.put(st) } }
174
+ st = sv.statuses([path])[path]
175
+ lock.synchronize { hz.seed(st) } if st
176
+ @err.puts "asterism: #{path}: counting the status's changes (Ctrl-C to stop)" unless structured?
177
+ nxt = now + @opts[:period]
178
+ until_stopped(net) do |_left|
179
+ sleep 0.05
180
+ next if now < nxt
181
+ nxt += @opts[:period]
182
+ s = lock.synchronize { hz.report }
183
+ next unless s
184
+ if structured?
185
+ emit_line({ "t" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"), "path" => path }.merge(s))
186
+ else
187
+ Hz.text(s).each { @out.puts _1 }
188
+ end
189
+ @out.flush
190
+ end
191
+ end
192
+ 0
193
+ end
194
+
195
+ # ------------------------------------------------------------ completion
196
+
197
+ def cmd_completion(rest)
198
+ case rest.shift
199
+ when "bash" then @out.write(Complete::BASH)
200
+ when "zsh" then @out.write(Complete::ZSH)
201
+ else raise ArgumentError, "completion: bash or zsh (source <(asterism completion bash))"
202
+ end
203
+ 0
204
+ end
205
+
206
+ # The candidates for the word being completed, one a line. The words
207
+ # come as arguments (the last one the word being completed), after
208
+ # "--"; or (--bash LINE) as bash's line up to the cursor.
209
+ def cmd_complete(argv)
210
+ bash = argv.first == "--bash"
211
+ words = if bash
212
+ Complete.split_line(argv[1].to_s)
213
+ else
214
+ argv.first == "--" ? argv.drop(1) : argv
215
+ end
216
+ words = [""] if words.empty?
217
+ @complete_words = words
218
+ cands = Complete.new(self, CompleteSource.new(self, words)).candidates(words)
219
+ cands = Complete.bash_trim(cands, words.last) if bash && cands != [Complete::FILES]
220
+ cands.each { @out.puts _1 }
221
+ @out.flush
222
+ # A connection still trying past the budget is left behind: the
223
+ # process ends without waiting for it.
224
+ Process.exit!(0) if @complete_abandoned
225
+ 0
226
+ end
227
+
228
+ # What __complete asks the network for, through the cache: the names
229
+ # (the tokens) and an object's methods (its meta).
230
+ class CompleteSource
231
+ def initialize(cli, words)
232
+ @cli = cli
233
+ @words = words
234
+ end
235
+
236
+ def names
237
+ cached("names") do
238
+ @cli.complete_fetch { |sv| { "apps" => sv.apps, "objects" => sv.objects } }
239
+ end
240
+ end
241
+
242
+ def methods(path)
243
+ cached("methods #{path}") do
244
+ @cli.complete_fetch { |sv| sv.metas([path])[path]&.map { _1["name"] } }
245
+ end
246
+ end
247
+
248
+ def all_methods
249
+ cached("methods") do
250
+ @cli.complete_fetch { |sv| sv.metas(sv.objects).values.flatten.map { _1["name"] }.uniq.sort }
251
+ end
252
+ end
253
+
254
+ private
255
+
256
+ def cached(entry, &blk)
257
+ net = @cli.complete_network(@words)
258
+ return nil if net.none?
259
+ dir = Complete::Cache.dir(@cli.env)
260
+ return blk.call unless dir
261
+ Complete::Cache.new(dir: dir, network: net).fetch(entry, &blk)
262
+ end
263
+ end
264
+
265
+ attr_reader :env
266
+
267
+ # Where the network is for __complete: the options among the words,
268
+ # else the environment (network). [router, credentials (its full path),
269
+ # peer]
270
+ def complete_network(words)
271
+ words.each_cons(2) do |a, b|
272
+ case a
273
+ when "--router" then @opts[:router] = b
274
+ when "--credentials" then @opts[:credentials] = b
275
+ when "--peer" then @opts[:peer] = b
276
+ end
277
+ end
278
+ r, c, pr = network
279
+ [r, c && File.expand_path(c), pr]
280
+ end
281
+
282
+ # Connects for at most Complete::BUDGET seconds and yields the Survey;
283
+ # the block's value, or nil when the network did not answer in time.
284
+ def complete_fetch
285
+ @opts[:settle] = 0.6
286
+ @opts[:timeout] = 0.6
287
+ out = Queue.new
288
+ th = Thread.new do
289
+ err = @err
290
+ @err = File.open(File::NULL, "w")
291
+ begin
292
+ connect { |_net, sv| out << yield(sv) }
293
+ rescue StandardError, ScriptError
294
+ out << nil
295
+ ensure
296
+ @err = err
297
+ end
298
+ end
299
+ v = out.pop(timeout: Complete::BUDGET)
300
+ if v.nil? && th.alive?
301
+ @complete_abandoned = true
302
+ return nil
303
+ end
304
+ th.join(0.5) || (@complete_abandoned = true)
305
+ v
306
+ end
307
+
308
+ # ------------------------------------------------------------ helpers
309
+
310
+ def app_path!(rest, what)
311
+ path = rest.shift
312
+ ok = path && path.split("/", -1).size == 2 && path.split("/").none? { _1.empty? || _1.include?("*") }
313
+ raise ArgumentError, "#{what}: which application? (asterism #{what} NODE/APP)" unless ok
314
+ path
315
+ end
316
+
317
+ def no_status(path)
318
+ "asterism: no status from #{path} (not there, no status, or no answer in time)"
319
+ end
320
+
321
+ # node/app[/object] painted part by part.
322
+ def paint_path(path)
323
+ parts = path.split("/")
324
+ roles = %i[node app object]
325
+ parts.each_with_index.map { |p, i| paint(p, roles[i] || :object) }.join(paint("/", :note))
326
+ end
327
+
328
+ # An application (a tree's entry) under its name: its status and its
329
+ # objects (how many methods, and the object's description).
330
+ def app_block(name, a, indent)
331
+ pad = " " * indent
332
+ @out.puts "#{pad}#{paint(name, :app)}"
333
+ status_block(a, indent + 2)
334
+ return if a["objects"].empty?
335
+ @out.puts "#{pad} #{paint('objects:', :note)}"
336
+ a["objects"].each do |o|
337
+ ms = o["methods"]
338
+ @out.puts "#{pad} #{paint(o['name'], :object)} #{paint(ms ? count(ms.size, 'method') : '(no meta)', :note)}"
339
+ Text.wrap_text(o["doc"], style.width, first: "#{pad} ").each { @out.puts _1 } if o["doc"]
340
+ end
341
+ end
342
+
343
+ # The status's puts of one application, cleaned (Status.parse).
344
+ def subscribe_status(net, path)
345
+ net.session.subscribe("asterism/#{path}/@status") do |sample|
346
+ got = ::Asterism::Status.parse(sample.key, sample.payload)
347
+ yield got[2] if got
348
+ end
349
+ end
350
+
351
+ # Yields until Ctrl-C, --duration or a lost connection (the seconds
352
+ # left of --duration, or nil).
353
+ def until_stopped(net)
354
+ deadline = @opts[:duration] && (now + @opts[:duration])
355
+ loop do
356
+ left = deadline && (deadline - now)
357
+ break if left && left <= 0
358
+ break unless net.connected?
359
+ yield left
360
+ end
361
+ end
362
+
363
+ def now
364
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
365
+ end
366
+
367
+ # One record of status echo: JSON / YAML, or the text (the status whole
368
+ # the first time, then a line for each change).
369
+ def echo_event(t, path, status, first)
370
+ status = json_safe(status)
371
+ if structured?
372
+ h = { "t" => t.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"), "path" => path, "status" => status }
373
+ h["now"] = true if first
374
+ emit_line(h)
375
+ else
376
+ text = echo_text(t, path, status, first)
377
+ @out.puts text if text
378
+ end
379
+ @out.flush
380
+ end
381
+
382
+ # The text of one record of status echo (nil: no change).
383
+ def echo_text(t, path, status, first)
384
+ head = "#{paint(t.strftime('%H:%M:%S.%L'), :time)} "
385
+ old = @echo_last
386
+ @echo_last = status
387
+ if first || !old.is_a?(Hash) || !status.is_a?(Hash)
388
+ lines = Text.status_lines(status, style.width, Text.width(head))
389
+ return "#{head}#{paint(path, :app)}#{paint(' (now)', :note) if first}\n#{lines.join("\n")}"
390
+ end
391
+ changes = Text.status_diff(old, status)
392
+ return nil if changes.empty?
393
+ changes.map { |c| "#{head}#{change_text(c)}" }.join("\n")
394
+ end
395
+ end
396
+ end