inferenceport-cli 1.0.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.
- inferenceport_cli-1.0.0.dist-info/METADATA +132 -0
- inferenceport_cli-1.0.0.dist-info/RECORD +39 -0
- inferenceport_cli-1.0.0.dist-info/WHEEL +5 -0
- inferenceport_cli-1.0.0.dist-info/entry_points.txt +2 -0
- inferenceport_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
- inferenceport_cli-1.0.0.dist-info/top_level.txt +1 -0
- ipx/__init__.py +4 -0
- ipx/__main__.py +4 -0
- ipx/app.py +78 -0
- ipx/auth.py +260 -0
- ipx/chat.py +314 -0
- ipx/cli.py +142 -0
- ipx/clickx.py +160 -0
- ipx/cloud.py +340 -0
- ipx/commands/__init__.py +0 -0
- ipx/commands/agent.py +459 -0
- ipx/commands/auth.py +269 -0
- ipx/commands/cloudgen.py +559 -0
- ipx/commands/config_cmd.py +85 -0
- ipx/commands/featherless.py +252 -0
- ipx/commands/gen.py +3 -0
- ipx/commands/imports.py +198 -0
- ipx/commands/local.py +88 -0
- ipx/commands/mcp.py +182 -0
- ipx/commands/misc.py +168 -0
- ipx/commands/model.py +234 -0
- ipx/commands/org.py +915 -0
- ipx/commands/p2g.py +3 -0
- ipx/commands/server.py +328 -0
- ipx/commands/shield.py +1079 -0
- ipx/commands/usage.py +123 -0
- ipx/config.py +215 -0
- ipx/errors.py +41 -0
- ipx/http.py +373 -0
- ipx/local.py +90 -0
- ipx/mcp.py +134 -0
- ipx/policy.py +139 -0
- ipx/proxy.py +279 -0
- ipx/ui.py +360 -0
ipx/commands/agent.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""ipx agent - InferencePort managed agents: a backing model + system prompt (+ MCP servers) callable as `agent/<user>/<id>`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from ipx import chat as chatmod
|
|
13
|
+
from ipx import cloud, ui
|
|
14
|
+
from ipx.app import App, pass_app
|
|
15
|
+
from ipx.clickx import IpxGroup
|
|
16
|
+
from ipx.cloud import P2G
|
|
17
|
+
from ipx.errors import IpxError
|
|
18
|
+
|
|
19
|
+
ROLES = ["user", "editor", "inviter", "administrator"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# --------------------------------------------------------------------------- helpers
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def rows_of(d: Any, key: str = "data") -> List[Dict[str, Any]]:
|
|
26
|
+
rows = d.get(key) if isinstance(d, dict) else d
|
|
27
|
+
return [x for x in (rows or []) if isinstance(x, dict)]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def split_ref(app: App, ref: str) -> Tuple[str, str]:
|
|
31
|
+
"""`user/agent`, `agent/user/agent` or a bare agent id (yours) -> (username, agent_id)."""
|
|
32
|
+
parts = [p for p in ref.strip().split("/") if p]
|
|
33
|
+
if parts and parts[0] == "agent":
|
|
34
|
+
parts = parts[1:]
|
|
35
|
+
if len(parts) == 2:
|
|
36
|
+
return parts[0].lower(), parts[1].lower()
|
|
37
|
+
if len(parts) == 1:
|
|
38
|
+
d = app.api.get("/v1/agents/profile", org=False) or {}
|
|
39
|
+
if not d.get("username"):
|
|
40
|
+
raise IpxError("You have no agent username yet.", "Set one with `ipx agent profile set <username>`, or use the full user/agent reference.")
|
|
41
|
+
return d["username"], parts[0].lower()
|
|
42
|
+
raise IpxError(f"'{ref}' is not an agent reference.", "Use <username>/<agent-id>, e.g. `acme-bot/support`.")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def path(app: App, ref: str, suffix: str = "") -> str:
|
|
46
|
+
user, aid = split_ref(app, ref)
|
|
47
|
+
return f"/v1/agents/{user}/{aid}{suffix}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def api(app: App, method: str, p: str, body: Any = None, **kw: Any) -> Any:
|
|
51
|
+
kw.setdefault("org", False) # agents belong to people; only chat bills an organization
|
|
52
|
+
r = app.api.request(method, p, json=body, **kw)
|
|
53
|
+
return r.json() if r.body else {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def read_text(value: Optional[str], file: Optional[str]) -> Optional[str]:
|
|
57
|
+
if file:
|
|
58
|
+
return sys.stdin.read() if file == "-" else open(file, "r", encoding="utf-8").read()
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_mcp(pairs: Tuple[str, ...], files: Tuple[str, ...]) -> Optional[List[Dict[str, Any]]]:
|
|
63
|
+
out: List[Dict[str, Any]] = []
|
|
64
|
+
for f in files:
|
|
65
|
+
try:
|
|
66
|
+
val = json.loads(sys.stdin.read() if f == "-" else open(f, "r", encoding="utf-8").read())
|
|
67
|
+
except ValueError as e:
|
|
68
|
+
raise IpxError(f"Invalid JSON in {f}: {e}")
|
|
69
|
+
out.extend(val if isinstance(val, list) else [val])
|
|
70
|
+
for p in pairs:
|
|
71
|
+
name, sep, url = p.partition("=")
|
|
72
|
+
if not sep or not name.strip() or not url.strip():
|
|
73
|
+
raise IpxError(f"Bad --mcp value '{p}'.", "Use NAME=URL, e.g. --mcp docs=https://mcp.example.com/mcp. For auth, pass a JSON file with --mcp-file.")
|
|
74
|
+
out.append({"name": name.strip(), "url": url.strip(), "transport": "auto", "auth_type": "none"})
|
|
75
|
+
return out or None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def vis_text(v: Any) -> Text:
|
|
79
|
+
return Text(str(v or "—"), style="ok" if v == "public" else "muted")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def show_agent(a: Dict[str, Any]) -> None:
|
|
83
|
+
rows = [
|
|
84
|
+
("Model id", a.get("id")), ("Name", a.get("name")), ("Description", a.get("description")),
|
|
85
|
+
("Backing model", a.get("backing_model")), ("Visibility", vis_text(a.get("visibility"))),
|
|
86
|
+
("Registry", a.get("registry")), ("Billing", a.get("payer_label") or a.get("payer")),
|
|
87
|
+
("Your role", a.get("role")), ("MCP servers", ", ".join(f"{s.get('name')} ({s.get('url')})" for s in a.get("mcp_servers") or []) or None),
|
|
88
|
+
("Updated", str(a.get("updated_at") or "")[:16].replace("T", " ") or None),
|
|
89
|
+
]
|
|
90
|
+
ui.console.print(ui.kv_panel(str(a.get("name") or a.get("id") or "Agent"), [r for r in rows if r[1] not in (None, "")]))
|
|
91
|
+
if a.get("system_prompt"):
|
|
92
|
+
ui.console.print(ui.kv_panel("System prompt", [("", a["system_prompt"])]))
|
|
93
|
+
members = a.get("members") or []
|
|
94
|
+
if members:
|
|
95
|
+
ui.console.print(ui.make_table(["User", "Email", "Role"], [(m.get("user_id"), m.get("email"), m.get("role")) for m in members], title="Members"))
|
|
96
|
+
for inv in a.get("invites") or []:
|
|
97
|
+
ui.console.print(f" pending invite: {inv.get('email') or inv.get('invitee_email') or inv.get('user_id')} as {inv.get('role')} · {inv.get('invite_url')}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def agent_table(rows: List[Dict[str, Any]], title: str, owner: bool = True) -> None:
|
|
101
|
+
ui.console.print(ui.make_table(
|
|
102
|
+
["Agent", "Name", "Backing model", "Visibility", "Billing"] + (["Role"] if owner else ["Owner"]),
|
|
103
|
+
[(str(x.get("id") or "").removeprefix("agent/"), x.get("name"), x.get("backing_model"), vis_text(x.get("visibility")), "owner pays" if x.get("payer") == "owner" else "caller pays",
|
|
104
|
+
(x.get("role") or ("—")) if owner else x.get("owner_username")) for x in rows],
|
|
105
|
+
title=f"{title} ({len(rows)})"))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def agent_options(f):
|
|
109
|
+
f = click.option("--mcp-file", "mcp_files", multiple=True, type=click.Path(), help="JSON file (or - for stdin) with MCP server objects/a list (name, url, transport, auth_type, token…).")(f)
|
|
110
|
+
f = click.option("--mcp", "mcp_pairs", multiple=True, metavar="NAME=URL", help="Attach an MCP server without auth (repeatable).")(f)
|
|
111
|
+
f = click.option("--payer", type=click.Choice(["caller", "owner"]), help="Who pays for each call (default: caller).")(f)
|
|
112
|
+
f = click.option("--registry", type=click.Choice(["published", "unlisted"]), help="Public agents: list in the discovery registry or keep unlisted.")(f)
|
|
113
|
+
f = click.option("--visibility", type=click.Choice(["private", "public"]), help="Who may call it (default: private).")(f)
|
|
114
|
+
f = click.option("--system-file", help="Read the system prompt from a file (or - for stdin).")(f)
|
|
115
|
+
f = click.option("--system", help="System prompt.")(f)
|
|
116
|
+
f = click.option("--description", help="Short description (max 500 characters).")(f)
|
|
117
|
+
return f
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def body_from(name, model, description, system, system_file, visibility, registry, payer, mcp_pairs, mcp_files) -> Dict[str, Any]:
|
|
121
|
+
b: Dict[str, Any] = {}
|
|
122
|
+
for k, v in (("name", name), ("model", model), ("description", description), ("system_prompt", read_text(system, system_file)),
|
|
123
|
+
("visibility", visibility), ("registry", registry), ("payer", payer)):
|
|
124
|
+
if v is not None:
|
|
125
|
+
b[k] = v
|
|
126
|
+
mcp = parse_mcp(mcp_pairs, mcp_files)
|
|
127
|
+
if mcp is not None:
|
|
128
|
+
b["mcp_servers"] = mcp
|
|
129
|
+
return b
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# --------------------------------------------------------------------------- root
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@click.group("agent", cls=IpxGroup, section="Cloud", short_help="Managed agents: create, share and chat",
|
|
136
|
+
examples=["ipx agent profile set acme-bot", "ipx agent create support --name \"Support\" --model lightning --system \"Be kind.\"",
|
|
137
|
+
"ipx agent list", "ipx agent chat acme-bot/support", "ipx agent invite create acme-bot/support dev@acme.com --role editor"])
|
|
138
|
+
def group() -> None:
|
|
139
|
+
"""Build and share AI agents.
|
|
140
|
+
|
|
141
|
+
An agent is a backing model plus a system prompt and optional MCP servers, called anywhere as
|
|
142
|
+
`agent/<username>/<agent-id>`. Calls are billed at standard per-token rates to the caller or, if you choose
|
|
143
|
+
`--payer owner`, to you. Set an organization context to bill chats with an agent to the organization.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@group.command("list", short_help="Agents you can call")
|
|
148
|
+
@click.option("--mine", is_flag=True, help="Only agents you own.")
|
|
149
|
+
@pass_app
|
|
150
|
+
def list_(app: App, mine: bool) -> None:
|
|
151
|
+
"""Your agents, agents shared with you, and public agents you have used."""
|
|
152
|
+
app.require_login()
|
|
153
|
+
d = api(app, "GET", "/v1/agents/models") or {}
|
|
154
|
+
rows = rows_of(d)
|
|
155
|
+
if mine:
|
|
156
|
+
rows = [r for r in rows if r.get("is_owner")]
|
|
157
|
+
|
|
158
|
+
def render(_: Any) -> None:
|
|
159
|
+
if not rows:
|
|
160
|
+
ui.info("No agents yet. Create one with `ipx agent create`, or browse `ipx agent registry`.")
|
|
161
|
+
return
|
|
162
|
+
agent_table(rows, "Agents")
|
|
163
|
+
|
|
164
|
+
app.out({**d, "data": rows} if isinstance(d, dict) else rows, render)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@group.command("registry", short_help="Browse public agents")
|
|
168
|
+
@click.option("-q", "--query", "q", help="Search by name, id or description.")
|
|
169
|
+
@click.option("--limit", type=click.IntRange(1, 500), default=50, show_default=True)
|
|
170
|
+
@pass_app
|
|
171
|
+
def registry(app: App, q: Optional[str], limit: int) -> None:
|
|
172
|
+
"""Public agents that opted into the discovery registry."""
|
|
173
|
+
app.require_login()
|
|
174
|
+
d = api(app, "GET", "/v1/agents/registry", params={"q": q, "limit": limit}) or {}
|
|
175
|
+
rows = rows_of(d)
|
|
176
|
+
app.out(d, lambda _: agent_table(rows, "Agent registry", owner=False) if rows else ui.info("No public agents match."))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@group.command("get", short_help="Show an agent")
|
|
180
|
+
@click.argument("agent")
|
|
181
|
+
@pass_app
|
|
182
|
+
def get(app: App, agent: str) -> None:
|
|
183
|
+
"""Details of AGENT (`username/agent-id`). Owners and editors also see the system prompt and members."""
|
|
184
|
+
app.require_login()
|
|
185
|
+
d = api(app, "GET", path(app, agent)) or {}
|
|
186
|
+
app.out(d, lambda x: show_agent(x.get("agent") or x))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@group.command("create", short_help="Create an agent", examples=[
|
|
190
|
+
"ipx agent create support --name Support --model lightning --system-file prompt.md --visibility public",
|
|
191
|
+
"ipx agent create docs-bot --name \"Docs bot\" --model openai/gpt-4o --mcp docs=https://mcp.example.com/mcp"])
|
|
192
|
+
@click.argument("agent_id")
|
|
193
|
+
@click.option("--name", required=True, help="Display name.")
|
|
194
|
+
@click.option("-m", "--model", required=True, help="Backing catalog model (see `ipx p2g models`).")
|
|
195
|
+
@agent_options
|
|
196
|
+
@pass_app
|
|
197
|
+
def create(app: App, agent_id, name, model, description, system, system_file, visibility, registry, payer, mcp_pairs, mcp_files) -> None:
|
|
198
|
+
"""Create AGENT_ID (3+ lowercase letters, numbers, hyphens) under your agent username."""
|
|
199
|
+
app.require_login()
|
|
200
|
+
body = body_from(name, model, description, system, system_file, visibility, registry, payer, mcp_pairs, mcp_files)
|
|
201
|
+
body["agent_id"] = agent_id.lower()
|
|
202
|
+
d = api(app, "POST", "/v1/agents", body) or {}
|
|
203
|
+
|
|
204
|
+
def render(x: Dict[str, Any]) -> None:
|
|
205
|
+
ui.ok(f"Created agent [bold]{x.get('model_id')}[/bold]")
|
|
206
|
+
ui.dim(f"Chat with it: ipx agent chat {str(x.get('model_id') or '').removeprefix('agent/')}")
|
|
207
|
+
|
|
208
|
+
app.out(d, render)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@group.command("update", short_help="Change an agent")
|
|
212
|
+
@click.argument("agent")
|
|
213
|
+
@click.option("--name", help="New display name.")
|
|
214
|
+
@click.option("-m", "--model", help="New backing model.")
|
|
215
|
+
@agent_options
|
|
216
|
+
@pass_app
|
|
217
|
+
def update(app: App, agent, name, model, description, system, system_file, visibility, registry, payer, mcp_pairs, mcp_files) -> None:
|
|
218
|
+
"""Update only the fields you pass. `--mcp`/`--mcp-file` replace the whole MCP server list."""
|
|
219
|
+
app.require_login()
|
|
220
|
+
body = body_from(name, model, description, system, system_file, visibility, registry, payer, mcp_pairs, mcp_files)
|
|
221
|
+
if not body:
|
|
222
|
+
raise IpxError("Nothing to change.", "Pass at least one option, e.g. --name or --system.")
|
|
223
|
+
d = api(app, "PATCH", path(app, agent), body) or {}
|
|
224
|
+
app.out(d, lambda x: ui.ok(f"Updated [bold]{x.get('model_id') or agent}[/bold]"))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@group.command("delete", short_help="Delete an agent")
|
|
228
|
+
@click.argument("agent")
|
|
229
|
+
@pass_app
|
|
230
|
+
def delete(app: App, agent: str) -> None:
|
|
231
|
+
"""Permanently delete AGENT and revoke everyone's access to it."""
|
|
232
|
+
app.require_login()
|
|
233
|
+
p = path(app, agent)
|
|
234
|
+
if not ui.confirm(f"Delete agent [bold]{agent}[/bold]? This cannot be undone."):
|
|
235
|
+
raise click.Abort()
|
|
236
|
+
d = api(app, "DELETE", p) or {}
|
|
237
|
+
app.out(d, lambda _: ui.ok(f"Deleted [bold]{agent}[/bold]"))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@group.command("chat", short_help="Chat with an agent", examples=["ipx agent chat acme-bot/support"])
|
|
241
|
+
@click.argument("agent")
|
|
242
|
+
@pass_app
|
|
243
|
+
def chat_(app: App, agent: str) -> None:
|
|
244
|
+
"""Interactive chat with AGENT (billing: you, or the organization context)."""
|
|
245
|
+
app.require_login()
|
|
246
|
+
user, aid = split_ref(app, agent)
|
|
247
|
+
model = f"agent/{user}/{aid}"
|
|
248
|
+
chatmod.run_repl(
|
|
249
|
+
title=f"Agent · {user}/{aid}", subtitle=f"billed to {'org ' + app.org if app.org else 'credits'}", backend=cloud.chat_backend(app, P2G),
|
|
250
|
+
model=model, system=None, list_models=lambda: [r["id"] for r in rows_of(api(app, "GET", "/v1/agents/models")) if r.get("id")],
|
|
251
|
+
footer_note=f"Billed to organization {app.org}." if app.org else None)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@group.command("ask", short_help="One-off question to an agent")
|
|
255
|
+
@click.argument("agent")
|
|
256
|
+
@click.argument("prompt", required=False)
|
|
257
|
+
@click.option("--no-stream", is_flag=True, help="Wait for the whole answer before printing.")
|
|
258
|
+
@pass_app
|
|
259
|
+
def ask(app: App, agent: str, prompt: Optional[str], no_stream: bool) -> None:
|
|
260
|
+
"""Send PROMPT (or standard input) to AGENT and print the answer."""
|
|
261
|
+
app.require_login()
|
|
262
|
+
text = prompt if prompt and prompt != "-" else (sys.stdin.read().strip() if not sys.stdin.isatty() else None)
|
|
263
|
+
if not text:
|
|
264
|
+
raise IpxError("No prompt given.", "Pass it as an argument or pipe it in.")
|
|
265
|
+
user, aid = split_ref(app, agent)
|
|
266
|
+
chatmod.one_shot(backend=cloud.chat_backend(app, P2G), prompt=text, model=f"agent/{user}/{aid}", system=None,
|
|
267
|
+
as_json=app.json, stream_out=not no_stream)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# --------------------------------------------------------------------------- profile / settings
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@group.group("profile", cls=IpxGroup, short_help="Your public agent username")
|
|
274
|
+
def profile() -> None:
|
|
275
|
+
"""The public username that prefixes every agent you own."""
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@profile.command("show")
|
|
279
|
+
@pass_app
|
|
280
|
+
def profile_show(app: App) -> None:
|
|
281
|
+
"""Show your agent username."""
|
|
282
|
+
app.require_login()
|
|
283
|
+
d = api(app, "GET", "/v1/agents/profile") or {}
|
|
284
|
+
app.out(d, lambda x: ui.console.print(ui.kv_panel("Agent profile", [("Username", x.get("username") or "not set")])))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@profile.command("set")
|
|
288
|
+
@click.argument("username")
|
|
289
|
+
@pass_app
|
|
290
|
+
def profile_set(app: App, username: str) -> None:
|
|
291
|
+
"""Set USERNAME (3-30 lowercase letters, numbers, hyphens, underscores)."""
|
|
292
|
+
app.require_login()
|
|
293
|
+
d = api(app, "PUT", "/v1/agents/profile", {"username": username}) or {}
|
|
294
|
+
app.out(d, lambda x: ui.ok(f"Agent username is now [bold]{x.get('username')}[/bold]"))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@group.group("settings", cls=IpxGroup, short_help="Agent preferences")
|
|
298
|
+
def settings() -> None:
|
|
299
|
+
"""Preferences for how agents appear to you."""
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@settings.command("show")
|
|
303
|
+
@pass_app
|
|
304
|
+
def settings_show(app: App) -> None:
|
|
305
|
+
"""Show agent settings and the role capability matrix."""
|
|
306
|
+
app.require_login()
|
|
307
|
+
d = api(app, "GET", "/v1/agents/settings") or {}
|
|
308
|
+
|
|
309
|
+
def render(x: Dict[str, Any]) -> None:
|
|
310
|
+
s = x.get("settings") or {}
|
|
311
|
+
ui.console.print(ui.kv_panel("Agent settings", [("Include agents in /v1/models", "yes" if s.get("include_in_models") else "no")]))
|
|
312
|
+
ui.console.print(ui.make_table(["Role", "Can"], [(r.get("role"), ", ".join(r.get("capabilities") or [])) for r in x.get("roles") or []], title="Roles"))
|
|
313
|
+
|
|
314
|
+
app.out(d, render)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@settings.command("set")
|
|
318
|
+
@click.option("--include-in-models/--no-include-in-models", default=None, help="List your agents in the OpenAI-style /v1/models response.")
|
|
319
|
+
@pass_app
|
|
320
|
+
def settings_set(app: App, include_in_models: Optional[bool]) -> None:
|
|
321
|
+
"""Change agent settings."""
|
|
322
|
+
app.require_login()
|
|
323
|
+
if include_in_models is None:
|
|
324
|
+
raise IpxError("Nothing to change.", "Pass --include-in-models or --no-include-in-models.")
|
|
325
|
+
d = api(app, "PUT", "/v1/agents/settings", {"include_in_models": include_in_models}) or {}
|
|
326
|
+
app.out(d, lambda _: ui.ok("Settings saved."))
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@group.command("roles", short_help="What each sharing role can do")
|
|
330
|
+
@pass_app
|
|
331
|
+
def roles(app: App) -> None:
|
|
332
|
+
"""Roles you can grant when inviting people to an agent."""
|
|
333
|
+
app.require_login()
|
|
334
|
+
d = api(app, "GET", "/v1/agents/settings") or {}
|
|
335
|
+
app.out(d.get("roles") or [], lambda rs: ui.console.print(ui.make_table(["Role", "Can"], [(r.get("role"), ", ".join(r.get("capabilities") or [])) for r in rs], title="Agent roles")))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# --------------------------------------------------------------------------- invites
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@group.group("invite", cls=IpxGroup, short_help="Share an agent by invite")
|
|
342
|
+
def invite() -> None:
|
|
343
|
+
"""Invite people to an agent, and answer invites you received. An invite grants nothing until accepted."""
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@invite.command("create")
|
|
347
|
+
@click.argument("agent")
|
|
348
|
+
@click.argument("who", metavar="EMAIL_OR_USER_ID")
|
|
349
|
+
@click.option("--role", type=click.Choice(ROLES), default="user", show_default=True)
|
|
350
|
+
@pass_app
|
|
351
|
+
def invite_create(app: App, agent: str, who: str, role: str) -> None:
|
|
352
|
+
"""Invite WHO to AGENT. Send them the printed link."""
|
|
353
|
+
app.require_login()
|
|
354
|
+
key = "user_id" if "@" not in who and len(who) >= 32 else "email"
|
|
355
|
+
d = api(app, "POST", path(app, agent).replace("/v1/agents/", "/v1/agents/invite/", 1), {key: who, "role": role}) or {}
|
|
356
|
+
|
|
357
|
+
def render(x: Dict[str, Any]) -> None:
|
|
358
|
+
ui.ok(f"Invited [bold]{who}[/bold] as {x.get('role_label') or role}")
|
|
359
|
+
ui.console.print(f" {x.get('invite_url')}")
|
|
360
|
+
if x.get("note"):
|
|
361
|
+
ui.dim(x["note"])
|
|
362
|
+
|
|
363
|
+
app.out(d, render)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@invite.command("list", short_help="Invites you received")
|
|
367
|
+
@pass_app
|
|
368
|
+
def invite_list(app: App) -> None:
|
|
369
|
+
"""Invites sent to you, pending first."""
|
|
370
|
+
app.require_login()
|
|
371
|
+
d = api(app, "GET", "/v1/agents/invites") or {}
|
|
372
|
+
rows = rows_of(d, "invites")
|
|
373
|
+
app.out(d, lambda _: ui.console.print(ui.make_table(
|
|
374
|
+
["Token", "Agent", "Role", "Status"],
|
|
375
|
+
[(i.get("invite_token"), f"{i.get('owner_username')}/{i.get('agent_id')}", i.get("role"), i.get("status")) for i in rows],
|
|
376
|
+
title=f"Agent invites ({len(rows)})")) if rows else ui.info("No invites."))
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@invite.command("preview")
|
|
380
|
+
@click.argument("token")
|
|
381
|
+
@pass_app
|
|
382
|
+
def invite_preview(app: App, token: str) -> None:
|
|
383
|
+
"""See what an invite TOKEN (or link) grants, and who pays, before accepting."""
|
|
384
|
+
app.require_login()
|
|
385
|
+
d = api(app, "GET", f"/v1/agents/invite/{token.rsplit('=', 1)[-1].rsplit('/', 1)[-1]}") or {}
|
|
386
|
+
app.out(d, lambda x: ui.console.print(ui.kv_panel("Agent invite", [(k.replace("_", " "), v) for k, v in x.items() if not isinstance(v, (dict, list))])))
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
for _name, _verb, _past in (("accept", "Accept", "Invite accepted."), ("deny", "Deny", "Invite denied.")):
|
|
390
|
+
def _make(name: str = _name, verb: str = _verb, past: str = _past) -> None:
|
|
391
|
+
@invite.command(name, short_help=f"{verb} an invite")
|
|
392
|
+
@click.argument("token")
|
|
393
|
+
@pass_app
|
|
394
|
+
def _cmd(app: App, token: str) -> None:
|
|
395
|
+
app.require_login()
|
|
396
|
+
d = api(app, "POST", f"/v1/agents/invite/{token.rsplit('=', 1)[-1].rsplit('/', 1)[-1]}/{name}", {}) or {}
|
|
397
|
+
app.out(d, lambda _: ui.ok(past))
|
|
398
|
+
|
|
399
|
+
_cmd.__doc__ = f"{verb} the invite TOKEN (the token or the full invite link)."
|
|
400
|
+
|
|
401
|
+
_make()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@invite.command("revoke")
|
|
405
|
+
@click.argument("token")
|
|
406
|
+
@pass_app
|
|
407
|
+
def invite_revoke(app: App, token: str) -> None:
|
|
408
|
+
"""Withdraw a pending invite you sent."""
|
|
409
|
+
app.require_login()
|
|
410
|
+
d = api(app, "DELETE", f"/v1/agents/invite/{token.rsplit('=', 1)[-1].rsplit('/', 1)[-1]}") or {}
|
|
411
|
+
app.out(d, lambda _: ui.ok("Invite revoked."))
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# --------------------------------------------------------------------------- members
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@group.group("members", cls=IpxGroup, short_help="People with access to an agent")
|
|
418
|
+
def members() -> None:
|
|
419
|
+
"""List and manage who can use an agent."""
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@members.command("list")
|
|
423
|
+
@click.argument("agent")
|
|
424
|
+
@pass_app
|
|
425
|
+
def members_list(app: App, agent: str) -> None:
|
|
426
|
+
"""Members of AGENT and their roles."""
|
|
427
|
+
app.require_login()
|
|
428
|
+
d = api(app, "GET", path(app, agent, "/members")) or {}
|
|
429
|
+
rows = rows_of(d, "members")
|
|
430
|
+
app.out(d, lambda _: ui.console.print(ui.make_table(["User", "Email", "Role", "Since"], [(m.get("user_id"), m.get("email"), m.get("role"), str(m.get("granted_at") or "")[:10]) for m in rows],
|
|
431
|
+
title=f"Members ({len(rows)})")) if rows else ui.info("No members yet."))
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@members.command("role")
|
|
435
|
+
@click.argument("agent")
|
|
436
|
+
@click.argument("user_id")
|
|
437
|
+
@click.argument("role", type=click.Choice(ROLES))
|
|
438
|
+
@pass_app
|
|
439
|
+
def members_role(app: App, agent: str, user_id: str, role: str) -> None:
|
|
440
|
+
"""Change a member's role."""
|
|
441
|
+
app.require_login()
|
|
442
|
+
d = api(app, "PATCH", path(app, agent, f"/members/{user_id}"), {"role": role}) or {}
|
|
443
|
+
app.out(d, lambda _: ui.ok(f"{user_id} is now {role}"))
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@members.command("remove")
|
|
447
|
+
@click.argument("agent")
|
|
448
|
+
@click.argument("user_id")
|
|
449
|
+
@pass_app
|
|
450
|
+
def members_remove(app: App, agent: str, user_id: str) -> None:
|
|
451
|
+
"""Remove a member from AGENT."""
|
|
452
|
+
app.require_login()
|
|
453
|
+
if not ui.confirm(f"Remove {user_id} from {agent}?"):
|
|
454
|
+
raise click.Abort()
|
|
455
|
+
d = api(app, "DELETE", path(app, agent, f"/members/{user_id}")) or {}
|
|
456
|
+
app.out(d, lambda _: ui.ok("Member removed."))
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
COMMANDS = [group]
|