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.
- aster_cli/__init__.py +1 -0
- aster_cli/access.py +270 -0
- aster_cli/aster_service.py +300 -0
- aster_cli/codegen.py +828 -0
- aster_cli/codegen_typescript.py +819 -0
- aster_cli/contract.py +1112 -0
- aster_cli/credentials.py +87 -0
- aster_cli/enroll.py +315 -0
- aster_cli/handle_validation.py +53 -0
- aster_cli/identity.py +194 -0
- aster_cli/init.py +104 -0
- aster_cli/join.py +442 -0
- aster_cli/keygen.py +203 -0
- aster_cli/main.py +15 -0
- aster_cli/mcp/__init__.py +13 -0
- aster_cli/mcp/schema.py +205 -0
- aster_cli/mcp/security.py +108 -0
- aster_cli/mcp/server.py +407 -0
- aster_cli/profile.py +334 -0
- aster_cli/publish.py +598 -0
- aster_cli/shell/__init__.py +17 -0
- aster_cli/shell/app.py +2390 -0
- aster_cli/shell/commands.py +1624 -0
- aster_cli/shell/completer.py +156 -0
- aster_cli/shell/display.py +405 -0
- aster_cli/shell/guide.py +230 -0
- aster_cli/shell/hooks.py +255 -0
- aster_cli/shell/invoker.py +430 -0
- aster_cli/shell/plugin.py +185 -0
- aster_cli/shell/vfs.py +438 -0
- aster_cli/signer.py +150 -0
- aster_cli/templates/llm/python.md +578 -0
- aster_cli/trust.py +244 -0
- aster_cli-0.1.2.dist-info/METADATA +10 -0
- aster_cli-0.1.2.dist-info/RECORD +38 -0
- aster_cli-0.1.2.dist-info/WHEEL +5 -0
- aster_cli-0.1.2.dist-info/entry_points.txt +2 -0
- aster_cli-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -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)
|