aster-cli 0.1.2__py3-none-any.whl

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,1624 @@
1
+ """
2
+ aster_cli.shell.commands -- Built-in shell commands.
3
+
4
+ Each command is a plugin: usable interactively and (where applicable)
5
+ as ``aster <noun> <verb>`` from the command line.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import sys
13
+ from typing import Any
14
+
15
+ from aster_cli.access import (
16
+ cmd_access_delegation,
17
+ cmd_access_grant,
18
+ cmd_access_list,
19
+ cmd_access_public_private,
20
+ cmd_access_revoke,
21
+ )
22
+ from aster_cli.join import cmd_join, cmd_status, cmd_verify
23
+ from aster_cli.publish import cmd_discover, cmd_publish, cmd_set_visibility, cmd_unpublish, cmd_update_service
24
+ from aster_cli.shell.plugin import (
25
+ Argument,
26
+ CommandContext,
27
+ ShellCommand,
28
+ get_commands_for_path,
29
+ register,
30
+ )
31
+ import argparse
32
+ import re
33
+ import shlex
34
+ import time
35
+
36
+ from rich.progress import Progress
37
+
38
+ from aster_cli.shell.hooks import get_hook_registry
39
+ from aster_cli.shell.invoker import invoke_method
40
+ from aster_cli.shell.vfs import (
41
+ NodeKind,
42
+ VfsNode,
43
+ ensure_directory_handle,
44
+ ensure_loaded,
45
+ resolve_path,
46
+ )
47
+
48
+
49
+ # ── Navigation ────────────────────────────────────────────────────────────────
50
+
51
+
52
+ @register
53
+ class CdCommand(ShellCommand):
54
+ name = "cd"
55
+ description = "Change directory"
56
+ contexts = [] # global
57
+
58
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
59
+ target = args[0] if args else "/"
60
+ node, path = resolve_path(ctx.vfs_root, ctx.vfs_cwd, target)
61
+
62
+ if node is None and path.startswith("/aster/@"):
63
+ node = await ensure_directory_handle(ctx.vfs_root, path.split("/")[-1], ctx.connection)
64
+
65
+ if node is None:
66
+ ctx.display.error(f"no such path: {path}")
67
+ return
68
+
69
+ if node.kind in (NodeKind.BLOB, NodeKind.METHOD, NodeKind.README, NodeKind.DOC_ENTRY):
70
+ ctx.display.error(f"{path} is not a directory")
71
+ return
72
+ # Collections are cd-able -- they have children (entries)
73
+
74
+ # Lazy-load children
75
+ await ensure_loaded(node, ctx.connection)
76
+
77
+ # Update cwd -- the caller reads ctx.vfs_cwd after execute
78
+ ctx.vfs_cwd = path # type: ignore[misc]
79
+
80
+ def get_completions(self, ctx: CommandContext, partial: str) -> list[str]:
81
+ return _complete_path(ctx, partial, dirs_only=True)
82
+
83
+
84
+ @register
85
+ class LsCommand(ShellCommand):
86
+ name = "ls"
87
+ description = "List contents of the current or specified path"
88
+ contexts = [] # global
89
+ cli_noun_verb = None # ls is context-dependent -- mapped per noun below
90
+
91
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
92
+ target = args[0] if args else "."
93
+ node, path = resolve_path(ctx.vfs_root, ctx.vfs_cwd, target)
94
+
95
+ if node is None:
96
+ ctx.display.error(f"no such path: {path}")
97
+ return
98
+
99
+ await ensure_loaded(node, ctx.connection)
100
+
101
+ if node.kind == NodeKind.ROOT:
102
+ entries = [
103
+ {"name": c.name, "kind": "dir", "detail": _kind_detail(c)}
104
+ for c in node.sorted_children()
105
+ ]
106
+ ctx.display.directory_listing(entries)
107
+
108
+ elif node.kind == NodeKind.SERVICES:
109
+ services = []
110
+ for c in node.sorted_children():
111
+ await ensure_loaded(c, ctx.connection)
112
+ services.append({
113
+ "name": c.name,
114
+ "method_count": len(c.children),
115
+ "version": c.metadata.get("version", 1),
116
+ "scoped": c.metadata.get("scoped", "shared"),
117
+ })
118
+ ctx.display.service_table(services)
119
+
120
+ elif node.kind == NodeKind.SERVICE:
121
+ methods = []
122
+ for c in node.sorted_children():
123
+ methods.append({
124
+ "name": c.name,
125
+ "pattern": c.metadata.get("pattern", "unary"),
126
+ "signature": _method_signature(c.metadata),
127
+ "timeout": c.metadata.get("timeout"),
128
+ })
129
+ ctx.display.method_table(methods, node.name)
130
+
131
+ elif node.kind == NodeKind.BLOBS:
132
+ blobs = []
133
+ for c in node.sorted_children():
134
+ blobs.append({
135
+ "hash": c.metadata.get("hash", c.name),
136
+ "size": c.metadata.get("size", "?"),
137
+ "tag": c.metadata.get("tag", ""),
138
+ "source": c.metadata.get("source", ""),
139
+ "is_collection": c.kind == NodeKind.COLLECTION,
140
+ })
141
+ ctx.display.blob_table(blobs)
142
+
143
+ elif node.kind == NodeKind.COLLECTION:
144
+ await ensure_loaded(node, ctx.connection)
145
+ entries = []
146
+ for c in node.sorted_children():
147
+ entries.append({
148
+ "name": c.name,
149
+ "hash": c.metadata.get("hash", "?"),
150
+ "size": c.metadata.get("size", 0),
151
+ })
152
+ ctx.display.collection_entry_table(entries)
153
+
154
+ elif node.kind == NodeKind.DOCS:
155
+ entries = []
156
+ for c in node.sorted_children():
157
+ entries.append(c.metadata)
158
+ ctx.display.doc_entry_table(entries)
159
+
160
+ elif node.kind == NodeKind.GOSSIP:
161
+ ctx.display.info("Gossip topics -- use 'tail' to listen to the producer mesh")
162
+ ctx.display.print(" [cyan]mesh[/cyan] [dim]producer mesh topic (derived from root key + salt)[/dim]")
163
+
164
+ elif node.kind == NodeKind.ASTER:
165
+ handles = []
166
+ for c in node.sorted_children():
167
+ await ensure_loaded(c, ctx.connection)
168
+ # Count non-README children as services
169
+ svc_count = sum(
170
+ 1 for ch in c.children.values() if ch.kind == NodeKind.SERVICE
171
+ )
172
+ handles.append({
173
+ "name": c.name,
174
+ "registered": c.metadata.get("registered", True),
175
+ "service_count": svc_count,
176
+ "description": "",
177
+ })
178
+ ctx.display.handle_listing(handles)
179
+
180
+ elif node.kind == NodeKind.HANDLE:
181
+ services = []
182
+ for c in node.sorted_children():
183
+ if c.kind == NodeKind.README:
184
+ continue
185
+ await ensure_loaded(c, ctx.connection)
186
+ published = c.metadata.get("published", True)
187
+ services.append({
188
+ "display_name": c.name,
189
+ "name": c.metadata.get("name", c.name),
190
+ "published": published,
191
+ "method_count": len([ch for ch in c.children.values() if ch.kind == NodeKind.METHOD]),
192
+ "version": c.metadata.get("version", 1),
193
+ "endpoints": c.metadata.get("endpoints", 0),
194
+ "description": c.metadata.get("description", ""),
195
+ })
196
+ # Show README hint if present
197
+ readme = node.child("README.md")
198
+ if readme:
199
+ ctx.display.info("README.md available -- use: cat README.md")
200
+ ctx.display.handle_service_listing(services, node.name)
201
+
202
+ elif node.kind == NodeKind.README:
203
+ content = node.metadata.get("content", "")
204
+ ctx.display.readme_content(content)
205
+
206
+ elif node.kind == NodeKind.COLLECTION:
207
+ await ensure_loaded(node, ctx.connection)
208
+ entries = []
209
+ for c in node.sorted_children():
210
+ entries.append({
211
+ "name": c.name,
212
+ "hash": c.metadata.get("hash", "?"),
213
+ "size": c.metadata.get("size", 0),
214
+ })
215
+ ctx.display.collection_entry_table(entries)
216
+
217
+ elif node.kind in (NodeKind.BLOB, NodeKind.METHOD, NodeKind.DOC_ENTRY):
218
+ ctx.display.json_value(node.metadata)
219
+
220
+ else:
221
+ entries = [
222
+ {"name": c.name, "kind": "dir" if c.children or not c.loaded else "file"}
223
+ for c in node.sorted_children()
224
+ ]
225
+ ctx.display.directory_listing(entries)
226
+
227
+ def get_completions(self, ctx: CommandContext, partial: str) -> list[str]:
228
+ return _complete_path(ctx, partial, dirs_only=False)
229
+
230
+
231
+ # ── Introspection ─────────────────────────────────────────────────────────────
232
+
233
+
234
+ @register
235
+ class DescribeCommand(ShellCommand):
236
+ name = "describe"
237
+ description = "Show detailed contract info for a service"
238
+ contexts = ["/services", "/services/*", "/aster/*/*"]
239
+ cli_noun_verb = ("service", "describe")
240
+
241
+ def get_arguments(self) -> list[Argument]:
242
+ return [Argument(name="service", description="Service name", positional=True)]
243
+
244
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
245
+ # Determine which service to describe
246
+ node, path = resolve_path(ctx.vfs_root, ctx.vfs_cwd, ".")
247
+ if node and node.kind == NodeKind.SERVICE:
248
+ service_name = node.name
249
+ elif args:
250
+ service_name = args[0]
251
+ else:
252
+ ctx.display.error("usage: describe <service>")
253
+ return
254
+
255
+ # Fetch contract details
256
+ try:
257
+ if node and node.kind == NodeKind.SERVICE and node.metadata.get("contract"):
258
+ contract = node.metadata.get("contract")
259
+ else:
260
+ contract = await ctx.connection.get_contract(service_name)
261
+ if contract is None:
262
+ ctx.display.error(f"no contract found for {service_name}")
263
+ return
264
+ ctx.display.contract_tree(
265
+ contract if isinstance(contract, dict) else _contract_to_dict(contract)
266
+ )
267
+ except Exception as e:
268
+ ctx.display.error(f"failed to get contract: {e}")
269
+
270
+ def get_completions(self, ctx: CommandContext, partial: str) -> list[str]:
271
+ services_node = ctx.vfs_root.child("services")
272
+ if services_node is None:
273
+ return []
274
+ return [c.name for c in services_node.sorted_children()
275
+ if c.name.lower().startswith(partial.lower())]
276
+
277
+
278
+ @register
279
+ class CatCommand(ShellCommand):
280
+ name = "cat"
281
+ description = "Display file or blob content"
282
+ contexts = [] # global -- works in blobs and directory handle contexts
283
+ cli_noun_verb = ("blob", "cat")
284
+
285
+ def get_arguments(self) -> list[Argument]:
286
+ return [Argument(name="target", description="File name or blob hash", positional=True)]
287
+
288
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
289
+ # Resolve target -- either from arg or current directory
290
+ if args:
291
+ node, path = resolve_path(ctx.vfs_root, ctx.vfs_cwd, args[0])
292
+ else:
293
+ node, path = resolve_path(ctx.vfs_root, ctx.vfs_cwd, ".")
294
+
295
+ if node is None and not args:
296
+ ctx.display.error("usage: cat <target>")
297
+ return
298
+
299
+ # Handle known node types
300
+ if node and node.kind == NodeKind.README:
301
+ content = node.metadata.get("content", "")
302
+ ctx.display.readme_content(content)
303
+ return
304
+
305
+ if node and node.kind == NodeKind.DOC_ENTRY:
306
+ key = node.metadata.get("key", node.name)
307
+ try:
308
+ content = await ctx.connection.read_doc_entry(key)
309
+ if content is None:
310
+ ctx.display.error(f"no content for doc entry: {key}")
311
+ return
312
+ _display_bytes(ctx.display, content)
313
+ except Exception as e:
314
+ ctx.display.error(f"failed to read doc entry: {e}")
315
+ return
316
+
317
+ if node and node.kind == NodeKind.COLLECTION:
318
+ # Show collection entries instead of raw binary
319
+ await ensure_loaded(node, ctx.connection)
320
+ entries = []
321
+ for c in node.sorted_children():
322
+ entries.append({
323
+ "name": c.name,
324
+ "hash": c.metadata.get("hash", "?"),
325
+ "size": c.metadata.get("size", 0),
326
+ })
327
+ ctx.display.collection_entry_table(entries)
328
+ return
329
+
330
+ # Resolve blob hash -- prefer full hash from VFS metadata
331
+ if node and node.kind == NodeKind.BLOB:
332
+ blob_hash = node.metadata.get("hash", node.name)
333
+ elif args:
334
+ blob_hash = args[0]
335
+ else:
336
+ ctx.display.error("usage: cat <target>")
337
+ return
338
+
339
+ try:
340
+ content = await ctx.connection.read_blob(blob_hash)
341
+ _display_bytes(ctx.display, content)
342
+ except Exception as e:
343
+ ctx.display.error(f"failed to read: {e}")
344
+
345
+ def get_completions(self, ctx: CommandContext, partial: str) -> list[str]:
346
+ # Complete file names in current dir
347
+ node, _ = resolve_path(ctx.vfs_root, ctx.vfs_cwd, ".")
348
+ if node is None:
349
+ return []
350
+ return [c.name for c in node.sorted_children()
351
+ if c.name.lower().startswith(partial.lower())]
352
+
353
+
354
+ @register
355
+ class SaveCommand(ShellCommand):
356
+ name = "save"
357
+ description = "Download a blob to a local file"
358
+ contexts = ["/blobs", "/blobs/*"]
359
+ cli_noun_verb = ("blob", "save")
360
+
361
+ def get_arguments(self) -> list[Argument]:
362
+ return [
363
+ Argument(name="hash", description="Blob hash (or prefix)", positional=True, required=True),
364
+ Argument(name="path", description="Local file path", positional=True, required=True),
365
+ ]
366
+
367
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
368
+ if len(args) < 2:
369
+ # If at a blob node, only need the output path
370
+ node, _ = resolve_path(ctx.vfs_root, ctx.vfs_cwd, ".")
371
+ if node and node.kind == NodeKind.BLOB and len(args) == 1:
372
+ blob_hash = node.metadata.get("hash", node.name)
373
+ out_path = args[0]
374
+ else:
375
+ ctx.display.error("usage: save <hash> <path>")
376
+ return
377
+ else:
378
+ blob_hash = args[0]
379
+ out_path = args[1]
380
+
381
+ try:
382
+ with Progress(console=ctx.display.console) as progress:
383
+ task = progress.add_task(f"Downloading {blob_hash[:12]}…", total=None)
384
+ content = await ctx.connection.read_blob(blob_hash)
385
+ progress.update(task, total=len(content), completed=len(content))
386
+
387
+ with open(out_path, "wb") as f:
388
+ f.write(content if isinstance(content, bytes) else content.encode())
389
+
390
+ ctx.display.success(f"Saved to {out_path} ({len(content)} bytes)")
391
+ except Exception as e:
392
+ ctx.display.error(f"failed to save blob: {e}")
393
+
394
+
395
+ # ── Service invocation ────────────────────────────────────────────────────────
396
+
397
+
398
+ @register
399
+ class InvokeCommand(ShellCommand):
400
+ name = "invoke"
401
+ description = "Invoke an RPC method"
402
+ contexts = ["/services/*", "/aster/*/*"]
403
+ cli_noun_verb = ("service", "invoke")
404
+
405
+ def get_arguments(self) -> list[Argument]:
406
+ return [
407
+ Argument(name="method", description="Method name", positional=True, required=True),
408
+ Argument(name="args", description="JSON arguments"),
409
+ ]
410
+
411
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
412
+ if not args:
413
+ ctx.display.error("usage: invoke <method> [key=value ...] or <method> '{json}'")
414
+ return
415
+
416
+ # If we're calling from /services/<name>, resolve service name
417
+ node, _ = resolve_path(ctx.vfs_root, ctx.vfs_cwd, ".")
418
+ if node and node.kind == NodeKind.SERVICE:
419
+ service_name = node.name
420
+ method_name = args[0]
421
+ call_args = args[1:]
422
+ elif "/" in args[0] or "." in args[0]:
423
+ # service.method or service/method syntax
424
+ parts = args[0].replace("/", ".").split(".", 1)
425
+ service_name = parts[0]
426
+ method_name = parts[1] if len(parts) > 1 else ""
427
+ call_args = args[1:]
428
+ else:
429
+ ctx.display.error("navigate to a service first, or use service.method syntax")
430
+ return
431
+
432
+ # Parse arguments
433
+ payload = _parse_call_args(call_args)
434
+
435
+ await invoke_method(ctx, service_name, method_name, payload)
436
+
437
+ def get_completions(self, ctx: CommandContext, partial: str) -> list[str]:
438
+ node, _ = resolve_path(ctx.vfs_root, ctx.vfs_cwd, ".")
439
+ if node and node.kind == NodeKind.SERVICE:
440
+ return [c.name for c in node.sorted_children()
441
+ if c.name.lower().startswith(partial.lower())]
442
+ return []
443
+
444
+
445
+ # ── Direct method invocation (./methodName syntax) ───────────────────────────
446
+
447
+
448
+ @register
449
+ class DirectInvokeCommand(ShellCommand):
450
+ """Hidden command that handles ./methodName syntax."""
451
+
452
+ name = "./"
453
+ description = "Direct method invocation"
454
+ contexts = ["/services/*", "/aster/*/*"]
455
+ hidden = True
456
+
457
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
458
+ # args[0] is the full "./methodName" or "methodName"
459
+ if not args:
460
+ return
461
+ method_name = args[0].lstrip("./")
462
+ call_args = args[1:]
463
+
464
+ node, _ = resolve_path(ctx.vfs_root, ctx.vfs_cwd, ".")
465
+ if not node or node.kind != NodeKind.SERVICE:
466
+ ctx.display.error("direct invocation requires being in a service directory")
467
+ return
468
+
469
+ # Check if the method's schema is CLI-compatible when using key=value
470
+ m_node = node.child(method_name)
471
+ if m_node and call_args:
472
+ fields = m_node.metadata.get("fields", [])
473
+ hint = _check_cli_compatible(fields)
474
+ if hint:
475
+ ctx.display.warning(hint)
476
+
477
+ payload = _parse_call_args(call_args)
478
+ await invoke_method(ctx, node.name, method_name, payload)
479
+
480
+
481
+ # ── Shell utilities ───────────────────────────────────────────────────────────
482
+
483
+
484
+ @register
485
+ class PwdCommand(ShellCommand):
486
+ name = "pwd"
487
+ description = "Print current path"
488
+ contexts = []
489
+
490
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
491
+ ctx.display.print(ctx.vfs_cwd)
492
+
493
+
494
+ @register
495
+ class HelpCommand(ShellCommand):
496
+ name = "help"
497
+ description = "Show available commands"
498
+ contexts = []
499
+
500
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
501
+
502
+ commands = get_commands_for_path(ctx.vfs_cwd)
503
+ ctx.display.print("[bold]Available commands:[/bold]")
504
+ for cmd in sorted(commands, key=lambda c: c.name):
505
+ ctx.display.print(f" [green]{cmd.name:16s}[/green] {cmd.description}")
506
+
507
+ # Show methods as direct invocations if in a service
508
+ node, _ = resolve_path(ctx.vfs_root, ctx.vfs_cwd, ".")
509
+ if node and node.kind == NodeKind.SERVICE and node.children:
510
+ ctx.display.print()
511
+ ctx.display.print("[bold]Direct invocation:[/bold]")
512
+ for child in node.sorted_children():
513
+ sig = _method_signature(child.metadata)
514
+ ctx.display.print(f" [green]./{child.name:14s}[/green] {sig}")
515
+
516
+
517
+ @register
518
+ class ExitCommand(ShellCommand):
519
+ name = "exit"
520
+ description = "Exit the shell"
521
+ contexts = []
522
+
523
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
524
+ raise SystemExit(0)
525
+
526
+
527
+ @register
528
+ class RefreshCommand(ShellCommand):
529
+ name = "refresh"
530
+ description = "Re-fetch data from the peer"
531
+ contexts = []
532
+
533
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
534
+ # Reset loaded flags so next ls/cd will re-fetch
535
+ _reset_loaded(ctx.vfs_root)
536
+ ctx.display.success("Cache cleared -- next listing will fetch fresh data")
537
+
538
+
539
+ @register
540
+ class JoinShellCommand(ShellCommand):
541
+ name = "join"
542
+ description = "Claim an Aster handle"
543
+ contexts = []
544
+
545
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
546
+
547
+
548
+ parsed = argparse.Namespace(
549
+ command="join",
550
+ handle=args[0] if args else None,
551
+ email=args[1] if len(args) > 1 else None,
552
+ announcements=False,
553
+ demo=not hasattr(ctx.connection, "_peer_addr"),
554
+ aster=getattr(ctx.connection, "_peer_addr", None),
555
+ root_key=None,
556
+ )
557
+ cmd_join(parsed)
558
+ _after_mutation(ctx)
559
+
560
+
561
+ @register
562
+ class VerifyShellCommand(ShellCommand):
563
+ name = "verify"
564
+ description = "Verify a pending handle claim"
565
+ contexts = []
566
+
567
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
568
+
569
+
570
+ parsed = argparse.Namespace(
571
+ command="verify",
572
+ code=args[0] if args else None,
573
+ resend="--resend" in args,
574
+ demo=not hasattr(ctx.connection, "_peer_addr"),
575
+ aster=getattr(ctx.connection, "_peer_addr", None),
576
+ root_key=None,
577
+ )
578
+ cmd_verify(parsed)
579
+ _after_mutation(ctx)
580
+
581
+
582
+ @register
583
+ class WhoamiShellCommand(ShellCommand):
584
+ name = "whoami"
585
+ description = "Show local identity state"
586
+ contexts = []
587
+
588
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
589
+
590
+
591
+ cmd_status(argparse.Namespace(command="whoami", raw_json=False, local_only=False, aster=getattr(ctx.connection, "_peer_addr", None), root_key=None))
592
+
593
+
594
+ @register
595
+ class StatusShellCommand(ShellCommand):
596
+ name = "status"
597
+ description = "Alias for whoami"
598
+ contexts = []
599
+
600
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
601
+
602
+
603
+ cmd_status(argparse.Namespace(command="status", raw_json=False, local_only=False, aster=getattr(ctx.connection, "_peer_addr", None), root_key=None))
604
+
605
+
606
+ @register
607
+ class PublishShellCommand(ShellCommand):
608
+ name = "publish"
609
+ description = "Publish a service to @aster"
610
+ contexts = ["/services", "/services/*", "/aster/*", "/aster/*/*"]
611
+
612
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
613
+
614
+
615
+ node, _ = resolve_path(ctx.vfs_root, ctx.vfs_cwd, ".")
616
+ target = args[0] if args else (node.metadata.get("name") if node and node.kind == NodeKind.SERVICE else None)
617
+ if not target:
618
+ ctx.display.error("usage: publish <MODULE:CLASS|service>")
619
+ return
620
+ cmd_publish(
621
+ argparse.Namespace(
622
+ command="publish",
623
+ target=target,
624
+ manifest=".aster/manifest.json",
625
+ semver=None,
626
+ aster=getattr(ctx.connection, "_peer_addr", None),
627
+ root_key=None,
628
+ identity_file=None,
629
+ endpoint_id=None,
630
+ relay="",
631
+ endpoint_ttl="5m",
632
+ description=node.metadata.get("description", "") if node and node.kind == NodeKind.SERVICE else "",
633
+ status=node.metadata.get("status", "experimental") if node and node.kind == NodeKind.SERVICE else "experimental",
634
+ public=True,
635
+ private=False,
636
+ open=True,
637
+ closed=False,
638
+ token_ttl="5m",
639
+ rate_limit=None,
640
+ role=[],
641
+ demo=not hasattr(ctx.connection, "_peer_addr"),
642
+ )
643
+ )
644
+ _after_mutation(ctx)
645
+
646
+
647
+ @register
648
+ class UnpublishShellCommand(ShellCommand):
649
+ name = "unpublish"
650
+ description = "Unpublish a service"
651
+ contexts = ["/services", "/services/*", "/aster/*", "/aster/*/*"]
652
+
653
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
654
+
655
+
656
+ node, _ = resolve_path(ctx.vfs_root, ctx.vfs_cwd, ".")
657
+ service = args[0] if args else (node.metadata.get("name") if node and node.kind == NodeKind.SERVICE else None)
658
+ if not service:
659
+ ctx.display.error("usage: unpublish <service>")
660
+ return
661
+ cmd_unpublish(
662
+ argparse.Namespace(
663
+ command="unpublish",
664
+ service=service,
665
+ aster=getattr(ctx.connection, "_peer_addr", None),
666
+ root_key=None,
667
+ demo=not hasattr(ctx.connection, "_peer_addr"),
668
+ )
669
+ )
670
+ _after_mutation(ctx)
671
+
672
+
673
+ @register
674
+ class DiscoverShellCommand(ShellCommand):
675
+ name = "discover"
676
+ description = "Search published services on @aster"
677
+ contexts = []
678
+
679
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
680
+
681
+
682
+ cmd_discover(
683
+ argparse.Namespace(
684
+ command="discover",
685
+ query=args[0] if args else "",
686
+ aster=getattr(ctx.connection, "_peer_addr", None),
687
+ limit=20,
688
+ offset=0,
689
+ raw_json=False,
690
+ )
691
+ )
692
+
693
+
694
+ @register
695
+ class AccessListShellCommand(ShellCommand):
696
+ name = "access"
697
+ description = "List access grants for a published service"
698
+ contexts = ["/services/*", "/aster/*/*"]
699
+
700
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
701
+
702
+
703
+ node, _ = resolve_path(ctx.vfs_root, ctx.vfs_cwd, ".")
704
+ service = args[0] if args else (node.metadata.get("name") if node and node.kind == NodeKind.SERVICE else None)
705
+ if not service:
706
+ ctx.display.error("usage: access <service>")
707
+ return
708
+ cmd_access_list(
709
+ argparse.Namespace(
710
+ access_command="list",
711
+ service=service,
712
+ aster=getattr(ctx.connection, "_peer_addr", None),
713
+ root_key=None,
714
+ raw_json=False,
715
+ )
716
+ )
717
+
718
+
719
+ @register
720
+ class GrantShellCommand(ShellCommand):
721
+ name = "grant"
722
+ description = "Grant a consumer access to a service"
723
+ contexts = ["/services/*", "/aster/*/*"]
724
+
725
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
726
+
727
+
728
+ node, _ = resolve_path(ctx.vfs_root, ctx.vfs_cwd, ".")
729
+ service = node.metadata.get("name") if node and node.kind == NodeKind.SERVICE else None
730
+ if len(args) < 1:
731
+ ctx.display.error("usage: grant <consumer> [service]")
732
+ return
733
+ consumer = args[0]
734
+ if len(args) > 1:
735
+ service = args[1]
736
+ if not service:
737
+ ctx.display.error("usage: grant <consumer> <service>")
738
+ return
739
+ cmd_access_grant(
740
+ argparse.Namespace(
741
+ access_command="grant",
742
+ service=service,
743
+ consumer=consumer,
744
+ role="consumer",
745
+ scope="handle",
746
+ scope_node_id=None,
747
+ aster=getattr(ctx.connection, "_peer_addr", None),
748
+ root_key=None,
749
+ )
750
+ )
751
+ _after_mutation(ctx)
752
+
753
+
754
+ @register
755
+ class RevokeShellCommand(ShellCommand):
756
+ name = "revoke"
757
+ description = "Revoke a consumer's access to a service"
758
+ contexts = ["/services/*", "/aster/*/*"]
759
+
760
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
761
+
762
+
763
+ node, _ = resolve_path(ctx.vfs_root, ctx.vfs_cwd, ".")
764
+ service = node.metadata.get("name") if node and node.kind == NodeKind.SERVICE else None
765
+ if len(args) < 1:
766
+ ctx.display.error("usage: revoke <consumer> [service]")
767
+ return
768
+ consumer = args[0]
769
+ if len(args) > 1:
770
+ service = args[1]
771
+ if not service:
772
+ ctx.display.error("usage: revoke <consumer> <service>")
773
+ return
774
+ cmd_access_revoke(
775
+ argparse.Namespace(
776
+ access_command="revoke",
777
+ service=service,
778
+ consumer=consumer,
779
+ aster=getattr(ctx.connection, "_peer_addr", None),
780
+ root_key=None,
781
+ )
782
+ )
783
+ _after_mutation(ctx)
784
+
785
+
786
+ @register
787
+ class VisibilityShellCommand(ShellCommand):
788
+ name = "visibility"
789
+ description = "Change visibility for a published service"
790
+ contexts = ["/services/*", "/aster/*/*"]
791
+
792
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
793
+
794
+
795
+ node, _ = resolve_path(ctx.vfs_root, ctx.vfs_cwd, ".")
796
+ service = node.metadata.get("name") if node and node.kind == NodeKind.SERVICE else None
797
+ if not args:
798
+ ctx.display.error("usage: visibility <public|private> [service]")
799
+ return
800
+ visibility = args[0]
801
+ if len(args) > 1:
802
+ service = args[1]
803
+ if visibility not in {"public", "private"} or not service:
804
+ ctx.display.error("usage: visibility <public|private> [service]")
805
+ return
806
+ cmd_set_visibility(
807
+ argparse.Namespace(
808
+ command="visibility",
809
+ service=service,
810
+ visibility=visibility,
811
+ aster=getattr(ctx.connection, "_peer_addr", None),
812
+ root_key=None,
813
+ )
814
+ )
815
+ _after_mutation(ctx)
816
+
817
+
818
+ @register
819
+ class UpdateServiceShellCommand(ShellCommand):
820
+ name = "update-service"
821
+ description = "Update published service metadata"
822
+ contexts = ["/services/*", "/aster/*/*"]
823
+
824
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
825
+
826
+
827
+ node, _ = resolve_path(ctx.vfs_root, ctx.vfs_cwd, ".")
828
+ service = node.metadata.get("name") if node and node.kind == NodeKind.SERVICE else None
829
+ if not service:
830
+ ctx.display.error("usage: update-service [service] [--description ...] [--status ...] [--replacement ...]")
831
+ return
832
+ description = None
833
+ status = None
834
+ replacement = None
835
+ remaining: list[str] = []
836
+ i = 0
837
+ while i < len(args):
838
+ if args[i] == "--description" and i + 1 < len(args):
839
+ description = args[i + 1]
840
+ i += 2
841
+ elif args[i] == "--status" and i + 1 < len(args):
842
+ status = args[i + 1]
843
+ i += 2
844
+ elif args[i] == "--replacement" and i + 1 < len(args):
845
+ replacement = args[i + 1]
846
+ i += 2
847
+ else:
848
+ remaining.append(args[i])
849
+ i += 1
850
+ if remaining:
851
+ service = remaining[0]
852
+ cmd_update_service(
853
+ argparse.Namespace(
854
+ command="update-service",
855
+ service=service,
856
+ description=description,
857
+ status=status,
858
+ replacement=replacement,
859
+ aster=getattr(ctx.connection, "_peer_addr", None),
860
+ root_key=None,
861
+ )
862
+ )
863
+ _after_mutation(ctx)
864
+
865
+
866
+ @register
867
+ class DelegationShellCommand(ShellCommand):
868
+ name = "delegation"
869
+ description = "Update a service's delegated access mode"
870
+ contexts = ["/services/*", "/aster/*/*"]
871
+
872
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
873
+
874
+
875
+ node, _ = resolve_path(ctx.vfs_root, ctx.vfs_cwd, ".")
876
+ service = node.metadata.get("name") if node and node.kind == NodeKind.SERVICE else None
877
+ if not service:
878
+ ctx.display.error("usage: delegation [service] [--open|--closed]")
879
+ return
880
+ mode = "open"
881
+ remaining: list[str] = []
882
+ for arg in args:
883
+ if arg == "--closed":
884
+ mode = "closed"
885
+ elif arg == "--open":
886
+ mode = "open"
887
+ else:
888
+ remaining.append(arg)
889
+ if remaining:
890
+ service = remaining[0]
891
+ cmd_access_delegation(
892
+ argparse.Namespace(
893
+ access_command="delegation",
894
+ service=service,
895
+ open=mode == "open",
896
+ closed=mode == "closed",
897
+ token_ttl="5m",
898
+ rate_limit=None,
899
+ role=[],
900
+ aster=getattr(ctx.connection, "_peer_addr", None),
901
+ root_key=None,
902
+ )
903
+ )
904
+ _after_mutation(ctx)
905
+
906
+
907
+ @register
908
+ class PublicShellCommand(ShellCommand):
909
+ name = "public"
910
+ description = "Make a published service discoverable"
911
+ contexts = ["/services/*", "/aster/*/*"]
912
+
913
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
914
+
915
+
916
+ node, _ = resolve_path(ctx.vfs_root, ctx.vfs_cwd, ".")
917
+ service = args[0] if args else (node.metadata.get("name") if node and node.kind == NodeKind.SERVICE else None)
918
+ if not service:
919
+ ctx.display.error("usage: public [service]")
920
+ return
921
+ cmd_access_public_private(
922
+ argparse.Namespace(
923
+ access_command="public",
924
+ service=service,
925
+ aster=getattr(ctx.connection, "_peer_addr", None),
926
+ root_key=None,
927
+ )
928
+ )
929
+ _after_mutation(ctx)
930
+
931
+
932
+ @register
933
+ class PrivateShellCommand(ShellCommand):
934
+ name = "private"
935
+ description = "Hide a published service from discovery"
936
+ contexts = ["/services/*", "/aster/*/*"]
937
+
938
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
939
+
940
+
941
+ node, _ = resolve_path(ctx.vfs_root, ctx.vfs_cwd, ".")
942
+ service = args[0] if args else (node.metadata.get("name") if node and node.kind == NodeKind.SERVICE else None)
943
+ if not service:
944
+ ctx.display.error("usage: private [service]")
945
+ return
946
+ cmd_access_public_private(
947
+ argparse.Namespace(
948
+ access_command="private",
949
+ service=service,
950
+ aster=getattr(ctx.connection, "_peer_addr", None),
951
+ root_key=None,
952
+ )
953
+ )
954
+ _after_mutation(ctx)
955
+
956
+
957
+ @register
958
+ class GenerateClientCommand(ShellCommand):
959
+ name = "generate-client"
960
+ description = "Generate a typed client for a service"
961
+ contexts = ["/services", "/services/*"]
962
+ cli_noun_verb = ("service", "generate-client")
963
+
964
+ def get_arguments(self) -> list[Argument]:
965
+ return [
966
+ Argument(name="lang", description="Target language (python, go, java, etc.)", required=True),
967
+ Argument(name="out", description="Output directory", required=True),
968
+ Argument(name="service", description="Service name", positional=True),
969
+ ]
970
+
971
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
972
+ from aster_cli.codegen import generate_python_clients, format_usage_snippet
973
+
974
+ # Determine which services to generate for
975
+ node, _ = resolve_path(ctx.vfs_root, ctx.vfs_cwd, ".")
976
+ target_service = None
977
+ if node and node.kind == NodeKind.SERVICE:
978
+ target_service = node.name
979
+
980
+ # Parse args
981
+ lang = "python"
982
+ out = "./clients/"
983
+ package = None
984
+ i = 0
985
+ while i < len(args):
986
+ if args[i] == "--lang" and i + 1 < len(args):
987
+ lang = args[i + 1]
988
+ i += 2
989
+ elif args[i] == "--out" and i + 1 < len(args):
990
+ out = args[i + 1]
991
+ i += 2
992
+ elif args[i] == "--package" and i + 1 < len(args):
993
+ package = args[i + 1]
994
+ i += 2
995
+ else:
996
+ target_service = args[i]
997
+ i += 1
998
+
999
+ if lang != "python":
1000
+ ctx.display.error(f"Only 'python' is supported for now (got '{lang}')")
1001
+ return
1002
+
1003
+ # Get manifests from connection
1004
+ manifests = ctx.connection.get_manifests()
1005
+ if not manifests:
1006
+ ctx.display.error("No manifests available -- wait for service discovery to complete")
1007
+ return
1008
+
1009
+ # Filter to target service if specified
1010
+ if target_service:
1011
+ if target_service not in manifests:
1012
+ ctx.display.error(f"No manifest for '{target_service}'")
1013
+ return
1014
+ manifests = {target_service: manifests[target_service]}
1015
+
1016
+ # Namespace: --package flag, peer name, or endpoint_id prefix
1017
+ namespace = package or ctx.peer_name or ctx.connection.get_peer_display()
1018
+ # Sanitize: only keep alphanumeric + underscores
1019
+ namespace = re.sub(r"[^a-zA-Z0-9_]", "_", namespace).strip("_") or "aster_client"
1020
+ source = f"{namespace}/{next(iter(manifests))}" if len(manifests) == 1 else namespace
1021
+
1022
+ generated = generate_python_clients(manifests, out, namespace, source)
1023
+
1024
+ ctx.display.info(f"Generated {len(generated)} files")
1025
+ for f in generated:
1026
+ ctx.display.print(f" [dim]{f}[/dim]")
1027
+
1028
+ address = getattr(ctx.connection, '_peer_addr', '')
1029
+ ctx.display.print(format_usage_snippet(out, namespace, manifests, address))
1030
+
1031
+
1032
+ # ── Session subshell ──────────────────────────────────────────────────────────
1033
+
1034
+
1035
+ @register
1036
+ class SessionCommand(ShellCommand):
1037
+ name = "session"
1038
+ description = "Open a session subshell for a session-scoped service"
1039
+ # Scoped to /services so future paths like /aster/<handle>/ get their
1040
+ # own session command via namespace-specific dispatch (no ambiguity
1041
+ # about which handle the session belongs to).
1042
+ contexts = ["/services", "/services/*"]
1043
+
1044
+ def get_arguments(self) -> list[Argument]:
1045
+ return [Argument(name="service", description="Session-scoped service name", positional=True)]
1046
+
1047
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
1048
+ # Determine service
1049
+ node, _ = resolve_path(ctx.vfs_root, ctx.vfs_cwd, ".")
1050
+ if node and node.kind == NodeKind.SERVICE:
1051
+ service_name = node.name
1052
+ elif args:
1053
+ service_name = args[0]
1054
+ else:
1055
+ ctx.display.error("usage: session <service>")
1056
+ return
1057
+
1058
+ # Make sure /services and the target service node are populated
1059
+ # before we touch metadata or render `ls` -- otherwise running
1060
+ # `session AgentSession` from anywhere except /services would
1061
+ # silently skip the scope check (target is None) and the in-shell
1062
+ # `ls` would see no methods (children empty).
1063
+ services_node = ctx.vfs_root.child("services")
1064
+ if services_node:
1065
+ await ensure_loaded(services_node, ctx.connection)
1066
+ target = services_node.child(service_name) if services_node else None
1067
+ if target:
1068
+ await ensure_loaded(target, ctx.connection)
1069
+
1070
+ # Check if session-scoped
1071
+ if target and target.metadata.get("scoped") not in ("session", "stream"):
1072
+ ctx.display.warning(f"{service_name} is not session-scoped (scoped={target.metadata.get('scoped', 'shared')})")
1073
+ ctx.display.info("Session subshell is designed for session-scoped services.")
1074
+ ctx.display.info("Shared services don't maintain per-connection state.")
1075
+ return
1076
+
1077
+ # Open the persistent session BEFORE fiddling with shell state.
1078
+ # If this fails (peer doesn't speak the service, network drop,
1079
+ # auth denied, etc.) we surface the error and stay in the main
1080
+ # shell rather than entering a half-open subshell.
1081
+ session_handle = None
1082
+ if hasattr(ctx.connection, "open_session"):
1083
+ try:
1084
+ session_handle = await ctx.connection.open_session(service_name)
1085
+ except Exception as exc:
1086
+ ctx.display.error(f"Failed to open session for {service_name}: {exc}")
1087
+ return
1088
+ else:
1089
+ ctx.display.error(
1090
+ f"This connection backend doesn't support sessions; "
1091
+ f"cannot enter session subshell for {service_name}."
1092
+ )
1093
+ return
1094
+
1095
+ # Fire session hooks
1096
+ hooks = get_hook_registry()
1097
+ for hook in hooks.session_hooks:
1098
+ await hook.on_session_start(service_name, ctx)
1099
+
1100
+ ctx.display.print()
1101
+ ctx.display.print(f"[bold cyan]Session opened: {service_name}[/bold cyan]")
1102
+ ctx.display.info("This is a dedicated session -- state persists across calls.")
1103
+ ctx.display.info("Type 'end' to close the session and return to the main shell.")
1104
+ ctx.display.print()
1105
+
1106
+ # Save main shell state
1107
+ saved_cwd = ctx.vfs_cwd
1108
+ saved_session = getattr(ctx, "session", None)
1109
+ ctx.vfs_cwd = f"/services/{service_name}"
1110
+ # Bind the session so invoke_method routes calls through it
1111
+ ctx.session = session_handle
1112
+
1113
+ # Subshell loop. Mirrors the TTY/non-TTY split in the outer
1114
+ # shell REPL: prompt_toolkit assumes a real terminal and emits
1115
+ # garbled cursor-positioning escapes when stdin/stdout are a
1116
+ # pipe, which means scripted callers (CI, expect-driven QA
1117
+ # runs, anything piping commands to `aster shell`) can't drive
1118
+ # the subshell. The plain-stdin reader works in both modes.
1119
+ is_interactive = sys.stdin.isatty() and sys.stdout.isatty()
1120
+
1121
+ if is_interactive:
1122
+ from prompt_toolkit import PromptSession as PS
1123
+ from prompt_toolkit.formatted_text import HTML
1124
+ sub_session = PS()
1125
+ prompt_html = HTML(
1126
+ f"<style fg='#E6C06B'>{service_name}</style>"
1127
+ f"<style fg='#666666'>~</style> "
1128
+ )
1129
+
1130
+ async def read_subshell_line() -> str:
1131
+ return await sub_session.prompt_async(prompt_html)
1132
+ else:
1133
+ async def read_subshell_line() -> str:
1134
+ line = await asyncio.to_thread(sys.stdin.readline)
1135
+ if not line:
1136
+ raise EOFError()
1137
+ return line.rstrip("\r\n")
1138
+
1139
+ while True:
1140
+ try:
1141
+ text = await read_subshell_line()
1142
+ text = text.strip()
1143
+
1144
+ if not text:
1145
+ continue
1146
+
1147
+ if text in ("end", "exit", "quit"):
1148
+ break
1149
+
1150
+ # Parse and execute within the service context
1151
+ try:
1152
+ parts = shlex.split(text)
1153
+ except ValueError:
1154
+ parts = text.split()
1155
+
1156
+ method_name = parts[0].lstrip("./")
1157
+ call_args = parts[1:]
1158
+
1159
+ # Check if it's a valid method
1160
+ target_node = ctx.vfs_root.child("services")
1161
+ if target_node:
1162
+ svc = target_node.child(service_name)
1163
+ if svc and svc.child(method_name):
1164
+ payload = _parse_call_args(call_args)
1165
+ await invoke_method(ctx, service_name, method_name, payload)
1166
+ continue
1167
+
1168
+ # Built-in subshell commands
1169
+ services_root = ctx.vfs_root.child("services")
1170
+ svc = services_root.child(service_name) if services_root else None
1171
+
1172
+ if method_name == "help":
1173
+ ctx.display.print("[bold]Session commands:[/bold]")
1174
+ ctx.display.print(" [green]end[/green] Close this session")
1175
+ ctx.display.print(" [green]help[/green] Show this help")
1176
+ ctx.display.print(" [green]ls[/green] List available methods")
1177
+ ctx.display.print(" [green]<method> args[/green] Invoke a method (./method also works)")
1178
+ ctx.display.print()
1179
+ ctx.display.print("[bold]Available methods:[/bold]")
1180
+ if svc:
1181
+ for c in svc.sorted_children():
1182
+ sig = c.metadata.get("request_type", "")
1183
+ pattern = c.metadata.get("pattern", "unary")
1184
+ ctx.display.print(f" [green]{c.name:20s}[/green] {pattern:15s} {sig}")
1185
+ else:
1186
+ ctx.display.print(" [dim](no methods discovered)[/dim]")
1187
+ elif method_name == "ls":
1188
+ if svc:
1189
+ for c in svc.sorted_children():
1190
+ pattern = c.metadata.get("pattern", "unary")
1191
+ sig = c.metadata.get("request_type", "")
1192
+ ctx.display.print(f" [green]{c.name:20s}[/green] [dim]{pattern:15s} {sig}[/dim]")
1193
+ else:
1194
+ ctx.display.print(" [dim](no methods discovered)[/dim]")
1195
+ else:
1196
+ ctx.display.error(f"unknown method: {method_name} (try 'help')")
1197
+
1198
+ except KeyboardInterrupt:
1199
+ ctx.display.print()
1200
+ continue
1201
+ except EOFError:
1202
+ break
1203
+
1204
+ # Close the session and restore state
1205
+ try:
1206
+ await ctx.connection.close_session(session_handle)
1207
+ except Exception:
1208
+ pass
1209
+ ctx.session = saved_session
1210
+ ctx.vfs_cwd = saved_cwd
1211
+
1212
+ # Fire session end hooks
1213
+ for hook in hooks.session_hooks:
1214
+ await hook.on_session_end(service_name, ctx)
1215
+
1216
+ ctx.display.info(f"Session closed: {service_name}")
1217
+
1218
+ def get_completions(self, ctx: CommandContext, partial: str) -> list[str]:
1219
+ services_node = ctx.vfs_root.child("services")
1220
+ if services_node is None:
1221
+ return []
1222
+ return [c.name for c in services_node.sorted_children()
1223
+ if c.metadata.get("scoped") == "session"
1224
+ and c.name.lower().startswith(partial.lower())]
1225
+
1226
+
1227
+ # ── Gossip + docs live commands ───────────────────────────────────────────────
1228
+
1229
+
1230
+ @register
1231
+ class TailCommand(ShellCommand):
1232
+ name = "tail"
1233
+ description = "Live-stream gossip messages (Ctrl-C to stop)"
1234
+ contexts = ["/gossip"]
1235
+
1236
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
1237
+
1238
+
1239
+
1240
+ ctx.display.info("Subscribing to producer mesh gossip topic…")
1241
+
1242
+ try:
1243
+ topic = await ctx.connection.subscribe_gossip()
1244
+ except Exception as e:
1245
+ ctx.display.error(f"failed to subscribe: {e}")
1246
+ return
1247
+
1248
+ ctx.display.success("Subscribed -- listening for messages (Ctrl-C to stop)")
1249
+ ctx.display.print()
1250
+
1251
+ try:
1252
+ while True:
1253
+ try:
1254
+ event_type, data = await asyncio.wait_for(topic.recv(), timeout=1.0)
1255
+ except asyncio.TimeoutError:
1256
+ continue
1257
+
1258
+ ts = time.strftime("%H:%M:%S")
1259
+
1260
+ if event_type == "received":
1261
+ # Try to decode as JSON (Aster producer messages are JSON)
1262
+ if data:
1263
+ try:
1264
+ text = bytes(data).decode("utf-8")
1265
+ parsed = json.loads(text)
1266
+ ctx.display.print(f"[dim]{ts}[/dim] [green]msg[/green] ", end="")
1267
+ ctx.display.json_value(parsed)
1268
+ except (UnicodeDecodeError, json.JSONDecodeError):
1269
+ ctx.display.print(
1270
+ f"[dim]{ts}[/dim] [green]msg[/green] "
1271
+ f"[dim]({len(data)} bytes)[/dim] {bytes(data).hex()[:40]}…"
1272
+ )
1273
+ else:
1274
+ ctx.display.print(f"[dim]{ts}[/dim] [green]msg[/green] [dim](empty)[/dim]")
1275
+
1276
+ elif event_type == "neighbor_up":
1277
+ peer_id = bytes(data).hex()[:16] if data else "?"
1278
+ ctx.display.print(f"[dim]{ts}[/dim] [cyan]+ neighbor[/cyan] {peer_id}…")
1279
+
1280
+ elif event_type == "neighbor_down":
1281
+ peer_id = bytes(data).hex()[:16] if data else "?"
1282
+ ctx.display.print(f"[dim]{ts}[/dim] [yellow]- neighbor[/yellow] {peer_id}…")
1283
+
1284
+ elif event_type == "lagged":
1285
+ ctx.display.print(f"[dim]{ts}[/dim] [yellow]lagged[/yellow] (missed messages)")
1286
+
1287
+ else:
1288
+ ctx.display.print(f"[dim]{ts}[/dim] [dim]{event_type}[/dim]")
1289
+
1290
+ except (KeyboardInterrupt, asyncio.CancelledError):
1291
+ ctx.display.print()
1292
+ ctx.display.info("Stopped listening")
1293
+ except Exception as e:
1294
+ ctx.display.error(f"gossip error: {e}")
1295
+
1296
+
1297
+ @register
1298
+ class WatchCommand(ShellCommand):
1299
+ name = "watch"
1300
+ description = "Watch live doc events (Ctrl-C to stop)"
1301
+ contexts = ["/docs"]
1302
+
1303
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
1304
+
1305
+
1306
+
1307
+ if not hasattr(ctx.connection, '_registry_event_rx') or not ctx.connection._registry_event_rx:
1308
+ ctx.display.error("no registry doc subscription available")
1309
+ return
1310
+
1311
+ rx = ctx.connection._registry_event_rx
1312
+ ctx.display.success("Watching registry doc events (Ctrl-C to stop)")
1313
+ ctx.display.print()
1314
+
1315
+ try:
1316
+ while True:
1317
+ try:
1318
+ event = await asyncio.wait_for(rx.recv(), timeout=1.0)
1319
+ except asyncio.TimeoutError:
1320
+ continue
1321
+
1322
+ if event is None:
1323
+ ctx.display.info("subscription ended")
1324
+ break
1325
+
1326
+ ts = time.strftime("%H:%M:%S")
1327
+ kind = event.kind
1328
+
1329
+ if kind in ("insert_local", "insert_remote"):
1330
+ entry = event.entry
1331
+ if entry:
1332
+ key_str = bytes(entry.key).decode("utf-8", errors="replace")
1333
+ author = entry.author_id[:12]
1334
+ source = f" [dim]from {event.from_peer[:12]}…[/dim]" if event.from_peer else ""
1335
+ ctx.display.print(
1336
+ f"[dim]{ts}[/dim] [green]{kind}[/green] "
1337
+ f"[cyan]{key_str}[/cyan] [dim]by {author}…[/dim]{source}"
1338
+ )
1339
+ else:
1340
+ ctx.display.print(f"[dim]{ts}[/dim] [green]{kind}[/green]")
1341
+
1342
+ elif kind == "content_ready":
1343
+ ctx.display.print(
1344
+ f"[dim]{ts}[/dim] [blue]content_ready[/blue] {event.hash or '?'}"
1345
+ )
1346
+
1347
+ elif kind in ("neighbor_up", "neighbor_down"):
1348
+ color = "cyan" if kind == "neighbor_up" else "yellow"
1349
+ peer = event.peer[:16] if event.peer else "?"
1350
+ ctx.display.print(f"[dim]{ts}[/dim] [{color}]{kind}[/{color}] {peer}…")
1351
+
1352
+ elif kind == "sync_finished":
1353
+ peer = event.peer[:16] if event.peer else "?"
1354
+ ctx.display.print(f"[dim]{ts}[/dim] [green]sync_finished[/green] {peer}…")
1355
+
1356
+ else:
1357
+ ctx.display.print(f"[dim]{ts}[/dim] [dim]{kind}[/dim]")
1358
+
1359
+ except (KeyboardInterrupt, asyncio.CancelledError):
1360
+ ctx.display.print()
1361
+ ctx.display.info("Stopped watching")
1362
+ except Exception as e:
1363
+ ctx.display.error(f"watch error: {e}")
1364
+
1365
+
1366
+ # ── CLI-mapped blob commands ──────────────────────────────────────────────────
1367
+
1368
+
1369
+ @register
1370
+ class BlobLsCommand(ShellCommand):
1371
+ name = "blob-ls"
1372
+ description = "List blobs (CLI: aster blob ls)"
1373
+ contexts = ["/blobs"]
1374
+ cli_noun_verb = ("blob", "ls")
1375
+ hidden = True # hidden in interactive mode -- use ls instead
1376
+
1377
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
1378
+ # Navigate to /blobs and list
1379
+ node = ctx.vfs_root.child("blobs")
1380
+ if node:
1381
+ await ensure_loaded(node, ctx.connection)
1382
+ blobs = [
1383
+ {"hash": c.metadata.get("hash", c.name), "size": c.metadata.get("size", "?")}
1384
+ for c in node.sorted_children()
1385
+ ]
1386
+ ctx.display.blob_table(blobs)
1387
+
1388
+
1389
+ @register
1390
+ class ServiceLsCommand(ShellCommand):
1391
+ name = "service-ls"
1392
+ description = "List services (CLI: aster service ls)"
1393
+ contexts = ["/services"]
1394
+ cli_noun_verb = ("service", "ls")
1395
+ hidden = True
1396
+
1397
+ async def execute(self, args: list[str], ctx: CommandContext) -> None:
1398
+ node = ctx.vfs_root.child("services")
1399
+ if node:
1400
+ await ensure_loaded(node, ctx.connection)
1401
+ services = []
1402
+ for c in node.sorted_children():
1403
+ await ensure_loaded(c, ctx.connection)
1404
+ services.append({
1405
+ "name": c.name,
1406
+ "method_count": len(c.children),
1407
+ "version": c.metadata.get("version", 1),
1408
+ "scoped": c.metadata.get("scoped", "shared"),
1409
+ })
1410
+ ctx.display.service_table(services)
1411
+
1412
+
1413
+ # ── Helpers ───────────────────────────────────────────────────────────────────
1414
+
1415
+
1416
+ def _complete_path(ctx: CommandContext, partial: str, dirs_only: bool = False) -> list[str]:
1417
+ """Complete a VFS path from a partial string.
1418
+
1419
+ Splits partial into parent directory + leaf prefix, resolves the parent,
1420
+ and filters children by the prefix.
1421
+
1422
+ Examples:
1423
+ partial="" → children of cwd
1424
+ partial="He" → children of cwd starting with "He"
1425
+ partial="srv/H" → children of cwd/srv starting with "H"
1426
+ """
1427
+ # Split into parent path and leaf prefix
1428
+ if "/" in partial and not partial.endswith("/"):
1429
+ parent_path = partial.rsplit("/", 1)[0] or "/"
1430
+ prefix = partial.rsplit("/", 1)[1]
1431
+ path_prefix = partial.rsplit("/", 1)[0] + "/"
1432
+ elif partial.endswith("/"):
1433
+ parent_path = partial.rstrip("/") or "/"
1434
+ prefix = ""
1435
+ path_prefix = partial if partial.endswith("/") else partial + "/"
1436
+ else:
1437
+ parent_path = "."
1438
+ prefix = partial
1439
+ path_prefix = ""
1440
+
1441
+ node, _path = resolve_path(ctx.vfs_root, ctx.vfs_cwd, parent_path)
1442
+ if node is None:
1443
+ return []
1444
+
1445
+ prefix_lower = prefix.lower()
1446
+ results = []
1447
+ for c in node.sorted_children():
1448
+ if dirs_only and c.kind in (NodeKind.BLOB, NodeKind.METHOD, NodeKind.DOC_ENTRY):
1449
+ continue
1450
+ if c.name.lower().startswith(prefix_lower):
1451
+ suffix = "/" if c.kind not in (NodeKind.BLOB, NodeKind.METHOD, NodeKind.DOC_ENTRY) else ""
1452
+ results.append(path_prefix + c.name + suffix)
1453
+ return results
1454
+
1455
+
1456
+ def _set_nested(d: dict[str, Any], dotted_key: str, value: Any) -> None:
1457
+ """Set a value in a nested dict using dot syntax.
1458
+
1459
+ ``_set_nested(d, "a.b.c", 1)`` produces ``d["a"]["b"]["c"] = 1``.
1460
+ """
1461
+ parts = dotted_key.split(".")
1462
+ for part in parts[:-1]:
1463
+ if part not in d or not isinstance(d[part], dict):
1464
+ d[part] = {}
1465
+ d = d[part]
1466
+ d[parts[-1]] = value
1467
+
1468
+
1469
+ def _parse_call_args(args: list[str]) -> dict[str, Any]:
1470
+ """Parse call arguments from shell tokens.
1471
+
1472
+ Supports:
1473
+ - key=value pairs: ``name="World" count=5``
1474
+ - Dot syntax for nested objects: ``config.timeout=30`` produces
1475
+ ``{"config": {"timeout": 30}}``
1476
+ - Raw JSON string: ``'{"name": "World"}'``
1477
+ - Positional value for single-arg methods: ``"World"``
1478
+ """
1479
+ if not args:
1480
+ return {}
1481
+
1482
+ # Try as a single JSON string
1483
+ joined = " ".join(args)
1484
+ if joined.startswith("{"):
1485
+ try:
1486
+ return json.loads(joined)
1487
+ except json.JSONDecodeError:
1488
+ pass
1489
+
1490
+ # Try as key=value pairs (with dot syntax for nesting)
1491
+ result: dict[str, Any] = {}
1492
+ positional: list[str] = []
1493
+
1494
+ for arg in args:
1495
+ if "=" in arg:
1496
+ key, value = arg.split("=", 1)
1497
+ value = value.strip("'\"")
1498
+ try:
1499
+ parsed = json.loads(value)
1500
+ except (json.JSONDecodeError, ValueError):
1501
+ parsed = value
1502
+ _set_nested(result, key, parsed)
1503
+ else:
1504
+ positional.append(arg.strip("'\""))
1505
+
1506
+ # Leftover positional args after key=value pairs: merge into last key
1507
+ if positional and result:
1508
+ last_key = list(result.keys())[-1]
1509
+ current = str(result[last_key])
1510
+ result[last_key] = current + " " + " ".join(positional)
1511
+
1512
+ # Only positional args
1513
+ if positional and not result:
1514
+ if len(positional) == 1:
1515
+ try:
1516
+ return {"_positional": json.loads(positional[0])}
1517
+ except (json.JSONDecodeError, ValueError):
1518
+ return {"_positional": positional[0]}
1519
+ for i, v in enumerate(positional):
1520
+ result[f"_arg{i}"] = v
1521
+
1522
+ return result
1523
+
1524
+
1525
+ def _check_cli_compatible(fields: list[dict[str, Any]]) -> str | None:
1526
+ """Check if a method's request schema can be built via CLI key=value syntax.
1527
+
1528
+ Returns None if compatible, or a hint message for unsupported schemas.
1529
+ """
1530
+ for f in fields:
1531
+ kind = f.get("kind", "")
1532
+ name = f.get("name", "?")
1533
+
1534
+ if kind == "list":
1535
+ item_kind = f.get("item_kind", "string")
1536
+ if item_kind == "ref":
1537
+ item_ref = f.get("item_ref", "object")
1538
+ return (
1539
+ f"field '{name}' is list<{item_ref}> which can't be "
1540
+ f"built with key=value syntax.\n"
1541
+ f" Use JSON: ./method '{{\"{ name}\": [...]}}'"
1542
+ )
1543
+ elif kind == "map":
1544
+ value_kind = f.get("value_kind", "string")
1545
+ if value_kind == "ref":
1546
+ return (
1547
+ f"field '{name}' is map<{f.get('key_kind', 'string')}, "
1548
+ f"{f.get('value_ref', 'object')}> which can't be "
1549
+ f"built with key=value syntax.\n"
1550
+ f" Use JSON: ./method '{{\"{ name}\": {{...}}}}'"
1551
+ )
1552
+
1553
+ return None
1554
+
1555
+
1556
+ def _method_signature(metadata: dict[str, Any]) -> str:
1557
+ """Build a human-readable method signature from metadata."""
1558
+ req = metadata.get("request_type", "")
1559
+ resp = metadata.get("response_type", "")
1560
+ if req and resp:
1561
+ return f"({req}) ->{resp}"
1562
+ elif req:
1563
+ return f"({req}) ->…"
1564
+ elif resp:
1565
+ return f"() ->{resp}"
1566
+ return ""
1567
+
1568
+
1569
+ def _display_bytes(display: Any, content: Any) -> None:
1570
+ """Display bytes content, trying UTF-8 text then hex."""
1571
+ if isinstance(content, bytes):
1572
+ try:
1573
+ text = content.decode("utf-8")
1574
+ # Try to pretty-print JSON
1575
+ try:
1576
+ parsed = json.loads(text)
1577
+ display.json_value(parsed)
1578
+ except (json.JSONDecodeError, ValueError):
1579
+ display.print(text)
1580
+ except UnicodeDecodeError:
1581
+ display.info(f"(binary data, {len(content)} bytes)")
1582
+ display.print(content.hex()[:200] + ("…" if len(content) > 100 else ""))
1583
+ else:
1584
+ display.print(str(content))
1585
+
1586
+
1587
+ def _kind_detail(node: VfsNode) -> str:
1588
+ """Short detail string for a top-level directory."""
1589
+ details = {
1590
+ NodeKind.BLOBS: "content-addressed storage",
1591
+ NodeKind.SERVICES: "RPC services",
1592
+ NodeKind.DOCS: "registry documents",
1593
+ NodeKind.GOSSIP: "pub/sub topics",
1594
+ NodeKind.ASTER: "service directory",
1595
+ }
1596
+ return details.get(node.kind, "")
1597
+
1598
+
1599
+ def _after_mutation(ctx: CommandContext) -> None:
1600
+ """Refresh shell state after local or remote mutating commands."""
1601
+ _reset_loaded(ctx.vfs_root)
1602
+
1603
+
1604
+ def _reset_loaded(node: VfsNode) -> None:
1605
+ """Recursively clear loaded flags."""
1606
+ children = list(node.children.values())
1607
+ node.loaded = node.kind == NodeKind.ROOT # keep root loaded
1608
+ if node.kind != NodeKind.ROOT:
1609
+ node.children.clear()
1610
+ for child in children:
1611
+ _reset_loaded(child)
1612
+
1613
+
1614
+ def _lang_ext(lang: str) -> str:
1615
+ """File extension for a language."""
1616
+ return {
1617
+ "python": "py",
1618
+ "go": "go",
1619
+ "java": "java",
1620
+ "typescript": "ts",
1621
+ "javascript": "js",
1622
+ "csharp": "cs",
1623
+ "rust": "rs",
1624
+ }.get(lang.lower(), lang)