asterism 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ee81a1950e5fffa0f5d299999d7878834a79d897e3b4cbf879e293fc6b00531
4
- data.tar.gz: 05a88c6ad24fafe262668cb11f4a48e7e5357678c1903207aae004ac0e518311
3
+ metadata.gz: 0c6b58e8ab7a548a803e5f44513b3b3dc0e7b7fb3fe26ecd38897c82f0db44cd
4
+ data.tar.gz: 616899e3dfc111ae5b8a930a8aa4f1725c55fe8123283d17d15fde4ae5d20875
5
5
  SHA512:
6
- metadata.gz: 4c6e015f5bd202a1cf44dc3d6ee59fe8af406cb899735df5ee828610cb82de5aa67035be2b5c9cbf1b4c603e0477eaba1e9e503f3ed901e81308ea1b98c91fbc
7
- data.tar.gz: 717902fdcce318aad8a78776f0f72b4063241868dbc8b50def6a0386f2868f764dbf356403b0ef1cbd727c5dc8df6818ced7d465cfb23338452e892d3e0c6dc1
6
+ metadata.gz: 11f82e660fda7b6daf50ac5fb39fd76d3f70a40adb2d531ed0ecce702d8aae889cc950af67d318fd16b6c92e084b8f7a0146bd5276c77ef42d4a9af8b83856c8
7
+ data.tar.gz: 947f18093015774357b5d9a1d136af8e35d7b0c33db23b90646d2ed23938c3759c710a90c8c237b6caa21c6cb46469cd923bca726c64c77ccd86406cd347ed93
data/CHANGELOG.md CHANGED
@@ -6,6 +6,77 @@ the earlier ones are only in this repository. From 0.4.0 the two Zenoh
6
6
  bindings (asterism-zenoh, picoruby-asterism-zenoh) carry the same minor
7
7
  version (a patch release of one may come without the other).
8
8
 
9
+ ## 0.10.0
10
+
11
+ The command line and its MCP bridge only; the library and the wire are as
12
+ in 0.9.0.
13
+
14
+ - `asterism mcp` has a tool `asterism_values`: the latest value of each
15
+ key the applications put on (`asterism/<node>/<app>/<object>/<more>`,
16
+ e.g. a game table's state), as the server received them since it
17
+ started (it subscribes to `asterism/**` from the start). Arguments
18
+ `pattern` (`*`, `**`; default every key) and `wait` (seconds, at most
19
+ 30: for the first put that matches when none has come yet). Each value
20
+ has its time, its age, its size and whether its application is still on
21
+ the network (`app`: `alive` / `gone` / `restarted`). Limits: 1000 keys
22
+ and 16 MiB kept (the oldest forgotten first), a put over 256 KiB as its
23
+ size only, a value's JSON cut at 8192 bytes in an answer
24
+ (`truncated`), 100 keys an answer (`more`). A viewer's certificate is
25
+ enough. README "MCP".
26
+ - `asterism object get PATH [KEY]`: the next value the application puts
27
+ there (`--timeout`, 5 s). `asterism object echo PATH [KEY]`: the values
28
+ as they come (the text: a key's value whole, then what changed;
29
+ `--raw`, `--once`, `--duration`). `-o json` / `-o yaml`, and Tab
30
+ completion of `PATH`. Both say so when nothing came (not readable, or
31
+ not changed).
32
+ - The other tools, commands and their output (`--json` too) are as they
33
+ were; `asterism --help`, `asterism object` and the MCP server's
34
+ instructions mention the new ones.
35
+
36
+ ## 0.9.0
37
+
38
+ The command line only; the library and the wire are as in 0.8.0 (0.8.1
39
+ was not published: its changes are here).
40
+
41
+ - The command line's commands are a noun and a verb, as ros2's are:
42
+ `node list` / `node info NODE`, `app list` / `app info NODE/APP`,
43
+ `object list [PATTERN]` / `object info PATH` / `object find METHOD`
44
+ (the objects that have a method, `*` as a wildcard), `status get` /
45
+ `status echo` / `status hz NODE/APP`. The lists print the names only, a
46
+ name a line, sorted (for grep and xargs). `asterism NOUN` lists its
47
+ verbs; `asterism --help` is grouped by noun. `call`, `ping`, `watch`,
48
+ `tree` and `mcp` are as they were, and the older `nodes`, `objects` and
49
+ `echo` stay with the same output. README "The command line".
50
+ - `status echo NODE/APP`: the status, then each change as it comes (the
51
+ text tells what changed, as `watch` does; `--once`: the status now,
52
+ once). `status hz NODE/APP`: how often the status changes, as
53
+ `ros2 topic hz` (the average rate and the intervals' min, max and
54
+ standard deviation, every `--period`; `--window`).
55
+ - `-o json` / `-o yaml` on every command that reads (YAML with the
56
+ standard library, the same content as the JSON). `--json` is `-o json`;
57
+ its output is as it was, byte for byte.
58
+ - Tab completion: `asterism completion bash` / `zsh` prints a script
59
+ (`source <(asterism completion bash)`). The commands, verbs and options
60
+ complete without the network; the nodes, `node/app`, `node/app/object`
61
+ (a part at a time) and an object's methods come from the network named
62
+ on the line or in the environment, asked for 1.5 s at most and
63
+ remembered for 20 s in `$XDG_CACHE_HOME/asterism/` (names only; never a
64
+ key or a certificate). README "Tab completion".
65
+ - The command line's text is easier to read (`--json` is unchanged): a
66
+ status is shown as indented `key=value` lines instead of one line of
67
+ JSON (`tree`, `nodes`, the first status of `watch`); `tree` shows how
68
+ many methods each object has and `tree --methods` lists them, wrapped to
69
+ the width; `objects` wraps the objects' and methods' descriptions to the
70
+ width in one column, the signatures in bold; `watch` shows only what
71
+ changed in a status (`~ status games/games mahjong1.members 0→1`, keys
72
+ that came with `+`, keys that went with `-`). On a terminal the text has
73
+ colours and the terminal's width; to a pipe or a file it is plain and 80
74
+ columns wide (or `COLUMNS`); `NO_COLOR` turns the colours off. Standard
75
+ library only. README "The command line".
76
+ - The command line takes `ASTERISM_CREDENTIALS` and `ASTERISM_ROUTER`
77
+ from the environment when none of `--router`, `--credentials`, `--peer`
78
+ is given (all or nothing). README "The command line".
79
+
9
80
  ## 0.8.0
10
81
 
11
82
  Needs asterism-zenoh 0.4.1 or later, as 0.7.1. Wire protocol 1 as before:
data/README.md CHANGED
@@ -101,7 +101,7 @@ build but not yet tested. To install from the repositories instead:
101
101
 
102
102
  ```
103
103
  (cd ../asterism-zenoh && rake gem) && rake gem
104
- gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.4.1.gem pkg/asterism-0.8.0.gem
104
+ gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.4.1.gem pkg/asterism-0.10.0.gem
105
105
  ```
106
106
 
107
107
  From the working trees, without installing:
@@ -130,7 +130,7 @@ rake # every suite
130
130
  rake test:msgs # generator, type hashes, CDR, bundled types (CRuby only; no Zenoh, no docker)
131
131
  rake test:objects # objects and Asterism::ROS between CRuby sessions over a local peer link
132
132
  rake test:api # the CRuby API (blocks, receiving threads, Enumerators)
133
- rake test:cli # the command line (exe/asterism) and its MCP bridge (the mcp gem's client; skipped without it)
133
+ rake test:cli # the command line (exe/asterism), its Tab completion and its MCP bridge (the mcp gem's client; skipped without it)
134
134
  ASTERISM_TEST_ROUTER=tcp/127.0.0.1:7447 rake test:objects test:api # the same through a zenohd router
135
135
  rake test:mruby # the shared layer on mruby (builds mruby; not part of `rake`)
136
136
  rake test:protocol # the wire protocol's conformance samples and driver against CRuby (docs/protocol.md)
@@ -415,31 +415,103 @@ What may be shared between threads:
415
415
  The gem has a command, `asterism`, that looks at a network from a terminal
416
416
  (CRuby). It is one more node: it connects, reads the liveliness tokens,
417
417
  the meta, the profiles and the statuses, and calls a method only when told
418
- to with `call`.
418
+ to with `call`. Its commands are a noun and a verb, as ros2's are
419
+ (`asterism node` alone lists the node's verbs, `asterism --help` all of
420
+ them):
419
421
 
420
422
  ```
421
- asterism nodes --router tcp/192.0.2.2:7447 # nodes, their apps (with their status), profiles
422
- asterism objects --router tcp/192.0.2.2:7447 'games/games/*' # objects and their methods
423
- asterism tree --credentials admin.tar # nodes > apps > objects, with methods and statuses
424
- asterism watch --router tcp/192.0.2.2:7447 # nodes, apps and objects coming and going, statuses
425
- asterism ping --router tcp/192.0.2.2:7447 games/games/othello1 # round trips (its meta; no method runs)
426
- asterism ping ... games/games/othello1 --call view '"<token>"' # round trips of a call
427
- asterism call --router tcp/192.0.2.2:7447 games/games/othello1 join '"Alice"'
428
- asterism call ... linux/demo/screen say hello --kwargs '{color: 3}'
423
+ asterism node list --router tcp/192.0.2.2:7447 # the nodes, a name a line
424
+ asterism node info games # one node: profile, apps, statuses, objects
425
+ asterism app list # node/app, a line each
426
+ asterism app info games/games # its status and its objects
427
+ asterism object list 'games/games/*' # node/app/object, a line each
428
+ asterism object info games/games/othello1 # its description and its methods
429
+ asterism object find join # the objects that have join (* as a wildcard)
430
+ asterism object get games/games/othello1 state # the next value the application puts there
431
+ asterism object echo games/games/othello1 state # the values as they come (what changed)
432
+ asterism status get games/games # the status now
433
+ asterism status echo games/games # the status, then each change (--once: now, once)
434
+ asterism status hz games/games # how often it changes
435
+ asterism call games/games/othello1 join '"Alice"'
436
+ asterism call linux/demo/screen say hello --kwargs '{color: 3}'
437
+ asterism ping games/games/othello1 # round trips (its meta; no method runs)
438
+ asterism ping games/games/othello1 --call view '"<token>"' # round trips of a call
439
+ asterism watch # nodes, apps and objects coming and going, statuses
440
+ asterism tree --credentials admin.tar # nodes > apps > objects (--methods: the methods)
441
+ asterism completion bash # Tab completion (below)
429
442
  ```
430
443
 
444
+ The older commands stay, with the same output: `nodes` (every node with
445
+ its applications and statuses, in one listing), `objects [PATTERN]` (each
446
+ object with its description and methods) and `echo` (`ping`).
447
+
431
448
  ```
432
- $ asterism tree --router tcp/192.0.2.2:7447
449
+ $ export ASTERISM_ROUTER=tcp/192.0.2.2:7447
450
+ $ asterism node list
451
+ games
452
+ linux
453
+ $ asterism object list
454
+ games/games/mahjong1
455
+ games/games/othello1
456
+ linux/demo/screen
457
+ $ asterism app info games/games
458
+ games/games
459
+ status:
460
+ mahjong1: st=wait members=0 humans=0 cpus=0 ais=0 away=0
461
+ othello1: st=wait members=0 humans=0 cpus=0 away=0
462
+ objects:
463
+ mahjong1 18 methods
464
+ A riichi mahjong table (four seats). From a program: call describe first,
465
+ then join, sit, and look / choose in a loop.
466
+ othello1 11 methods
467
+ An othello table (two seats). join, sit, then view / move in a loop.
468
+ $ asterism object info games/games/othello1
469
+ games/games/othello1
470
+ An othello table (two seats). join, sit, then view / move in a loop.
471
+ join(name) Joins the table as name (or gets this node's
472
+ membership back). Returns {"token", ...}: every later
473
+ call brings the token. {"error" => why} when the table
474
+ is full.
475
+ mine() This node's membership ({"token", ...}), or nil when
476
+ it has not joined.
477
+ ...
478
+ $ asterism object find join
479
+ games/games/mahjong1
480
+ games/games/othello1
481
+ $ asterism status get games/games -o yaml
482
+ ---
483
+ mahjong1:
484
+ st: wait
485
+ members: 0
486
+ ...
487
+ $ asterism status echo games/games
488
+ 12:00:01.204 games/games (now)
489
+ mahjong1: st=wait members=0 humans=0 cpus=0 ais=0 away=0
490
+ othello1: st=wait members=0 humans=0 cpus=0 away=0
491
+ 12:00:05.120 mahjong1.members 0→1
492
+ 12:00:05.133 mahjong1.humans 0→1
493
+ $ asterism status hz games/games
494
+ average rate: 0.409 Hz
495
+ min: 0.013 s max: 4.876 s std dev: 2.432 s window: 2
496
+ $ asterism tree
433
497
  games (cruby) "asterism-games"
434
- games status {"mahjong1":{"st":"wait","members":0,"humans":0,"cpus":0,"away":0}, ...}
435
- mahjong1 join(1) mine(0) leave(1) sit(2) stand(1) ...
498
+ games
499
+ status:
500
+ mahjong1: st=wait members=0 humans=0 cpus=0 ais=0 away=0
501
+ othello1: st=wait members=0 humans=0 cpus=0 away=0
502
+ mahjong1 18 methods
503
+ othello1 11 methods
436
504
  linux (family_mruby/sim)
437
- game
438
- $ asterism watch --router tcp/192.0.2.2:7447
439
- 12:00:01.204 ~ status games/games {"othello1":{"st":"wait","members":0,...}} (now)
505
+ demo
506
+ screen 3 methods
507
+ $ asterism watch
508
+ 12:00:01.204 ~ status games/games (now)
509
+ mahjong1: st=wait members=0 humans=0 cpus=0 ais=0 away=0
510
+ othello1: st=wait members=0 humans=0 cpus=0 away=0
440
511
  12:00:04.881 + node wasm-1a2b3c
441
512
  12:00:04.881 + app wasm-1a2b3c/game
442
- 12:00:05.120 ~ status games/games {"othello1":{"st":"wait","members":1,...}}
513
+ 12:00:05.120 ~ status games/games mahjong1.members 0→1
514
+ 12:00:05.133 ~ status games/games mahjong1.humans 0→1
443
515
  ```
444
516
 
445
517
  - **Where the network is.** `--router tcp/HOST:7447` (a router without TLS),
@@ -447,7 +519,12 @@ $ asterism watch --router tcp/192.0.2.2:7447
447
519
  and its name; `--router` overrides the router). `--peer LOCATOR` links
448
520
  straight to a node listening there (no router). The node ID is the
449
521
  certificate's name, otherwise `cli-` and 6 hex digits (`--node`); the
450
- application `cli-` and 4 (`--app`).
522
+ application `cli-` and 4 (`--app`). With none of `--router`,
523
+ `--credentials`, `--peer` on the line, `ASTERISM_CREDENTIALS` and
524
+ `ASTERISM_ROUTER` from the environment are used instead (all or nothing:
525
+ a router on the line never meets a `.tar` from the environment), so
526
+ `export ASTERISM_CREDENTIALS=~/me.tar` makes `asterism tree` enough, and
527
+ an MCP client can pass it in the server's environment.
451
528
  - **What it sees** is what the router lets it see. With a console's `.tar`
452
529
  that is the row's roles: a role for looking only is in the console's
453
530
  docs/g1_5.md ("Looking only: a viewer role"): it reads every node, object,
@@ -458,13 +535,75 @@ $ asterism watch --router tcp/192.0.2.2:7447
458
535
  literal (nil, true, false, numbers, strings, symbols, arrays, hashes; read
459
536
  with Ripper, nothing is evaluated), else the text as written. `--kwargs`
460
537
  is a JSON object or a Ruby Hash.
461
- - **Output**: text, or `--json` (`watch --json`: one object a line, with
462
- the time). `--timeout` (2.0 s) is each get's and call's limit, `--settle`
538
+ - **Lists** (`node list`, `app list`, `object list`, `object find`) print
539
+ the names only, a name a line, sorted, so they go to `grep` and `xargs`
540
+ as they are (on a terminal the parts are coloured; to a pipe they are
541
+ plain).
542
+ - **Output**: text, or `-o json` / `-o yaml` on every command that reads
543
+ (the YAML holds what the JSON does; `--json` is `-o json`, its output as
544
+ it always was). The streams (`watch`, `status echo`, `status hz`) give a
545
+ JSON object a line, or a YAML document each (`---`), with the time. The
546
+ text is for people: on a terminal it has colours (nodes, applications,
547
+ objects, the `+` `-` `~` of watch; `NO_COLOR=1` turns them off) and is
548
+ wrapped to the terminal's width; written to a pipe or a file it is plain
549
+ and 80 columns wide (or `COLUMNS`). A status is shown as `key=value`, a
550
+ line for each level that holds Hashes (a long text or array is cut with
551
+ `…`; `-o json` has it whole). `tree` tells how many methods an object
552
+ has, `tree --methods` lists them; `object info` (and `objects`) lists
553
+ each method with its description. `watch` and `status echo` show a
554
+ status whole the first time (and when its application comes back), then
555
+ only what changed: `a.b 0→1`, `+a.c=…` (a key came), `-a.d` (a key
556
+ went). `--timeout` (2.0 s) is each get's and call's limit, `--settle`
463
557
  (1.5 s) how long it waits for the tokens after connecting.
558
+ - **object get / echo PATH [KEY]** read what an application puts on its
559
+ own keys, `asterism/<node>/<app>/<object>/<more>` (docs/protocol.md 2.2;
560
+ a game table puts its state on `<table>/state`). `PATH` is
561
+ `node/app/object` (`*` for a part), `KEY` the rest (`*` and `**` as whole
562
+ parts; `**` by default). A put is kept nowhere and a get has no answer
563
+ for it, so both wait for puts: `object get` prints the next one and ends
564
+ (`--timeout`, 5 s; an application puts when its value changes, so a quiet
565
+ one gives nothing), `object echo` prints them as they come: a key's
566
+ value whole the first time, then only what changed in it, as
567
+ `status echo` does (`--raw`: each value whole; `--once`: the first put;
568
+ `--duration`). The value is MessagePack (Asterism's own) or JSON as it
569
+ is, other text as a string, bytes as their count; `-o json` gives each
570
+ put as `{"key", "t", "bytes", "format", "value"}`. When nothing comes
571
+ they say so: the router does not let this node read the key (a role
572
+ needs `read` on it), or nothing changed.
573
+ - **status hz** counts the changes of the status (a put of the same status
574
+ again is not one) and prints, every `--period` (1 s) in which a change
575
+ came, the average rate and the intervals' min, max and standard
576
+ deviation over the last `--window` (1000) intervals, as `ros2 topic hz`
577
+ does. An application puts its status when it changes, so this is how
578
+ often the application has something new to tell.
464
579
  - **watch** shows the applications and objects themselves (their tokens),
465
580
  not only the nodes: a node with two applications stays while one closes,
466
581
  and `watch` shows that one leave.
467
582
 
583
+ ### Tab completion
584
+
585
+ ```
586
+ source <(asterism completion bash) # or put this line in ~/.bashrc
587
+ source <(asterism completion zsh) # zsh, after compinit (~/.zshrc)
588
+ ```
589
+
590
+ The commands, the verbs and the options complete without the network. The
591
+ names on the network complete too: the nodes, `node/app`, `node/app/object`
592
+ a part at a time (`ga<Tab>` gives `games/`, `games/games/<Tab>` the
593
+ objects), and after `call PATH` (or `ping PATH --call`) the object's
594
+ methods. On each Tab the script runs `asterism __complete`, which asks the
595
+ network named on the line (`--router`, `--credentials`, `--peer`) or in
596
+ `ASTERISM_CREDENTIALS` / `ASTERISM_ROUTER`:
597
+
598
+ - briefly: what does not answer within 1.5 s gives no candidates, and the
599
+ shell is not kept waiting;
600
+ - remembered for 20 s, a file per network in `$XDG_CACHE_HOME/asterism/`
601
+ (`~/.cache/asterism/`; the directory 0700, the files 0600), named by a
602
+ digest of where the network is. It holds the names only, never a key or
603
+ a certificate. What did not answer is not remembered;
604
+ - as one more node for a moment (a `cli-` node, or the certificate's name),
605
+ so `asterism watch` shows it come and go.
606
+
468
607
  ### MCP: Asterism as tools of an AI assistant
469
608
 
470
609
  `asterism mcp` is an MCP server on stdio (it needs the `mcp` gem, which
@@ -481,6 +620,7 @@ claude mcp add asterism -- asterism mcp --credentials admin.tar --allow 'games/o
481
620
  | `asterism_nodes` | the nodes, their profiles, applications (with their statuses) and objects |
482
621
  | `asterism_objects` | the objects and their methods (meta); `callable_as` names the tool for an allowed method |
483
622
  | `asterism_status` | the applications' statuses (`node` / `app` narrow it) |
623
+ | `asterism_values` | the latest value of each key the applications put on (`asterism/<node>/<app>/<object>/<more>`, e.g. a game table's state), as this server received them since it started; `pattern` (`node/app/object/more`, `*` and `**`; default `**`) and `wait` (seconds, at most 30) |
484
624
  | `<node>.<app>.<object>.<method>` | one per method an `--allow` pattern names, made from the meta: the arguments' names it tells are the inputs and its doc the description (with the object's doc after it); without names, arity n gives the inputs `arg1` .. `argN` (any JSON value), arity -1 an array `args`; `kwargs` is an object of keyword arguments. The answer is `{"value": ...}`, or an error with its kind |
485
625
 
486
626
  - `--allow PATTERN` (repeatable): `[node/][app/]object.method`, `*` within
@@ -495,6 +635,38 @@ claude mcp add asterism -- asterism mcp --credentials admin.tar --allow 'games/o
495
635
  gives the MCP client its own seat. `--app` names this application (a
496
636
  game host that follows its players by the application `game` sees an
497
637
  MCP client started with `--app game` come and go).
638
+ - **asterism_values**: from its start the server subscribes to
639
+ `asterism/**` (the applications' own keys; `@profile` and `@status` are
640
+ not under it) and keeps the last value of each key with the time it
641
+ came. A put comes only when a value changes and is kept nowhere else, so
642
+ a key that has not been put since the server started is not there:
643
+ `wait` waits (at most 30 s) for the first put that matches when none has
644
+ come yet. Each value has `key`, `value`, `t`, `age_s` (seconds since it
645
+ came), `bytes`, `format` and `app`: `alive`, `gone` (the application is
646
+ not on the network now; the value is its last one, not the present) or
647
+ `restarted` (it left and came back after this value). A value whose JSON
648
+ is longer than 8192 bytes is cut (`truncated: true`, the value the start
649
+ of its JSON text); the server keeps at most 1000 keys and 16 MiB (the
650
+ key received longest ago is forgotten first; `forgotten` counts them), a
651
+ single put over 256 KiB as its size only, and an answer lists at most
652
+ 100 keys (`more`). Nothing that matches: `note` says so (not put since
653
+ the start, or the router does not let the server read it). A console's
654
+ viewer role (`read` / `watch` on `asterism/**`) is enough.
655
+
656
+ Showing an othello table to an AI assistant (the game host puts the
657
+ table on `games/games/othello1/state` after every move):
658
+
659
+ ```
660
+ claude mcp add asterism -- asterism mcp --credentials viewer.tar
661
+ > Watch the othello game on games/games/othello1 and tell me who is winning.
662
+ (the assistant calls asterism_values with pattern games/games/othello1/state, wait 30)
663
+ {"since":"2026-10-20T02:16:50.191Z","values":[{"key":"games/games/othello1/state",
664
+ "t":"2026-10-20T02:16:58.583Z","age_s":0.4,"app":"alive","bytes":181,"format":"msgpack",
665
+ "value":{"v":15,"t":"othello1","st":"play","nm":["CPU","Alice"],"sk":[2,1],...,
666
+ "bd":"..W.......W.......WBWWW...WBB....W.BBB..........................",
667
+ "tn":0,"n":10,"last":"c1","lg":"b1b2e2f2g2b3h3b4a6","cnt":[6,8]}}]}
668
+ ```
669
+
498
670
  - An object that describes its methods at `expose` (`args:` and `doc:`,
499
671
  "Describing the methods" below) gives the AI assistant the names and the
500
672
  texts; without them it sees only the number of arguments.
@@ -0,0 +1,283 @@
1
+ # The shell's Tab completion: `asterism completion bash|zsh` prints a
2
+ # script, and the script calls the hidden `asterism __complete` on each
3
+ # Tab, which prints the candidates, one a line.
4
+ #
5
+ # The commands, the verbs and the options come from the command line's own
6
+ # tables (no network). The names on the network (nodes, node/app,
7
+ # node/app/object, an object's methods) come from the network named on the
8
+ # line (--router, --credentials, --peer) or in the environment, asked
9
+ # briefly (Complete::BUDGET: nothing comes back when it does not answer in
10
+ # time, the shell is not kept waiting) and remembered for a few seconds
11
+ # (Cache: a file per network under $XDG_CACHE_HOME/asterism, named by a
12
+ # digest of where the network is; it holds names only, never a key or a
13
+ # certificate). A name with "/" is completed a part at a time:
14
+ # `ga<Tab>` gives `games/`, `games/games/<Tab>` the objects.
15
+ require "json"
16
+ require "digest"
17
+ require "fileutils"
18
+ require "shellwords"
19
+
20
+ module Asterism
21
+ class CLI
22
+ class Complete
23
+ # Seconds the network may take to answer one Tab (connecting, the
24
+ # tokens, a meta get).
25
+ BUDGET = 1.5
26
+ # What a line of the output means "complete a file name here".
27
+ FILES = ":files"
28
+
29
+ # spec: the CLI (its command tables and option lists); source: what
30
+ # the network names (#names, #methods(path), #all_methods), each
31
+ # nil when it did not answer.
32
+ def initialize(spec, source)
33
+ @spec = spec
34
+ @source = source
35
+ end
36
+
37
+ # The candidates for the last word of words (the words after
38
+ # `asterism`, the last one the word being completed, "" for a new
39
+ # one), those that start with it, sorted.
40
+ def candidates(words)
41
+ words = words.dup
42
+ cur = words.pop || ""
43
+ pos = []
44
+ prev = nil
45
+ words.each do |w|
46
+ if prev
47
+ prev = nil
48
+ next
49
+ end
50
+ if w.start_with?("-") && w.size > 1
51
+ prev = w if @spec.option_takes_value?(w)
52
+ next
53
+ end
54
+ pos << w
55
+ end
56
+ key, args = @spec.command_key(pos)
57
+ list =
58
+ if prev then values(prev, key, args)
59
+ elsif cur.start_with?("-") then key ? @spec.options_for(key) : []
60
+ else positional(pos, key, args, cur)
61
+ end
62
+ return list if list == [FILES]
63
+ list.select { _1.start_with?(cur) }.uniq.sort
64
+ end
65
+
66
+ private
67
+
68
+ def values(opt, key, args)
69
+ case opt
70
+ when "-o", "--output" then %w[json yaml]
71
+ when "--credentials" then [FILES]
72
+ when "--call" then key == "ping" && args[0] ? methods_of(args[0]) : []
73
+ else []
74
+ end
75
+ end
76
+
77
+ def positional(pos, key, args, cur)
78
+ return @spec.command_words if pos.empty?
79
+ return @spec.verbs(pos[0]) if pos.size == 1 && @spec.noun?(pos[0])
80
+ return [] unless key
81
+ i = args.size
82
+ case [key, i]
83
+ when ["node info", 0] then nodes
84
+ when ["app info", 0], ["status get", 0], ["status echo", 0], ["status hz", 0] then staged(cur, apps, 2)
85
+ when ["object list", 0], ["object info", 0], ["object get", 0], ["object echo", 0], ["objects", 0], ["call", 0]
86
+ staged(cur, objects, 3)
87
+ when ["ping", 0], ["echo", 0] then (cur.include?("/") ? [] : nodes) + staged(cur, objects, 3)
88
+ when ["object find", 0] then @source.all_methods || []
89
+ when ["call", 1] then methods_of(args[0])
90
+ when ["completion", 0] then %w[bash zsh]
91
+ when ["help", 0] then @spec.nouns
92
+ else []
93
+ end
94
+ end
95
+
96
+ def methods_of(path)
97
+ path.count("/") == 2 ? (@source.methods(path) || []) : []
98
+ end
99
+
100
+ def names
101
+ @names ||= @source.names || { "apps" => [], "objects" => [] }
102
+ end
103
+
104
+ def objects
105
+ names["objects"]
106
+ end
107
+
108
+ # The applications, with those that only objects tell of.
109
+ def apps
110
+ (names["apps"] + objects.map { _1.split("/")[0, 2].join("/") }).uniq
111
+ end
112
+
113
+ def nodes
114
+ (apps + objects).map { _1.split("/")[0] }.uniq
115
+ end
116
+
117
+ # The next part of the paths (depth parts each) that start with cur:
118
+ # the parts up to the one cur is in, with "/" after it when more come.
119
+ def staged(cur, paths, depth)
120
+ k = cur.count("/")
121
+ return [] if k >= depth
122
+ paths.filter_map do |p|
123
+ s = p.split("/")
124
+ next if s.size < depth
125
+ c = s[0..k].join("/")
126
+ c += "/" if k + 1 < depth
127
+ c if c.start_with?(cur)
128
+ end
129
+ end
130
+
131
+ public
132
+
133
+ # The words of a command line up to the cursor (bash's COMP_LINE cut
134
+ # at COMP_POINT): the words after the command, the last one the word
135
+ # being completed ("" after a space). An open quote is closed.
136
+ def self.split_line(line)
137
+ words = begin
138
+ Shellwords.split(line)
139
+ rescue ArgumentError
140
+ begin
141
+ Shellwords.split("#{line}\"")
142
+ rescue ArgumentError
143
+ Shellwords.split("#{line}'")
144
+ end
145
+ end
146
+ words << "" if line.match?(/\s\z/) || words.empty?
147
+ words.drop(1)
148
+ end
149
+
150
+ # Bash splits the word being completed at ":" and "=" (COMP_WORDBREAKS):
151
+ # the candidates without what comes before the last of them in cur.
152
+ def self.bash_trim(cands, cur)
153
+ cut = cur.rindex(/[:=]/)
154
+ return cands unless cut
155
+ cands.map { _1.start_with?(cur[0..cut]) ? _1[(cut + 1)..] : _1 }
156
+ end
157
+
158
+ # ------------------------------------------------------------ the cache
159
+
160
+ # What __complete remembers of a network for a few seconds: a JSON
161
+ # file per network ({entry => {"t" => seconds, "v" => value}}), the
162
+ # directory 0700 and the files 0600. The network's identity is a
163
+ # digest of where it is (router, the credentials file's path, peer);
164
+ # nothing else about it is written.
165
+ class Cache
166
+ TTL = 20
167
+
168
+ def initialize(dir:, network:, ttl: TTL, clock: -> { Time.now.to_f })
169
+ @dir = dir
170
+ @ttl = ttl
171
+ @clock = clock
172
+ id = network.map { _1.to_s }.join("\n")
173
+ @file = File.join(dir, "#{Digest::SHA256.hexdigest(id)[0, 24]}.json")
174
+ end
175
+
176
+ attr_reader :file
177
+
178
+ # The cache directory: $XDG_CACHE_HOME/asterism, else
179
+ # ~/.cache/asterism.
180
+ def self.dir(env)
181
+ base = env["XDG_CACHE_HOME"].to_s
182
+ base = File.join(Dir.home, ".cache") if base.empty?
183
+ File.join(base, "asterism")
184
+ rescue ArgumentError
185
+ nil
186
+ end
187
+
188
+ # The entry's value when it is fresh, else the block's (kept unless
189
+ # it is nil: what did not answer is asked again next time).
190
+ def fetch(entry)
191
+ data = read
192
+ e = data[entry]
193
+ now = @clock.call
194
+ return e["v"] if e.is_a?(Hash) && e["t"].is_a?(Numeric) && now - e["t"] < @ttl && now >= e["t"]
195
+ v = yield
196
+ return v if v.nil?
197
+ data = read.reject { |_, x| !x.is_a?(Hash) || !x["t"].is_a?(Numeric) || now - x["t"] >= @ttl }
198
+ data[entry] = { "t" => now, "v" => v }
199
+ write(data)
200
+ v
201
+ end
202
+
203
+ private
204
+
205
+ def read
206
+ h = JSON.parse(File.read(@file))
207
+ h.is_a?(Hash) ? h : {}
208
+ rescue StandardError
209
+ {}
210
+ end
211
+
212
+ def write(data)
213
+ FileUtils.mkdir_p(@dir, mode: 0o700)
214
+ tmp = "#{@file}.#{Process.pid}.tmp"
215
+ File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { _1.write(JSON.generate(data)) }
216
+ File.rename(tmp, @file)
217
+ rescue StandardError
218
+ begin
219
+ File.unlink(tmp) if tmp
220
+ rescue StandardError
221
+ nil
222
+ end
223
+ nil
224
+ end
225
+ end
226
+
227
+ # ------------------------------------------------------------ the scripts
228
+
229
+ BASH = <<~'BASH'
230
+ # asterism's Tab completion for bash: source <(asterism completion bash)
231
+ # (or put that line in ~/.bashrc). Each Tab runs `asterism __complete`;
232
+ # the names on the network come from the network named on the line
233
+ # (--router, --credentials, --peer) or in ASTERISM_CREDENTIALS /
234
+ # ASTERISM_ROUTER, asked briefly and remembered for a few seconds.
235
+ _asterism() {
236
+ local cur=${COMP_WORDS[COMP_CWORD]}
237
+ local IFS=$'\n'
238
+ local out
239
+ out=$(asterism __complete --bash "${COMP_LINE:0:COMP_POINT}" 2>/dev/null)
240
+ if [[ $out == ":files" ]]; then
241
+ compopt -o filenames 2>/dev/null
242
+ COMPREPLY=($(compgen -f -- "$cur"))
243
+ return 0
244
+ fi
245
+ COMPREPLY=($out)
246
+ local c
247
+ for c in "${COMPREPLY[@]}"; do
248
+ if [[ $c == */ ]]; then
249
+ compopt -o nospace 2>/dev/null
250
+ break
251
+ fi
252
+ done
253
+ return 0
254
+ }
255
+ complete -F _asterism asterism
256
+ BASH
257
+
258
+ ZSH = <<~'ZSH'
259
+ # asterism's Tab completion for zsh: source <(asterism completion zsh)
260
+ # after compinit (or put that line in ~/.zshrc). Each Tab runs
261
+ # `asterism __complete`; the names on the network come from the network
262
+ # named on the line (--router, --credentials, --peer) or in
263
+ # ASTERISM_CREDENTIALS / ASTERISM_ROUTER, asked briefly and remembered
264
+ # for a few seconds.
265
+ _asterism() {
266
+ local -a cands slash plain
267
+ cands=("${(@f)$(asterism __complete -- "${(@)words[2,CURRENT]}" 2>/dev/null)}")
268
+ cands=(${cands:#})
269
+ if [[ ${cands[1]} == ":files" ]]; then
270
+ _files
271
+ return
272
+ fi
273
+ slash=(${(M)cands:#*/})
274
+ plain=(${cands:#*/})
275
+ (( ${#plain} )) && compadd -- "${plain[@]}"
276
+ (( ${#slash} )) && compadd -S '' -- "${slash[@]}"
277
+ return 0
278
+ }
279
+ compdef _asterism asterism
280
+ ZSH
281
+ end
282
+ end
283
+ end