arqera-cli 0.2.0__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.
arqera_cli/__init__.py
ADDED
arqera_cli/cli.py
ADDED
|
@@ -0,0 +1,1341 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ARQERA CLI -- command-line interface for the ARQERA platform.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
arqera login Authenticate with your API key
|
|
7
|
+
arqera whoami Check identity and connection
|
|
8
|
+
arqera chat [MESSAGE] Chat with Ore (streaming, interactive)
|
|
9
|
+
arqera briefing View today's briefing
|
|
10
|
+
arqera governance status View governance health
|
|
11
|
+
arqera governance evaluate Evaluate an action
|
|
12
|
+
arqera agents list List available agents
|
|
13
|
+
arqera agents run ID Run an agent
|
|
14
|
+
arqera evidence search Search the evidence trail
|
|
15
|
+
arqera dimensions View dimension scores
|
|
16
|
+
arqera init Initialize a new Arqera project
|
|
17
|
+
arqera deploy Deploy a workflow manifest
|
|
18
|
+
arqera validate Validate an agent manifest
|
|
19
|
+
arqera publish Publish an agent to the marketplace
|
|
20
|
+
arqera mcp install Install ARQERA MCP server for Claude
|
|
21
|
+
|
|
22
|
+
Examples:
|
|
23
|
+
arqera login --api-key ak_live_abc123
|
|
24
|
+
arqera chat "What's my trust score?"
|
|
25
|
+
arqera briefing
|
|
26
|
+
arqera governance evaluate --action email.send --description "Send newsletter"
|
|
27
|
+
arqera evidence search --type governance_evaluation --limit 10
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import shutil
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
import urllib.request
|
|
39
|
+
import webbrowser
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from urllib.error import HTTPError, URLError
|
|
42
|
+
from typing import Any, Dict, Iterator, Optional
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# =============================================================================
|
|
46
|
+
# CONFIGURATION
|
|
47
|
+
# =============================================================================
|
|
48
|
+
|
|
49
|
+
ARQERA_DIR = Path.home() / ".arqera"
|
|
50
|
+
CREDENTIALS_FILE = ARQERA_DIR / "credentials.json"
|
|
51
|
+
SWARM_KEY_FILE = ARQERA_DIR / "swarm-key"
|
|
52
|
+
DEFAULT_BASE_URL = "https://api.arqera.io/api"
|
|
53
|
+
MANIFEST_FILENAME = "arqera.yaml"
|
|
54
|
+
|
|
55
|
+
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
|
|
56
|
+
|
|
57
|
+
MANIFEST_TEMPLATE = """\
|
|
58
|
+
# Arqera Project Manifest
|
|
59
|
+
# Docs: https://docs.arqera.io/sdk/manifest
|
|
60
|
+
|
|
61
|
+
version: "0.2"
|
|
62
|
+
intent:
|
|
63
|
+
id: {project_name}.main
|
|
64
|
+
title: "{project_title}"
|
|
65
|
+
description: "{project_description}"
|
|
66
|
+
parameters: {{}}
|
|
67
|
+
policy:
|
|
68
|
+
approvals_required: false
|
|
69
|
+
approval_level: "standard"
|
|
70
|
+
data_access:
|
|
71
|
+
- "read:protocol"
|
|
72
|
+
- "write:evidence"
|
|
73
|
+
workflow:
|
|
74
|
+
id: "{project_name}.v1"
|
|
75
|
+
steps:
|
|
76
|
+
- id: execute
|
|
77
|
+
action:
|
|
78
|
+
type: execute
|
|
79
|
+
module: core
|
|
80
|
+
params: {{}}
|
|
81
|
+
agent:
|
|
82
|
+
id: "{project_name}-agent"
|
|
83
|
+
name: "{project_title} Agent"
|
|
84
|
+
capabilities:
|
|
85
|
+
- "text-generation"
|
|
86
|
+
context:
|
|
87
|
+
tenant_id: "{{{{ env.ARQERA_TENANT_ID }}}}"
|
|
88
|
+
user_id: "{{{{ env.ARQERA_USER_ID }}}}"
|
|
89
|
+
surface: "cli"
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# =============================================================================
|
|
94
|
+
# HELPERS
|
|
95
|
+
# =============================================================================
|
|
96
|
+
|
|
97
|
+
def _api_request(
|
|
98
|
+
path: str,
|
|
99
|
+
method: str = "GET",
|
|
100
|
+
body: Optional[Dict[str, Any]] = None,
|
|
101
|
+
api_key: Optional[str] = None,
|
|
102
|
+
base_url: Optional[str] = None,
|
|
103
|
+
timeout: int = 30,
|
|
104
|
+
) -> Any:
|
|
105
|
+
"""Make an API request to the Arqera platform."""
|
|
106
|
+
url = f"{(base_url or DEFAULT_BASE_URL).rstrip('/')}{path}"
|
|
107
|
+
data = json.dumps(body).encode("utf-8") if body else None
|
|
108
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
109
|
+
req.add_header("Content-Type", "application/json")
|
|
110
|
+
req.add_header("Accept", "application/json")
|
|
111
|
+
req.add_header("User-Agent", "arqera-cli/0.2.0")
|
|
112
|
+
|
|
113
|
+
if api_key:
|
|
114
|
+
req.add_header("X-API-Key", api_key)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
118
|
+
payload = resp.read().decode("utf-8")
|
|
119
|
+
return json.loads(payload) if payload else None
|
|
120
|
+
except HTTPError as exc:
|
|
121
|
+
body_text = exc.read().decode("utf-8") if exc.fp else ""
|
|
122
|
+
return {"error": True, "status": exc.code, "message": body_text}
|
|
123
|
+
except URLError as exc:
|
|
124
|
+
return {"error": True, "status": 0, "message": str(exc.reason)}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _stream_sse(
|
|
128
|
+
path: str,
|
|
129
|
+
body: Dict[str, Any],
|
|
130
|
+
api_key: str,
|
|
131
|
+
base_url: str,
|
|
132
|
+
) -> Iterator[str]:
|
|
133
|
+
"""Stream SSE response, yielding text chunks."""
|
|
134
|
+
try:
|
|
135
|
+
import httpx
|
|
136
|
+
except ImportError:
|
|
137
|
+
# Fall back to non-streaming if httpx not available
|
|
138
|
+
result = _api_request(path, method="POST", body=body, api_key=api_key, base_url=base_url)
|
|
139
|
+
if isinstance(result, dict):
|
|
140
|
+
content = (
|
|
141
|
+
result.get("response")
|
|
142
|
+
or result.get("message")
|
|
143
|
+
or result.get("content")
|
|
144
|
+
or result.get("reply")
|
|
145
|
+
)
|
|
146
|
+
if content:
|
|
147
|
+
yield content
|
|
148
|
+
else:
|
|
149
|
+
yield json.dumps(result, indent=2)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
url = f"{base_url.rstrip('/')}{path}"
|
|
153
|
+
headers = {
|
|
154
|
+
"Content-Type": "application/json",
|
|
155
|
+
"Accept": "text/event-stream",
|
|
156
|
+
"X-API-Key": api_key,
|
|
157
|
+
"User-Agent": "arqera-cli/0.2.0",
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
with httpx.Client(timeout=60) as http:
|
|
161
|
+
with http.stream("POST", url, json=body, headers=headers) as response:
|
|
162
|
+
if response.status_code != 200:
|
|
163
|
+
error_body = response.read().decode("utf-8")
|
|
164
|
+
yield f"Error ({response.status_code}): {error_body}"
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
for line in response.iter_lines():
|
|
168
|
+
if not line:
|
|
169
|
+
continue
|
|
170
|
+
if line.startswith("data: "):
|
|
171
|
+
payload = line[6:]
|
|
172
|
+
if payload == "[DONE]":
|
|
173
|
+
return
|
|
174
|
+
try:
|
|
175
|
+
event = json.loads(payload)
|
|
176
|
+
chunk = (
|
|
177
|
+
event.get("chunk")
|
|
178
|
+
or event.get("delta", {}).get("content")
|
|
179
|
+
or event.get("content")
|
|
180
|
+
or event.get("text")
|
|
181
|
+
or ""
|
|
182
|
+
)
|
|
183
|
+
if chunk:
|
|
184
|
+
yield chunk
|
|
185
|
+
except json.JSONDecodeError:
|
|
186
|
+
yield payload
|
|
187
|
+
else:
|
|
188
|
+
try:
|
|
189
|
+
data = json.loads(line)
|
|
190
|
+
if data.get("type") == "chunk":
|
|
191
|
+
yield data.get("chunk", "")
|
|
192
|
+
elif data.get("type") == "error":
|
|
193
|
+
yield f"\nError: {data.get('error', 'Unknown')}"
|
|
194
|
+
return
|
|
195
|
+
except json.JSONDecodeError:
|
|
196
|
+
yield line
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _load_credentials() -> Dict[str, Any]:
|
|
200
|
+
"""Load stored credentials."""
|
|
201
|
+
if CREDENTIALS_FILE.exists():
|
|
202
|
+
return json.loads(CREDENTIALS_FILE.read_text())
|
|
203
|
+
return {}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _save_credentials(creds: Dict[str, Any]) -> None:
|
|
207
|
+
"""Save credentials to disk."""
|
|
208
|
+
ARQERA_DIR.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
CREDENTIALS_FILE.write_text(json.dumps(creds, indent=2))
|
|
210
|
+
CREDENTIALS_FILE.chmod(0o600)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _get_api_key() -> Optional[str]:
|
|
214
|
+
"""Get API key from environment, credentials, or swarm key file."""
|
|
215
|
+
# Environment variable takes precedence
|
|
216
|
+
env_key = os.environ.get("ARQERA_API_KEY")
|
|
217
|
+
if env_key:
|
|
218
|
+
return env_key
|
|
219
|
+
creds = _load_credentials()
|
|
220
|
+
if creds.get("api_key"):
|
|
221
|
+
return creds["api_key"]
|
|
222
|
+
if SWARM_KEY_FILE.exists():
|
|
223
|
+
return SWARM_KEY_FILE.read_text().strip()
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _get_base_url() -> str:
|
|
228
|
+
"""Get base URL from environment or credentials."""
|
|
229
|
+
env_url = os.environ.get("ARQERA_BASE_URL")
|
|
230
|
+
if env_url:
|
|
231
|
+
return env_url
|
|
232
|
+
creds = _load_credentials()
|
|
233
|
+
return creds.get("base_url", DEFAULT_BASE_URL)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _print_error(msg: str) -> None:
|
|
237
|
+
"""Print an error message."""
|
|
238
|
+
print(f"\033[31mError:\033[0m {msg}", file=sys.stderr)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _print_success(msg: str) -> None:
|
|
242
|
+
"""Print a success message."""
|
|
243
|
+
print(f"\033[32mOK:\033[0m {msg}")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _print_json(data: Any) -> None:
|
|
247
|
+
"""Print formatted JSON."""
|
|
248
|
+
print(json.dumps(data, indent=2, default=str))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _require_auth() -> tuple[str, str]:
|
|
252
|
+
"""Get API key and base URL, or exit with error."""
|
|
253
|
+
api_key = _get_api_key()
|
|
254
|
+
if not api_key:
|
|
255
|
+
_print_error("Not authenticated. Run 'arqera login' or set ARQERA_API_KEY.")
|
|
256
|
+
sys.exit(1)
|
|
257
|
+
return api_key, _get_base_url()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# =============================================================================
|
|
261
|
+
# COMMANDS
|
|
262
|
+
# =============================================================================
|
|
263
|
+
|
|
264
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
265
|
+
"""Initialize a new Arqera project."""
|
|
266
|
+
project_dir = Path(args.directory or ".").resolve()
|
|
267
|
+
project_name = args.name or project_dir.name
|
|
268
|
+
project_title = project_name.replace("-", " ").replace("_", " ").title()
|
|
269
|
+
|
|
270
|
+
if args.template:
|
|
271
|
+
template_file = TEMPLATES_DIR / f"intent-{args.template}.yaml"
|
|
272
|
+
if not template_file.exists():
|
|
273
|
+
_print_error(f"Template '{args.template}' not found.")
|
|
274
|
+
available = [f.stem.replace("intent-", "") for f in TEMPLATES_DIR.glob("intent-*.yaml")]
|
|
275
|
+
if available:
|
|
276
|
+
print(f"Available templates: {', '.join(available)}")
|
|
277
|
+
return 1
|
|
278
|
+
manifest_dest = project_dir / MANIFEST_FILENAME
|
|
279
|
+
project_dir.mkdir(parents=True, exist_ok=True)
|
|
280
|
+
shutil.copy2(template_file, manifest_dest)
|
|
281
|
+
_print_success(f"Initialized from template '{args.template}' at {manifest_dest}")
|
|
282
|
+
else:
|
|
283
|
+
manifest_path = project_dir / MANIFEST_FILENAME
|
|
284
|
+
if manifest_path.exists() and not args.force:
|
|
285
|
+
_print_error(f"{MANIFEST_FILENAME} already exists. Use --force to overwrite.")
|
|
286
|
+
return 1
|
|
287
|
+
|
|
288
|
+
project_dir.mkdir(parents=True, exist_ok=True)
|
|
289
|
+
content = MANIFEST_TEMPLATE.format(
|
|
290
|
+
project_name=project_name,
|
|
291
|
+
project_title=project_title,
|
|
292
|
+
project_description=f"{project_title} powered by Arqera",
|
|
293
|
+
)
|
|
294
|
+
manifest_path.write_text(content)
|
|
295
|
+
_print_success(f"Initialized project at {project_dir}")
|
|
296
|
+
|
|
297
|
+
print(f" Manifest: {MANIFEST_FILENAME}")
|
|
298
|
+
print(f" Project: {project_name}")
|
|
299
|
+
print()
|
|
300
|
+
print("Next steps:")
|
|
301
|
+
print(" 1. Edit arqera.yaml to configure your workflow")
|
|
302
|
+
print(" 2. Run 'arqera login' to authenticate")
|
|
303
|
+
print(" 3. Run 'arqera deploy' to deploy your workflow")
|
|
304
|
+
return 0
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def cmd_login(args: argparse.Namespace) -> int:
|
|
308
|
+
"""Authenticate via browser (device code flow) or API key."""
|
|
309
|
+
base_url = args.base_url or DEFAULT_BASE_URL
|
|
310
|
+
|
|
311
|
+
if args.api_key:
|
|
312
|
+
return _login_with_api_key(args.api_key, base_url)
|
|
313
|
+
|
|
314
|
+
return _login_with_device_code(base_url)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _login_with_api_key(api_key: str, base_url: str) -> int:
|
|
318
|
+
"""Login with a provided API key."""
|
|
319
|
+
result = _api_request("/diagnostic/whoami", api_key=api_key, base_url=base_url)
|
|
320
|
+
if isinstance(result, dict) and result.get("error"):
|
|
321
|
+
_print_error(f"Authentication failed: {result.get('message', 'Unknown error')}")
|
|
322
|
+
return 1
|
|
323
|
+
|
|
324
|
+
_save_credentials({
|
|
325
|
+
"api_key": api_key,
|
|
326
|
+
"base_url": base_url,
|
|
327
|
+
"user": result.get("user") if isinstance(result, dict) else None,
|
|
328
|
+
"email": result.get("email") if isinstance(result, dict) else None,
|
|
329
|
+
"logged_in_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
user_name = result.get("user", "unknown") if isinstance(result, dict) else "unknown"
|
|
333
|
+
_print_success(f"Logged in as {user_name}")
|
|
334
|
+
print(f" Credentials saved to {CREDENTIALS_FILE}")
|
|
335
|
+
return 0
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _login_with_device_code(base_url: str) -> int:
|
|
339
|
+
"""Login via browser-based device code flow."""
|
|
340
|
+
result = _api_request("/auth/device/code", method="POST", body={}, base_url=base_url)
|
|
341
|
+
if isinstance(result, dict) and result.get("error"):
|
|
342
|
+
_print_error(f"Failed to start login: {result.get('message', 'Unknown error')}")
|
|
343
|
+
print("Tip: Use 'arqera login --api-key YOUR_KEY' for non-interactive login.")
|
|
344
|
+
return 1
|
|
345
|
+
|
|
346
|
+
device_code = result.get("device_code", "")
|
|
347
|
+
verification_url = result.get("verification_url", "")
|
|
348
|
+
expires_in = result.get("expires_in", 900)
|
|
349
|
+
interval = result.get("interval", 2)
|
|
350
|
+
|
|
351
|
+
print("Opening browser to authenticate...")
|
|
352
|
+
print(f" If browser doesn't open, visit: {verification_url}")
|
|
353
|
+
print(f" Your code: {device_code}")
|
|
354
|
+
print()
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
webbrowser.open(verification_url)
|
|
358
|
+
except Exception:
|
|
359
|
+
pass
|
|
360
|
+
|
|
361
|
+
print("Waiting for authentication...", end="", flush=True)
|
|
362
|
+
deadline = time.time() + expires_in
|
|
363
|
+
while time.time() < deadline:
|
|
364
|
+
time.sleep(interval)
|
|
365
|
+
print(".", end="", flush=True)
|
|
366
|
+
|
|
367
|
+
poll_result = _api_request(
|
|
368
|
+
"/auth/device/token",
|
|
369
|
+
method="POST",
|
|
370
|
+
body={"device_code": device_code},
|
|
371
|
+
base_url=base_url,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
if isinstance(poll_result, dict):
|
|
375
|
+
status = poll_result.get("status", "")
|
|
376
|
+
if status == "complete":
|
|
377
|
+
print()
|
|
378
|
+
api_key = poll_result.get("api_key") or poll_result.get("access_token")
|
|
379
|
+
if not api_key:
|
|
380
|
+
_print_error("Authentication succeeded but no credentials returned.")
|
|
381
|
+
return 1
|
|
382
|
+
|
|
383
|
+
whoami = _api_request("/diagnostic/whoami", api_key=api_key, base_url=base_url)
|
|
384
|
+
user_name = "unknown"
|
|
385
|
+
email = None
|
|
386
|
+
if isinstance(whoami, dict) and not whoami.get("error"):
|
|
387
|
+
user_name = whoami.get("user", "unknown")
|
|
388
|
+
email = whoami.get("email")
|
|
389
|
+
|
|
390
|
+
_save_credentials({
|
|
391
|
+
"api_key": api_key,
|
|
392
|
+
"base_url": base_url,
|
|
393
|
+
"user": user_name,
|
|
394
|
+
"email": email,
|
|
395
|
+
"logged_in_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
_print_success(f"Logged in as {email or user_name}")
|
|
399
|
+
print(f" Credentials saved to {CREDENTIALS_FILE}")
|
|
400
|
+
return 0
|
|
401
|
+
|
|
402
|
+
elif status == "expired":
|
|
403
|
+
print()
|
|
404
|
+
_print_error("Device code expired. Please try again.")
|
|
405
|
+
return 1
|
|
406
|
+
|
|
407
|
+
print()
|
|
408
|
+
_print_error("Authentication timed out. Please try again.")
|
|
409
|
+
return 1
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def cmd_whoami(args: argparse.Namespace) -> int:
|
|
413
|
+
"""Check identity and swarm connection."""
|
|
414
|
+
api_key, base_url = _require_auth()
|
|
415
|
+
|
|
416
|
+
result = _api_request("/diagnostic/whoami", api_key=api_key, base_url=base_url)
|
|
417
|
+
|
|
418
|
+
if isinstance(result, dict) and result.get("error"):
|
|
419
|
+
print("Not connected.")
|
|
420
|
+
_print_error(result.get("message", "Connection failed"))
|
|
421
|
+
return 1
|
|
422
|
+
|
|
423
|
+
if not isinstance(result, dict) or not result.get("connected"):
|
|
424
|
+
print("Not connected.")
|
|
425
|
+
return 1
|
|
426
|
+
|
|
427
|
+
is_admin = result.get("admin", False)
|
|
428
|
+
user_name = result.get("user", "unknown")
|
|
429
|
+
|
|
430
|
+
if is_admin:
|
|
431
|
+
print(f"Authenticated as {user_name} (admin).")
|
|
432
|
+
else:
|
|
433
|
+
print(f"Authenticated as {user_name}.")
|
|
434
|
+
|
|
435
|
+
if args.verbose:
|
|
436
|
+
print(f" Email: {result.get('email', 'N/A')}")
|
|
437
|
+
print(f" Actor: {result.get('actor_type', 'N/A')}")
|
|
438
|
+
print(f" Admin: {is_admin}")
|
|
439
|
+
print(f" API: {base_url}")
|
|
440
|
+
|
|
441
|
+
return 0
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def cmd_chat(args: argparse.Namespace) -> int:
|
|
445
|
+
"""Chat with Ore from the terminal (streaming)."""
|
|
446
|
+
api_key, base_url = _require_auth()
|
|
447
|
+
session_id = f"cli-{int(time.time())}"
|
|
448
|
+
|
|
449
|
+
if args.message:
|
|
450
|
+
return _chat_send(args.message, session_id, api_key, base_url, stream=not args.no_stream)
|
|
451
|
+
|
|
452
|
+
# Interactive REPL mode
|
|
453
|
+
print("Ore chat (Ctrl+C to exit)")
|
|
454
|
+
print()
|
|
455
|
+
try:
|
|
456
|
+
while True:
|
|
457
|
+
try:
|
|
458
|
+
message = input("\033[36myou>\033[0m ").strip()
|
|
459
|
+
except EOFError:
|
|
460
|
+
print()
|
|
461
|
+
break
|
|
462
|
+
if not message:
|
|
463
|
+
continue
|
|
464
|
+
_chat_send(message, session_id, api_key, base_url, stream=not args.no_stream)
|
|
465
|
+
print() # blank line between exchanges
|
|
466
|
+
except KeyboardInterrupt:
|
|
467
|
+
print()
|
|
468
|
+
|
|
469
|
+
return 0
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _chat_send(
|
|
473
|
+
message: str,
|
|
474
|
+
session_id: str,
|
|
475
|
+
api_key: str,
|
|
476
|
+
base_url: str,
|
|
477
|
+
stream: bool = True,
|
|
478
|
+
) -> int:
|
|
479
|
+
"""Send a single chat message and print the response."""
|
|
480
|
+
if stream:
|
|
481
|
+
# Streaming mode -- tokens arrive in real time
|
|
482
|
+
print("\033[33more>\033[0m ", end="", flush=True)
|
|
483
|
+
body = {"message": message, "session_id": session_id, "stream": True}
|
|
484
|
+
got_content = False
|
|
485
|
+
for chunk in _stream_sse("/ara/chat", body, api_key, base_url):
|
|
486
|
+
print(chunk, end="", flush=True)
|
|
487
|
+
got_content = True
|
|
488
|
+
if got_content:
|
|
489
|
+
print() # newline after stream
|
|
490
|
+
return 0
|
|
491
|
+
|
|
492
|
+
# Non-streaming fallback
|
|
493
|
+
result = _api_request(
|
|
494
|
+
"/ara/chat",
|
|
495
|
+
method="POST",
|
|
496
|
+
body={"message": message, "session_id": session_id},
|
|
497
|
+
api_key=api_key,
|
|
498
|
+
base_url=base_url,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
if isinstance(result, dict) and result.get("error"):
|
|
502
|
+
_print_error(result.get("message", "Chat request failed"))
|
|
503
|
+
return 1
|
|
504
|
+
|
|
505
|
+
if isinstance(result, dict):
|
|
506
|
+
reply = (
|
|
507
|
+
result.get("response")
|
|
508
|
+
or result.get("message")
|
|
509
|
+
or result.get("reply")
|
|
510
|
+
or result.get("content")
|
|
511
|
+
)
|
|
512
|
+
if reply:
|
|
513
|
+
print(f"\033[33more>\033[0m {reply}")
|
|
514
|
+
else:
|
|
515
|
+
_print_json(result)
|
|
516
|
+
else:
|
|
517
|
+
print(result)
|
|
518
|
+
|
|
519
|
+
return 0
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def cmd_briefing(args: argparse.Namespace) -> int:
|
|
523
|
+
"""View today's briefing."""
|
|
524
|
+
api_key, base_url = _require_auth()
|
|
525
|
+
|
|
526
|
+
result = _api_request(
|
|
527
|
+
"/page-data/home/briefing",
|
|
528
|
+
api_key=api_key,
|
|
529
|
+
base_url=base_url,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
if isinstance(result, dict) and result.get("error"):
|
|
533
|
+
_print_error(f"Failed to fetch briefing: {result.get('message', 'Unknown error')}")
|
|
534
|
+
return 1
|
|
535
|
+
|
|
536
|
+
if not isinstance(result, dict):
|
|
537
|
+
print("No briefing data available.")
|
|
538
|
+
return 0
|
|
539
|
+
|
|
540
|
+
# Display greeting
|
|
541
|
+
greeting = result.get("greeting", "")
|
|
542
|
+
if greeting:
|
|
543
|
+
print(f"\033[1m{greeting}\033[0m")
|
|
544
|
+
print()
|
|
545
|
+
|
|
546
|
+
# Display sections
|
|
547
|
+
sections = result.get("sections", [])
|
|
548
|
+
for section in sections:
|
|
549
|
+
title = section.get("title", "")
|
|
550
|
+
summary = section.get("summary", "")
|
|
551
|
+
if title:
|
|
552
|
+
print(f" \033[36m{title}\033[0m")
|
|
553
|
+
if summary:
|
|
554
|
+
print(f" {summary}")
|
|
555
|
+
items = section.get("items", [])
|
|
556
|
+
for item in items:
|
|
557
|
+
if isinstance(item, str):
|
|
558
|
+
print(f" - {item}")
|
|
559
|
+
elif isinstance(item, dict):
|
|
560
|
+
label = item.get("label", item.get("title", ""))
|
|
561
|
+
value = item.get("value", item.get("summary", ""))
|
|
562
|
+
if label:
|
|
563
|
+
print(f" - {label}: {value}")
|
|
564
|
+
print()
|
|
565
|
+
|
|
566
|
+
# Display energy
|
|
567
|
+
energy = result.get("energy", {})
|
|
568
|
+
if energy and isinstance(energy, dict):
|
|
569
|
+
level = energy.get("level", "")
|
|
570
|
+
label = energy.get("label", "")
|
|
571
|
+
if level or label:
|
|
572
|
+
print(f" Energy: {label or level}")
|
|
573
|
+
|
|
574
|
+
# Display dimensions summary
|
|
575
|
+
dimensions = result.get("dimensions", [])
|
|
576
|
+
if dimensions:
|
|
577
|
+
print()
|
|
578
|
+
print(" \033[36mDimensions\033[0m")
|
|
579
|
+
for dim in dimensions:
|
|
580
|
+
name = dim.get("label", dim.get("name", dim.get("dimension", "")))
|
|
581
|
+
score = dim.get("score", 0)
|
|
582
|
+
trend = dim.get("trend", "")
|
|
583
|
+
trend_arrow = {"up": "^", "down": "v", "stable": "-"}.get(trend, "")
|
|
584
|
+
bar_len = int(score * 20)
|
|
585
|
+
bar = "#" * bar_len + "." * (20 - bar_len)
|
|
586
|
+
print(f" {name:20s} [{bar}] {score:.0%} {trend_arrow}")
|
|
587
|
+
|
|
588
|
+
if args.json:
|
|
589
|
+
print()
|
|
590
|
+
_print_json(result)
|
|
591
|
+
|
|
592
|
+
return 0
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def cmd_governance_status(args: argparse.Namespace) -> int:
|
|
596
|
+
"""View governance health status."""
|
|
597
|
+
api_key, base_url = _require_auth()
|
|
598
|
+
|
|
599
|
+
# Fetch trust score
|
|
600
|
+
trust_result = _api_request("/ara/trust/score", api_key=api_key, base_url=base_url)
|
|
601
|
+
# Fetch compliance status
|
|
602
|
+
compliance_result = _api_request("/compliance", api_key=api_key, base_url=base_url)
|
|
603
|
+
|
|
604
|
+
print("\033[1mGovernance Status\033[0m")
|
|
605
|
+
print()
|
|
606
|
+
|
|
607
|
+
# Trust score
|
|
608
|
+
if isinstance(trust_result, dict) and not trust_result.get("error"):
|
|
609
|
+
overall = trust_result.get("overall", 0)
|
|
610
|
+
print(f" Trust Score: {overall:.0%}")
|
|
611
|
+
components = trust_result.get("components", {})
|
|
612
|
+
if components:
|
|
613
|
+
for name, value in components.items():
|
|
614
|
+
label = name.replace("_", " ").title()
|
|
615
|
+
print(f" {label}: {value:.0%}")
|
|
616
|
+
else:
|
|
617
|
+
print(" Trust Score: unavailable")
|
|
618
|
+
|
|
619
|
+
print()
|
|
620
|
+
|
|
621
|
+
# Compliance
|
|
622
|
+
if isinstance(compliance_result, dict) and not compliance_result.get("error"):
|
|
623
|
+
print(" Compliance:")
|
|
624
|
+
frameworks = compliance_result.get("frameworks", [])
|
|
625
|
+
if frameworks:
|
|
626
|
+
for fw in frameworks:
|
|
627
|
+
fw_name = fw.get("name", fw.get("framework", ""))
|
|
628
|
+
fw_status = fw.get("status", "")
|
|
629
|
+
print(f" {fw_name}: {fw_status}")
|
|
630
|
+
else:
|
|
631
|
+
status = compliance_result.get("status", "unknown")
|
|
632
|
+
print(f" Status: {status}")
|
|
633
|
+
else:
|
|
634
|
+
print(" Compliance: unavailable")
|
|
635
|
+
|
|
636
|
+
if args.json:
|
|
637
|
+
print()
|
|
638
|
+
_print_json({"trust": trust_result, "compliance": compliance_result})
|
|
639
|
+
|
|
640
|
+
return 0
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def cmd_governance_evaluate(args: argparse.Namespace) -> int:
|
|
644
|
+
"""Evaluate an action against governance laws."""
|
|
645
|
+
api_key, base_url = _require_auth()
|
|
646
|
+
|
|
647
|
+
body: Dict[str, Any] = {"action": args.action}
|
|
648
|
+
if args.description:
|
|
649
|
+
body["description"] = args.description
|
|
650
|
+
if args.context:
|
|
651
|
+
try:
|
|
652
|
+
body["context"] = json.loads(args.context)
|
|
653
|
+
except json.JSONDecodeError:
|
|
654
|
+
_print_error("Invalid JSON for --context")
|
|
655
|
+
return 1
|
|
656
|
+
|
|
657
|
+
result = _api_request(
|
|
658
|
+
"/ara/governance/evaluate",
|
|
659
|
+
method="POST",
|
|
660
|
+
body=body,
|
|
661
|
+
api_key=api_key,
|
|
662
|
+
base_url=base_url,
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
if isinstance(result, dict) and result.get("error"):
|
|
666
|
+
_print_error(f"Evaluation failed: {result.get('message', 'Unknown error')}")
|
|
667
|
+
return 1
|
|
668
|
+
|
|
669
|
+
if not isinstance(result, dict):
|
|
670
|
+
_print_json(result)
|
|
671
|
+
return 0
|
|
672
|
+
|
|
673
|
+
verdict = result.get("verdict", "unknown")
|
|
674
|
+
confidence = result.get("confidence", 0)
|
|
675
|
+
reasons = result.get("reasons", [])
|
|
676
|
+
|
|
677
|
+
# Colour the verdict
|
|
678
|
+
colour = {"proceed": "\033[32m", "review": "\033[33m", "deny": "\033[31m"}.get(
|
|
679
|
+
verdict, "\033[0m"
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
print(f" Verdict: {colour}{verdict}\033[0m")
|
|
683
|
+
print(f" Confidence: {confidence:.0%}")
|
|
684
|
+
if reasons:
|
|
685
|
+
print(" Reasons:")
|
|
686
|
+
for reason in reasons:
|
|
687
|
+
print(f" - {reason}")
|
|
688
|
+
|
|
689
|
+
laws = result.get("laws_evaluated", [])
|
|
690
|
+
if laws:
|
|
691
|
+
print(f" Laws checked: {', '.join(laws)}")
|
|
692
|
+
|
|
693
|
+
if args.json:
|
|
694
|
+
print()
|
|
695
|
+
_print_json(result)
|
|
696
|
+
|
|
697
|
+
return 0
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def cmd_evidence_search(args: argparse.Namespace) -> int:
|
|
701
|
+
"""Search the evidence trail."""
|
|
702
|
+
api_key, base_url = _require_auth()
|
|
703
|
+
|
|
704
|
+
params = f"?page=1&page_size={args.limit}"
|
|
705
|
+
if args.type:
|
|
706
|
+
params += f"&artifact_type={args.type}"
|
|
707
|
+
if args.start_date:
|
|
708
|
+
params += f"&start_date={args.start_date}"
|
|
709
|
+
if args.end_date:
|
|
710
|
+
params += f"&end_date={args.end_date}"
|
|
711
|
+
|
|
712
|
+
result = _api_request(
|
|
713
|
+
f"/ara/evidence{params}",
|
|
714
|
+
api_key=api_key,
|
|
715
|
+
base_url=base_url,
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
if isinstance(result, dict) and result.get("error"):
|
|
719
|
+
_print_error(f"Search failed: {result.get('message', 'Unknown error')}")
|
|
720
|
+
return 1
|
|
721
|
+
|
|
722
|
+
artifacts: list[Any] = []
|
|
723
|
+
if isinstance(result, list):
|
|
724
|
+
artifacts = result
|
|
725
|
+
elif isinstance(result, dict):
|
|
726
|
+
artifacts = result.get("items", result.get("artifacts", []))
|
|
727
|
+
|
|
728
|
+
if not artifacts:
|
|
729
|
+
print("No evidence artifacts found.")
|
|
730
|
+
return 0
|
|
731
|
+
|
|
732
|
+
print(f" Found {len(artifacts)} artifact(s):")
|
|
733
|
+
print()
|
|
734
|
+
for artifact in artifacts:
|
|
735
|
+
art_id = artifact.get("id", "")
|
|
736
|
+
art_type = artifact.get("artifact_type", "")
|
|
737
|
+
created = artifact.get("created_at", "")
|
|
738
|
+
print(f" [{created}] {art_type} (#{art_id})")
|
|
739
|
+
payload = artifact.get("payload", {})
|
|
740
|
+
if payload:
|
|
741
|
+
summary = json.dumps(payload, default=str)
|
|
742
|
+
if len(summary) > 120:
|
|
743
|
+
summary = summary[:117] + "..."
|
|
744
|
+
print(f" {summary}")
|
|
745
|
+
|
|
746
|
+
return 0
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def cmd_dimensions(args: argparse.Namespace) -> int:
|
|
750
|
+
"""View dimension scores."""
|
|
751
|
+
api_key, base_url = _require_auth()
|
|
752
|
+
|
|
753
|
+
result = _api_request("/briefing/dimensions", api_key=api_key, base_url=base_url)
|
|
754
|
+
|
|
755
|
+
if isinstance(result, dict) and result.get("error"):
|
|
756
|
+
_print_error(f"Failed to fetch dimensions: {result.get('message', 'Unknown error')}")
|
|
757
|
+
return 1
|
|
758
|
+
|
|
759
|
+
items: list[Any] = []
|
|
760
|
+
if isinstance(result, list):
|
|
761
|
+
items = result
|
|
762
|
+
elif isinstance(result, dict):
|
|
763
|
+
items = result.get("dimensions", result.get("items", []))
|
|
764
|
+
|
|
765
|
+
if not items:
|
|
766
|
+
print("No dimension data available.")
|
|
767
|
+
return 0
|
|
768
|
+
|
|
769
|
+
print("\033[1mLife Dimensions\033[0m")
|
|
770
|
+
print()
|
|
771
|
+
|
|
772
|
+
for dim in items:
|
|
773
|
+
name = dim.get("label", dim.get("name", dim.get("dimension", "")))
|
|
774
|
+
score = dim.get("score", 0)
|
|
775
|
+
trend = dim.get("trend", "stable")
|
|
776
|
+
conductance = dim.get("conductance", 0)
|
|
777
|
+
trend_arrow = {"up": "^", "down": "v", "stable": "-"}.get(trend, "")
|
|
778
|
+
bar_len = int(score * 20)
|
|
779
|
+
bar = "#" * bar_len + "." * (20 - bar_len)
|
|
780
|
+
print(f" {name:20s} [{bar}] {score:.0%} {trend_arrow} (c={conductance:.2f})")
|
|
781
|
+
|
|
782
|
+
if args.json:
|
|
783
|
+
print()
|
|
784
|
+
_print_json(result)
|
|
785
|
+
|
|
786
|
+
return 0
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def cmd_deploy(args: argparse.Namespace) -> int:
|
|
790
|
+
"""Deploy a workflow manifest to the platform."""
|
|
791
|
+
manifest_path = Path(args.manifest or MANIFEST_FILENAME)
|
|
792
|
+
if not manifest_path.exists():
|
|
793
|
+
_print_error(f"Manifest not found: {manifest_path}")
|
|
794
|
+
print("Run 'arqera init' to create one.")
|
|
795
|
+
return 1
|
|
796
|
+
|
|
797
|
+
api_key, base_url = _require_auth()
|
|
798
|
+
manifest_content = manifest_path.read_text()
|
|
799
|
+
|
|
800
|
+
if args.dry_run:
|
|
801
|
+
print("[DRY RUN] Would deploy manifest:")
|
|
802
|
+
print(f" File: {manifest_path}")
|
|
803
|
+
print(f" API: {base_url}")
|
|
804
|
+
print(f" Size: {len(manifest_content)} bytes")
|
|
805
|
+
return 0
|
|
806
|
+
|
|
807
|
+
result = _api_request(
|
|
808
|
+
"/v1/protocol/manifest",
|
|
809
|
+
method="POST",
|
|
810
|
+
body={"manifest_yaml": manifest_content, "status": "active"},
|
|
811
|
+
api_key=api_key,
|
|
812
|
+
base_url=base_url,
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
if isinstance(result, dict) and result.get("error"):
|
|
816
|
+
_print_error(f"Deploy failed: {result.get('message', 'Unknown error')}")
|
|
817
|
+
return 1
|
|
818
|
+
|
|
819
|
+
_print_success("Workflow deployed successfully.")
|
|
820
|
+
if isinstance(result, dict):
|
|
821
|
+
if result.get("id"):
|
|
822
|
+
print(f" Manifest ID: {result['id']}")
|
|
823
|
+
if result.get("version"):
|
|
824
|
+
print(f" Version: {result['version']}")
|
|
825
|
+
return 0
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def cmd_logs(args: argparse.Namespace) -> int:
|
|
829
|
+
"""Stream execution logs."""
|
|
830
|
+
api_key, base_url = _require_auth()
|
|
831
|
+
params = f"?limit={args.limit}"
|
|
832
|
+
if args.workflow_id:
|
|
833
|
+
params += f"&workflow_id={args.workflow_id}"
|
|
834
|
+
|
|
835
|
+
result = _api_request(
|
|
836
|
+
f"/v1/protocol/executions{params}",
|
|
837
|
+
api_key=api_key,
|
|
838
|
+
base_url=base_url,
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
if isinstance(result, dict) and result.get("error"):
|
|
842
|
+
_print_error(f"Failed to fetch logs: {result.get('message', 'Unknown error')}")
|
|
843
|
+
return 1
|
|
844
|
+
|
|
845
|
+
entries = []
|
|
846
|
+
if isinstance(result, list):
|
|
847
|
+
entries = result
|
|
848
|
+
elif isinstance(result, dict) and result.get("items"):
|
|
849
|
+
entries = result["items"]
|
|
850
|
+
|
|
851
|
+
if entries:
|
|
852
|
+
for entry in entries:
|
|
853
|
+
ts = entry.get("created_at", "")
|
|
854
|
+
status = entry.get("status", "unknown")
|
|
855
|
+
wf_id = entry.get("workflow_id", "N/A")
|
|
856
|
+
print(f"[{ts}] {status:12s} {wf_id}")
|
|
857
|
+
else:
|
|
858
|
+
_print_json(result)
|
|
859
|
+
|
|
860
|
+
return 0
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def cmd_agents_list(args: argparse.Namespace) -> int:
|
|
864
|
+
"""List available agents."""
|
|
865
|
+
api_key, base_url = _require_auth()
|
|
866
|
+
result = _api_request("/v1/agents", api_key=api_key, base_url=base_url)
|
|
867
|
+
|
|
868
|
+
if isinstance(result, dict) and result.get("error"):
|
|
869
|
+
_print_error(f"Failed to list agents: {result.get('message', 'Unknown error')}")
|
|
870
|
+
return 1
|
|
871
|
+
|
|
872
|
+
agents = result if isinstance(result, list) else (
|
|
873
|
+
result.get("items", []) if isinstance(result, dict) else []
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
if not agents:
|
|
877
|
+
print("No agents available.")
|
|
878
|
+
return 0
|
|
879
|
+
|
|
880
|
+
print(f"{'ID':<30s} {'Name':<25s} {'Status':<10s} {'Type':<15s}")
|
|
881
|
+
print("-" * 80)
|
|
882
|
+
for agent in agents:
|
|
883
|
+
agent_id = str(agent.get("id", "N/A"))
|
|
884
|
+
name = agent.get("name", "N/A")
|
|
885
|
+
status = agent.get("status", "N/A")
|
|
886
|
+
agent_type = agent.get("type", agent.get("role", "N/A"))
|
|
887
|
+
print(f"{agent_id:<30s} {name:<25s} {status:<10s} {agent_type:<15s}")
|
|
888
|
+
|
|
889
|
+
return 0
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def cmd_agents_run(args: argparse.Namespace) -> int:
|
|
893
|
+
"""Run an agent."""
|
|
894
|
+
api_key, base_url = _require_auth()
|
|
895
|
+
|
|
896
|
+
body: Dict[str, Any] = {"agent_id": args.agent_id}
|
|
897
|
+
if args.input:
|
|
898
|
+
try:
|
|
899
|
+
body["input"] = json.loads(args.input)
|
|
900
|
+
except json.JSONDecodeError:
|
|
901
|
+
body["input"] = {"prompt": args.input}
|
|
902
|
+
|
|
903
|
+
if args.model:
|
|
904
|
+
body["model"] = args.model
|
|
905
|
+
|
|
906
|
+
result = _api_request(
|
|
907
|
+
"/v1/agents/run",
|
|
908
|
+
method="POST",
|
|
909
|
+
body=body,
|
|
910
|
+
api_key=api_key,
|
|
911
|
+
base_url=base_url,
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
if isinstance(result, dict) and result.get("error"):
|
|
915
|
+
_print_error(f"Agent execution failed: {result.get('message', 'Unknown error')}")
|
|
916
|
+
return 1
|
|
917
|
+
|
|
918
|
+
_print_json(result)
|
|
919
|
+
return 0
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _load_manifest_file(manifest_path: Path) -> Optional[Dict[str, Any]]:
|
|
923
|
+
"""Load and parse a YAML or JSON manifest file, returning dict or None on error."""
|
|
924
|
+
content = manifest_path.read_text()
|
|
925
|
+
# Try YAML first (requires pyyaml), fall back to JSON
|
|
926
|
+
try:
|
|
927
|
+
import yaml
|
|
928
|
+
return yaml.safe_load(content)
|
|
929
|
+
except ImportError:
|
|
930
|
+
pass
|
|
931
|
+
except Exception:
|
|
932
|
+
pass
|
|
933
|
+
try:
|
|
934
|
+
return json.loads(content)
|
|
935
|
+
except json.JSONDecodeError as exc:
|
|
936
|
+
_print_error(f"Failed to parse manifest: {exc}")
|
|
937
|
+
return None
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _find_manifest_file(manifest_arg: Optional[str]) -> Optional[Path]:
|
|
941
|
+
"""Find the manifest file. Checks explicit path, then arqera.yaml, then manifest.yaml."""
|
|
942
|
+
if manifest_arg:
|
|
943
|
+
p = Path(manifest_arg)
|
|
944
|
+
if p.exists():
|
|
945
|
+
return p
|
|
946
|
+
_print_error(f"Manifest not found: {p}")
|
|
947
|
+
return None
|
|
948
|
+
for name in ("arqera.yaml", "manifest.yaml"):
|
|
949
|
+
p = Path(name)
|
|
950
|
+
if p.exists():
|
|
951
|
+
return p
|
|
952
|
+
_print_error("No manifest file found. Expected arqera.yaml or manifest.yaml in current directory.")
|
|
953
|
+
return None
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def cmd_validate(args: argparse.Namespace) -> int:
|
|
957
|
+
"""Validate an agent manifest against the AgentManifest v1 schema."""
|
|
958
|
+
manifest_path = _find_manifest_file(getattr(args, "manifest", None))
|
|
959
|
+
if not manifest_path:
|
|
960
|
+
return 1
|
|
961
|
+
|
|
962
|
+
manifest_data = _load_manifest_file(manifest_path)
|
|
963
|
+
if manifest_data is None:
|
|
964
|
+
return 1
|
|
965
|
+
|
|
966
|
+
api_key, base_url = _require_auth()
|
|
967
|
+
|
|
968
|
+
result = _api_request(
|
|
969
|
+
"/marketplace/manifest/validate",
|
|
970
|
+
method="POST",
|
|
971
|
+
body={"manifest": manifest_data},
|
|
972
|
+
api_key=api_key,
|
|
973
|
+
base_url=base_url,
|
|
974
|
+
)
|
|
975
|
+
|
|
976
|
+
if isinstance(result, dict) and result.get("error"):
|
|
977
|
+
_print_error(f"Validation request failed: {result.get('message', 'Unknown error')}")
|
|
978
|
+
return 1
|
|
979
|
+
|
|
980
|
+
if not isinstance(result, dict):
|
|
981
|
+
_print_error("Unexpected response from validation endpoint.")
|
|
982
|
+
return 1
|
|
983
|
+
|
|
984
|
+
if result.get("valid"):
|
|
985
|
+
_print_success(f"Manifest is valid ({manifest_path})")
|
|
986
|
+
if args.json:
|
|
987
|
+
_print_json(result.get("manifest", {}))
|
|
988
|
+
return 0
|
|
989
|
+
|
|
990
|
+
errors = result.get("errors", [])
|
|
991
|
+
_print_error(f"Manifest validation failed ({len(errors)} error(s)):")
|
|
992
|
+
for err in errors:
|
|
993
|
+
print(f" - {err}")
|
|
994
|
+
return 1
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def cmd_publish(args: argparse.Namespace) -> int:
|
|
998
|
+
"""Validate and publish an agent manifest to the marketplace."""
|
|
999
|
+
manifest_path = _find_manifest_file(getattr(args, "manifest", None))
|
|
1000
|
+
if not manifest_path:
|
|
1001
|
+
return 1
|
|
1002
|
+
|
|
1003
|
+
manifest_data = _load_manifest_file(manifest_path)
|
|
1004
|
+
if manifest_data is None:
|
|
1005
|
+
return 1
|
|
1006
|
+
|
|
1007
|
+
api_key, base_url = _require_auth()
|
|
1008
|
+
|
|
1009
|
+
# Step 1: Validate first
|
|
1010
|
+
val_result = _api_request(
|
|
1011
|
+
"/marketplace/manifest/validate",
|
|
1012
|
+
method="POST",
|
|
1013
|
+
body={"manifest": manifest_data},
|
|
1014
|
+
api_key=api_key,
|
|
1015
|
+
base_url=base_url,
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
if isinstance(val_result, dict) and not val_result.get("valid"):
|
|
1019
|
+
errors = val_result.get("errors", [])
|
|
1020
|
+
_print_error(f"Manifest validation failed ({len(errors)} error(s)):")
|
|
1021
|
+
for err in errors:
|
|
1022
|
+
print(f" - {err}")
|
|
1023
|
+
return 1
|
|
1024
|
+
|
|
1025
|
+
if isinstance(val_result, dict) and val_result.get("error"):
|
|
1026
|
+
_print_error(f"Validation request failed: {val_result.get('message', 'Unknown error')}")
|
|
1027
|
+
return 1
|
|
1028
|
+
|
|
1029
|
+
# Step 2: Publish to marketplace
|
|
1030
|
+
listing_body: Dict[str, Any] = {
|
|
1031
|
+
"name": manifest_data.get("name", "Unnamed Agent"),
|
|
1032
|
+
"description": manifest_data.get("listing", {}).get("description", ""),
|
|
1033
|
+
"type": manifest_data.get("slug", ""),
|
|
1034
|
+
"category": manifest_data.get("category", "utilities"),
|
|
1035
|
+
"manifest": manifest_data,
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
# Map pricing from manifest
|
|
1039
|
+
pricing = manifest_data.get("pricing", {})
|
|
1040
|
+
if pricing:
|
|
1041
|
+
listing_body["price_type"] = pricing.get("type", "free")
|
|
1042
|
+
listing_body["price_cents"] = pricing.get("cents", 0)
|
|
1043
|
+
|
|
1044
|
+
result = _api_request(
|
|
1045
|
+
"/marketplace/listings",
|
|
1046
|
+
method="POST",
|
|
1047
|
+
body=listing_body,
|
|
1048
|
+
api_key=api_key,
|
|
1049
|
+
base_url=base_url,
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
if isinstance(result, dict) and result.get("error"):
|
|
1053
|
+
_print_error(f"Publish failed: {result.get('message', 'Unknown error')}")
|
|
1054
|
+
return 1
|
|
1055
|
+
|
|
1056
|
+
_print_success("Agent published to marketplace.")
|
|
1057
|
+
if isinstance(result, dict):
|
|
1058
|
+
if result.get("id"):
|
|
1059
|
+
print(f" Listing ID: {result['id']}")
|
|
1060
|
+
if result.get("type"):
|
|
1061
|
+
print(f" Slug: {result['type']}")
|
|
1062
|
+
if result.get("status"):
|
|
1063
|
+
print(f" Status: {result['status']}")
|
|
1064
|
+
return 0
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def cmd_mcp_install(args: argparse.Namespace) -> int:
|
|
1068
|
+
"""Install ARQERA MCP server for Claude Code / Claude Desktop."""
|
|
1069
|
+
api_key, base_url = _require_auth()
|
|
1070
|
+
|
|
1071
|
+
mcp_server_path = Path(__file__).resolve().parent.parent / "mcp" / "server.py"
|
|
1072
|
+
if not mcp_server_path.exists():
|
|
1073
|
+
_print_error(f"MCP server not found at {mcp_server_path}")
|
|
1074
|
+
return 1
|
|
1075
|
+
|
|
1076
|
+
mcp_config = {
|
|
1077
|
+
"command": sys.executable,
|
|
1078
|
+
"args": [str(mcp_server_path)],
|
|
1079
|
+
"env": {
|
|
1080
|
+
"ARQERA_API_KEY": api_key,
|
|
1081
|
+
"ARQERA_BASE_URL": base_url,
|
|
1082
|
+
},
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
claude_config_path = Path.home() / ".claude" / "claude_desktop_config.json"
|
|
1086
|
+
claude_config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1087
|
+
|
|
1088
|
+
config: Dict[str, Any] = {}
|
|
1089
|
+
if claude_config_path.exists():
|
|
1090
|
+
try:
|
|
1091
|
+
config = json.loads(claude_config_path.read_text())
|
|
1092
|
+
except json.JSONDecodeError:
|
|
1093
|
+
config = {}
|
|
1094
|
+
|
|
1095
|
+
if "mcpServers" not in config:
|
|
1096
|
+
config["mcpServers"] = {}
|
|
1097
|
+
|
|
1098
|
+
config["mcpServers"]["arqera"] = mcp_config
|
|
1099
|
+
claude_config_path.write_text(json.dumps(config, indent=2))
|
|
1100
|
+
|
|
1101
|
+
_print_success("ARQERA MCP server installed for Claude Code")
|
|
1102
|
+
print(f" Config: {claude_config_path}")
|
|
1103
|
+
print(f" Server: {mcp_server_path}")
|
|
1104
|
+
print()
|
|
1105
|
+
print("Restart Claude Code to activate.")
|
|
1106
|
+
return 0
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
# =============================================================================
|
|
1110
|
+
# ARGUMENT PARSER
|
|
1111
|
+
# =============================================================================
|
|
1112
|
+
|
|
1113
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1114
|
+
"""Build the CLI argument parser."""
|
|
1115
|
+
parser = argparse.ArgumentParser(
|
|
1116
|
+
prog="arqera",
|
|
1117
|
+
description="ARQERA CLI -- AI governance from your terminal",
|
|
1118
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1119
|
+
epilog="""\
|
|
1120
|
+
Examples:
|
|
1121
|
+
arqera login --api-key ak_live_abc123
|
|
1122
|
+
arqera chat "What's my trust score?"
|
|
1123
|
+
arqera chat # interactive mode
|
|
1124
|
+
arqera briefing
|
|
1125
|
+
arqera governance status
|
|
1126
|
+
arqera governance evaluate --action email.send
|
|
1127
|
+
arqera dimensions
|
|
1128
|
+
arqera evidence search --type governance_evaluation
|
|
1129
|
+
arqera agents list
|
|
1130
|
+
arqera validate # validate arqera.yaml
|
|
1131
|
+
arqera publish # publish agent to marketplace
|
|
1132
|
+
|
|
1133
|
+
Documentation: https://docs.arqera.io/sdk/cli
|
|
1134
|
+
""",
|
|
1135
|
+
)
|
|
1136
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
1137
|
+
|
|
1138
|
+
# init
|
|
1139
|
+
p_init = subparsers.add_parser(
|
|
1140
|
+
"init", help="Initialize a new Arqera project",
|
|
1141
|
+
description="Create an arqera.yaml manifest in the target directory.",
|
|
1142
|
+
)
|
|
1143
|
+
p_init.add_argument("directory", nargs="?", help="Project directory (default: current)")
|
|
1144
|
+
p_init.add_argument("--name", "-n", help="Project name (default: directory name)")
|
|
1145
|
+
p_init.add_argument(
|
|
1146
|
+
"--template", "-t",
|
|
1147
|
+
help="Use a template (e.g. support-triage, workflow-automation)",
|
|
1148
|
+
)
|
|
1149
|
+
p_init.add_argument("--force", "-f", action="store_true", help="Overwrite existing manifest")
|
|
1150
|
+
|
|
1151
|
+
# login
|
|
1152
|
+
p_login = subparsers.add_parser(
|
|
1153
|
+
"login", help="Authenticate and store credentials",
|
|
1154
|
+
description="Log in with an API key or browser-based device code flow.",
|
|
1155
|
+
)
|
|
1156
|
+
p_login.add_argument("--api-key", "-k", help="API key (opens browser if not provided)")
|
|
1157
|
+
p_login.add_argument("--base-url", help=f"API base URL (default: {DEFAULT_BASE_URL})")
|
|
1158
|
+
|
|
1159
|
+
# whoami
|
|
1160
|
+
p_whoami = subparsers.add_parser(
|
|
1161
|
+
"whoami", help="Check identity and connection",
|
|
1162
|
+
description="Verify your authentication and display account details.",
|
|
1163
|
+
)
|
|
1164
|
+
p_whoami.add_argument("--verbose", "-v", action="store_true", help="Show detailed info")
|
|
1165
|
+
|
|
1166
|
+
# chat
|
|
1167
|
+
p_chat = subparsers.add_parser(
|
|
1168
|
+
"chat", help="Chat with Ore (streaming)",
|
|
1169
|
+
description="Send messages to Ore. Tokens stream in real time.\n"
|
|
1170
|
+
"Omit the message for interactive REPL mode.",
|
|
1171
|
+
)
|
|
1172
|
+
p_chat.add_argument("message", nargs="?", default=None, help="Message (omit for interactive)")
|
|
1173
|
+
p_chat.add_argument("--no-stream", action="store_true", help="Disable streaming")
|
|
1174
|
+
|
|
1175
|
+
# briefing
|
|
1176
|
+
p_briefing = subparsers.add_parser(
|
|
1177
|
+
"briefing", help="View today's briefing",
|
|
1178
|
+
description="Display your personalised daily briefing with\n"
|
|
1179
|
+
"greeting, key sections, energy, and dimension scores.",
|
|
1180
|
+
)
|
|
1181
|
+
p_briefing.add_argument("--json", action="store_true", help="Output raw JSON")
|
|
1182
|
+
|
|
1183
|
+
# governance
|
|
1184
|
+
p_governance = subparsers.add_parser("governance", help="Governance operations")
|
|
1185
|
+
gov_sub = p_governance.add_subparsers(dest="governance_command", help="Governance commands")
|
|
1186
|
+
|
|
1187
|
+
p_gov_status = gov_sub.add_parser(
|
|
1188
|
+
"status", help="View governance health",
|
|
1189
|
+
description="Display trust score, compliance status, and governance metrics.",
|
|
1190
|
+
)
|
|
1191
|
+
p_gov_status.add_argument("--json", action="store_true", help="Output raw JSON")
|
|
1192
|
+
|
|
1193
|
+
p_gov_eval = gov_sub.add_parser(
|
|
1194
|
+
"evaluate", help="Evaluate an action against governance laws",
|
|
1195
|
+
description="Submit an action for governance evaluation and see the verdict.",
|
|
1196
|
+
)
|
|
1197
|
+
p_gov_eval.add_argument("--action", "-a", required=True, help="Action identifier (e.g. email.send)")
|
|
1198
|
+
p_gov_eval.add_argument("--description", "-d", help="Human-readable description")
|
|
1199
|
+
p_gov_eval.add_argument("--context", "-c", help="Additional context as JSON string")
|
|
1200
|
+
p_gov_eval.add_argument("--json", action="store_true", help="Output raw JSON")
|
|
1201
|
+
|
|
1202
|
+
# evidence
|
|
1203
|
+
p_evidence = subparsers.add_parser("evidence", help="Evidence trail operations")
|
|
1204
|
+
evidence_sub = p_evidence.add_subparsers(dest="evidence_command", help="Evidence commands")
|
|
1205
|
+
|
|
1206
|
+
p_ev_search = evidence_sub.add_parser(
|
|
1207
|
+
"search", help="Search the evidence trail",
|
|
1208
|
+
description="Search evidence artifacts with optional filters.",
|
|
1209
|
+
)
|
|
1210
|
+
p_ev_search.add_argument("--type", "-t", help="Filter by artifact type")
|
|
1211
|
+
p_ev_search.add_argument("--start-date", help="Start date (ISO format)")
|
|
1212
|
+
p_ev_search.add_argument("--end-date", help="End date (ISO format)")
|
|
1213
|
+
p_ev_search.add_argument("--limit", "-l", type=int, default=20, help="Max results (default: 20)")
|
|
1214
|
+
|
|
1215
|
+
# dimensions
|
|
1216
|
+
p_dimensions = subparsers.add_parser(
|
|
1217
|
+
"dimensions", help="View dimension scores",
|
|
1218
|
+
description="Display scores for all life dimensions with trends\n"
|
|
1219
|
+
"and Physarum conductance values.",
|
|
1220
|
+
)
|
|
1221
|
+
p_dimensions.add_argument("--json", action="store_true", help="Output raw JSON")
|
|
1222
|
+
|
|
1223
|
+
# agents
|
|
1224
|
+
p_agents = subparsers.add_parser("agents", help="Manage agents")
|
|
1225
|
+
agents_sub = p_agents.add_subparsers(dest="agents_command", help="Agent commands")
|
|
1226
|
+
|
|
1227
|
+
agents_sub.add_parser("list", help="List available agents")
|
|
1228
|
+
|
|
1229
|
+
p_agents_run = agents_sub.add_parser("run", help="Run an agent")
|
|
1230
|
+
p_agents_run.add_argument("agent_id", help="Agent ID to execute")
|
|
1231
|
+
p_agents_run.add_argument("--input", "-i", help="Input JSON or text prompt")
|
|
1232
|
+
p_agents_run.add_argument("--model", help="Model override")
|
|
1233
|
+
|
|
1234
|
+
# validate
|
|
1235
|
+
p_validate = subparsers.add_parser(
|
|
1236
|
+
"validate", help="Validate an agent manifest",
|
|
1237
|
+
description="Validate arqera.yaml or manifest.yaml against the AgentManifest v1 schema.",
|
|
1238
|
+
)
|
|
1239
|
+
p_validate.add_argument("--manifest", "-m", help="Manifest file (default: arqera.yaml)")
|
|
1240
|
+
p_validate.add_argument("--json", action="store_true", help="Output validated manifest as JSON")
|
|
1241
|
+
|
|
1242
|
+
# publish
|
|
1243
|
+
p_publish = subparsers.add_parser(
|
|
1244
|
+
"publish", help="Publish an agent to the marketplace",
|
|
1245
|
+
description="Validate and submit an agent manifest as a marketplace listing.",
|
|
1246
|
+
)
|
|
1247
|
+
p_publish.add_argument("--manifest", "-m", help="Manifest file (default: arqera.yaml)")
|
|
1248
|
+
|
|
1249
|
+
# deploy
|
|
1250
|
+
p_deploy = subparsers.add_parser("deploy", help="Deploy a workflow manifest")
|
|
1251
|
+
p_deploy.add_argument("--manifest", "-m", help=f"Manifest file (default: {MANIFEST_FILENAME})")
|
|
1252
|
+
p_deploy.add_argument("--dry-run", action="store_true", help="Preview without deploying")
|
|
1253
|
+
|
|
1254
|
+
# logs
|
|
1255
|
+
p_logs = subparsers.add_parser("logs", help="Stream execution logs")
|
|
1256
|
+
p_logs.add_argument("--workflow-id", "-w", help="Filter by workflow ID")
|
|
1257
|
+
p_logs.add_argument("--limit", "-l", type=int, default=20, help="Number of entries (default: 20)")
|
|
1258
|
+
|
|
1259
|
+
# mcp
|
|
1260
|
+
p_mcp = subparsers.add_parser("mcp", help="MCP server management")
|
|
1261
|
+
mcp_sub = p_mcp.add_subparsers(dest="mcp_command", help="MCP commands")
|
|
1262
|
+
mcp_sub.add_parser("install", help="Install ARQERA MCP server for Claude Code")
|
|
1263
|
+
|
|
1264
|
+
return parser
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
# =============================================================================
|
|
1268
|
+
# MAIN
|
|
1269
|
+
# =============================================================================
|
|
1270
|
+
|
|
1271
|
+
def main() -> int:
|
|
1272
|
+
"""CLI entry point."""
|
|
1273
|
+
parser = build_parser()
|
|
1274
|
+
args = parser.parse_args()
|
|
1275
|
+
|
|
1276
|
+
if not args.command:
|
|
1277
|
+
parser.print_help()
|
|
1278
|
+
return 0
|
|
1279
|
+
|
|
1280
|
+
# Top-level commands
|
|
1281
|
+
top_commands = {
|
|
1282
|
+
"init": cmd_init,
|
|
1283
|
+
"login": cmd_login,
|
|
1284
|
+
"whoami": cmd_whoami,
|
|
1285
|
+
"chat": cmd_chat,
|
|
1286
|
+
"briefing": cmd_briefing,
|
|
1287
|
+
"validate": cmd_validate,
|
|
1288
|
+
"publish": cmd_publish,
|
|
1289
|
+
"deploy": cmd_deploy,
|
|
1290
|
+
"logs": cmd_logs,
|
|
1291
|
+
"dimensions": cmd_dimensions,
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
if args.command in top_commands:
|
|
1295
|
+
return top_commands[args.command](args)
|
|
1296
|
+
|
|
1297
|
+
# Nested commands
|
|
1298
|
+
if args.command == "governance":
|
|
1299
|
+
if not getattr(args, "governance_command", None):
|
|
1300
|
+
parser.parse_args(["governance", "--help"])
|
|
1301
|
+
return 0
|
|
1302
|
+
gov_commands = {
|
|
1303
|
+
"status": cmd_governance_status,
|
|
1304
|
+
"evaluate": cmd_governance_evaluate,
|
|
1305
|
+
}
|
|
1306
|
+
return gov_commands[args.governance_command](args)
|
|
1307
|
+
|
|
1308
|
+
if args.command == "evidence":
|
|
1309
|
+
if not getattr(args, "evidence_command", None):
|
|
1310
|
+
parser.parse_args(["evidence", "--help"])
|
|
1311
|
+
return 0
|
|
1312
|
+
ev_commands = {
|
|
1313
|
+
"search": cmd_evidence_search,
|
|
1314
|
+
}
|
|
1315
|
+
return ev_commands[args.evidence_command](args)
|
|
1316
|
+
|
|
1317
|
+
if args.command == "mcp":
|
|
1318
|
+
if not getattr(args, "mcp_command", None):
|
|
1319
|
+
parser.parse_args(["mcp", "--help"])
|
|
1320
|
+
return 0
|
|
1321
|
+
mcp_commands = {
|
|
1322
|
+
"install": cmd_mcp_install,
|
|
1323
|
+
}
|
|
1324
|
+
return mcp_commands[args.mcp_command](args)
|
|
1325
|
+
|
|
1326
|
+
if args.command == "agents":
|
|
1327
|
+
if not getattr(args, "agents_command", None):
|
|
1328
|
+
parser.parse_args(["agents", "--help"])
|
|
1329
|
+
return 0
|
|
1330
|
+
agent_commands = {
|
|
1331
|
+
"list": cmd_agents_list,
|
|
1332
|
+
"run": cmd_agents_run,
|
|
1333
|
+
}
|
|
1334
|
+
return agent_commands[args.agents_command](args)
|
|
1335
|
+
|
|
1336
|
+
parser.print_help()
|
|
1337
|
+
return 1
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
if __name__ == "__main__":
|
|
1341
|
+
sys.exit(main())
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: arqera-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: ARQERA CLI -- AI governance from your terminal
|
|
5
|
+
Project-URL: Homepage, https://arqera.io
|
|
6
|
+
Project-URL: Repository, https://github.com/Arqera-IO/ARQERA
|
|
7
|
+
Project-URL: Documentation, https://docs.arqera.io/sdk/cli
|
|
8
|
+
Author-email: ARQERA <engineering@arqera.io>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Keywords: ai,arqera,cli,compliance,evidence,governance
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Security
|
|
23
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Provides-Extra: streaming
|
|
26
|
+
Requires-Dist: httpx>=0.25.0; extra == 'streaming'
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
arqera_cli/__init__.py,sha256=q_5jq8UEgp0a9kgqetDF8l56HptFRs1-lTxyTOq4KGM,165
|
|
2
|
+
arqera_cli/cli.py,sha256=UVi99gueRsyyel4IQoSTseGZabPAnK7dsubd23lfXug,45229
|
|
3
|
+
arqera_cli-0.2.0.dist-info/METADATA,sha256=aKnTjbJhll8VklOw6w_l4GlE6_-RNfoWw5x3Qj3oO4Y,1123
|
|
4
|
+
arqera_cli-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
5
|
+
arqera_cli-0.2.0.dist-info/entry_points.txt,sha256=VR5C1jBjZSbmPs3FAih0v2z06y-24ufOhE21TqEqXoI,47
|
|
6
|
+
arqera_cli-0.2.0.dist-info/RECORD,,
|