general-augment-cli 0.1.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.
- general_augment_cli-0.1.0.dist-info/METADATA +180 -0
- general_augment_cli-0.1.0.dist-info/RECORD +42 -0
- general_augment_cli-0.1.0.dist-info/WHEEL +4 -0
- general_augment_cli-0.1.0.dist-info/entry_points.txt +2 -0
- platform_cli/__init__.py +5 -0
- platform_cli/branding.py +27 -0
- platform_cli/client.py +179 -0
- platform_cli/commands/__init__.py +1 -0
- platform_cli/commands/approvals.py +150 -0
- platform_cli/commands/auth.py +96 -0
- platform_cli/commands/billing.py +143 -0
- platform_cli/commands/channels.py +212 -0
- platform_cli/commands/deploy.py +72 -0
- platform_cli/commands/dev.py +38 -0
- platform_cli/commands/doctor.py +170 -0
- platform_cli/commands/identity.py +433 -0
- platform_cli/commands/init.py +55 -0
- platform_cli/commands/integrate.py +94 -0
- platform_cli/commands/keys.py +116 -0
- platform_cli/commands/logs.py +43 -0
- platform_cli/commands/mcp.py +258 -0
- platform_cli/commands/memory.py +316 -0
- platform_cli/commands/mock.py +30 -0
- platform_cli/commands/model_providers.py +226 -0
- platform_cli/commands/observability.py +174 -0
- platform_cli/commands/onboarding.py +72 -0
- platform_cli/commands/projects.py +302 -0
- platform_cli/commands/skills.py +116 -0
- platform_cli/commands/smoke.py +280 -0
- platform_cli/commands/status.py +49 -0
- platform_cli/commands/tools.py +179 -0
- platform_cli/commands/users.py +150 -0
- platform_cli/commands/validate.py +96 -0
- platform_cli/commands/verify.py +648 -0
- platform_cli/config.py +114 -0
- platform_cli/errors.py +103 -0
- platform_cli/local_mock.py +1392 -0
- platform_cli/main.py +130 -0
- platform_cli/openapi.py +1048 -0
- platform_cli/output.py +47 -0
- platform_cli/readiness.py +176 -0
- platform_cli/runtime.py +22 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Smoke-test app-facing General Augment endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, Any
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from platform_cli.client import resolve_project
|
|
13
|
+
from platform_cli.output import panel, print_json, table
|
|
14
|
+
from platform_cli.runtime import Runtime
|
|
15
|
+
|
|
16
|
+
DEFAULT_SMOKE_MESSAGE = "Reply exactly with: genaug-smoke-ok"
|
|
17
|
+
DEFAULT_STRUCTURED_MESSAGE = 'Return JSON with ok=true and label="genaug-smoke-ok".'
|
|
18
|
+
DEFAULT_STRUCTURED_SCHEMA: dict[str, Any] = {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"additionalProperties": False,
|
|
21
|
+
"properties": {
|
|
22
|
+
"ok": {"type": "boolean"},
|
|
23
|
+
"label": {"type": "string"},
|
|
24
|
+
},
|
|
25
|
+
"required": ["ok", "label"],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def smoke(
|
|
30
|
+
ctx: typer.Context,
|
|
31
|
+
message: Annotated[
|
|
32
|
+
str,
|
|
33
|
+
typer.Option("--message", "-m", help="Responses input for the smoke turn."),
|
|
34
|
+
] = DEFAULT_SMOKE_MESSAGE,
|
|
35
|
+
user: Annotated[str, typer.Option(help="App user id for the smoke turn.")] = "genaug-smoke",
|
|
36
|
+
idempotency_key: Annotated[
|
|
37
|
+
str | None,
|
|
38
|
+
typer.Option(help="Replay-safe key for retry/debug checks."),
|
|
39
|
+
] = None,
|
|
40
|
+
request_id: Annotated[
|
|
41
|
+
str | None,
|
|
42
|
+
typer.Option(help="Caller request id to propagate."),
|
|
43
|
+
] = None,
|
|
44
|
+
traceparent: Annotated[
|
|
45
|
+
str | None,
|
|
46
|
+
typer.Option(help="W3C traceparent header to propagate."),
|
|
47
|
+
] = None,
|
|
48
|
+
metadata: Annotated[
|
|
49
|
+
list[str] | None,
|
|
50
|
+
typer.Option("--metadata", help="Metadata as key=value. Repeatable."),
|
|
51
|
+
] = None,
|
|
52
|
+
project: Annotated[
|
|
53
|
+
str | None,
|
|
54
|
+
typer.Option(help="Project id, slug, or name when using a management key."),
|
|
55
|
+
] = None,
|
|
56
|
+
structured: Annotated[
|
|
57
|
+
bool,
|
|
58
|
+
typer.Option("--structured", help="Request a json_schema structured-output smoke."),
|
|
59
|
+
] = False,
|
|
60
|
+
schema_file: Annotated[
|
|
61
|
+
Path | None,
|
|
62
|
+
typer.Option(
|
|
63
|
+
"--schema-file",
|
|
64
|
+
exists=True,
|
|
65
|
+
file_okay=True,
|
|
66
|
+
dir_okay=False,
|
|
67
|
+
readable=True,
|
|
68
|
+
help="JSON Schema file for --structured smoke output.",
|
|
69
|
+
),
|
|
70
|
+
] = None,
|
|
71
|
+
raw: Annotated[bool, typer.Option("--json", help="Print the raw response JSON.")] = False,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Run health plus one `/v1/responses` smoke request."""
|
|
74
|
+
runtime: Runtime = ctx.obj
|
|
75
|
+
turn_id = uuid.uuid4().hex[:12]
|
|
76
|
+
schema = _load_schema(schema_file) if schema_file else None
|
|
77
|
+
structured = structured or schema is not None
|
|
78
|
+
if structured and message == DEFAULT_SMOKE_MESSAGE:
|
|
79
|
+
message = DEFAULT_STRUCTURED_MESSAGE
|
|
80
|
+
headers = _correlation_headers(
|
|
81
|
+
idempotency_key=idempotency_key or f"genaug-smoke-{turn_id}",
|
|
82
|
+
request_id=request_id or f"req_genaug_smoke_{turn_id}",
|
|
83
|
+
traceparent=traceparent,
|
|
84
|
+
)
|
|
85
|
+
payload = {
|
|
86
|
+
"model": "balanced",
|
|
87
|
+
"user": user,
|
|
88
|
+
"input": message,
|
|
89
|
+
"metadata": {"source": "genaug-cli-smoke", **_metadata_pairs(metadata or [])},
|
|
90
|
+
}
|
|
91
|
+
if structured:
|
|
92
|
+
payload["text"] = _structured_text_format(schema or DEFAULT_STRUCTURED_SCHEMA)
|
|
93
|
+
project_payload: dict[str, Any] | None = None
|
|
94
|
+
with runtime.client() as client:
|
|
95
|
+
ready = client.public("GET", "/health/ready")
|
|
96
|
+
if project:
|
|
97
|
+
project_payload = resolve_project(client, project)
|
|
98
|
+
headers["X-Project-ID"] = str(project_payload["id"])
|
|
99
|
+
response = client.app("POST", "/v1/responses", json=payload, headers=headers)
|
|
100
|
+
|
|
101
|
+
metadata_payload = response.get("metadata", {}) if isinstance(response, dict) else {}
|
|
102
|
+
support_receipt = _support_receipt(
|
|
103
|
+
ready=ready,
|
|
104
|
+
response=response,
|
|
105
|
+
metadata=metadata_payload,
|
|
106
|
+
project=project_payload,
|
|
107
|
+
)
|
|
108
|
+
if raw:
|
|
109
|
+
print_json(
|
|
110
|
+
{
|
|
111
|
+
"ready": ready,
|
|
112
|
+
"response": response,
|
|
113
|
+
"response_id": response.get("id") if isinstance(response, dict) else None,
|
|
114
|
+
"request_id": _metadata_value(
|
|
115
|
+
metadata_payload,
|
|
116
|
+
"general_augment_request_id",
|
|
117
|
+
"request_id",
|
|
118
|
+
),
|
|
119
|
+
"trace_id": _metadata_value(
|
|
120
|
+
metadata_payload,
|
|
121
|
+
"general_augment_trace_id",
|
|
122
|
+
"trace_id",
|
|
123
|
+
),
|
|
124
|
+
"support_receipt": support_receipt,
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
rows: list[list[object]] = [["Ready", _status_text(ready)]]
|
|
130
|
+
if isinstance(response, dict):
|
|
131
|
+
rows.extend(
|
|
132
|
+
[
|
|
133
|
+
["Response ID", response.get("id", "")],
|
|
134
|
+
["Status", response.get("status", "")],
|
|
135
|
+
["Model", response.get("model", metadata_payload.get("general_augment_model", ""))],
|
|
136
|
+
[
|
|
137
|
+
"Request ID",
|
|
138
|
+
metadata_payload.get(
|
|
139
|
+
"request_id",
|
|
140
|
+
metadata_payload.get("general_augment_request_id", ""),
|
|
141
|
+
),
|
|
142
|
+
],
|
|
143
|
+
[
|
|
144
|
+
"Trace ID",
|
|
145
|
+
metadata_payload.get(
|
|
146
|
+
"trace_id",
|
|
147
|
+
metadata_payload.get("general_augment_trace_id", ""),
|
|
148
|
+
),
|
|
149
|
+
],
|
|
150
|
+
]
|
|
151
|
+
)
|
|
152
|
+
if structured:
|
|
153
|
+
rows.append(["Output Format", "json_schema"])
|
|
154
|
+
table("Smoke", ["Check", "Value"], rows)
|
|
155
|
+
if isinstance(response, dict):
|
|
156
|
+
panel("Output", _response_output_text(response) or "<empty>")
|
|
157
|
+
panel("Support receipt", json.dumps(support_receipt, indent=2, sort_keys=True))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _correlation_headers(
|
|
161
|
+
*,
|
|
162
|
+
idempotency_key: str,
|
|
163
|
+
request_id: str,
|
|
164
|
+
traceparent: str | None,
|
|
165
|
+
) -> dict[str, str]:
|
|
166
|
+
"""Build app-facing correlation headers."""
|
|
167
|
+
headers = {
|
|
168
|
+
"X-Idempotency-Key": idempotency_key,
|
|
169
|
+
"X-Request-ID": request_id,
|
|
170
|
+
}
|
|
171
|
+
if traceparent:
|
|
172
|
+
headers["traceparent"] = traceparent
|
|
173
|
+
return headers
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _metadata_pairs(values: list[str]) -> dict[str, str]:
|
|
177
|
+
"""Parse repeated key=value metadata flags."""
|
|
178
|
+
parsed: dict[str, str] = {}
|
|
179
|
+
for item in values:
|
|
180
|
+
key, separator, value = item.partition("=")
|
|
181
|
+
if not separator or not key.strip():
|
|
182
|
+
raise typer.BadParameter("--metadata values must use key=value.")
|
|
183
|
+
parsed[key.strip()] = value
|
|
184
|
+
return parsed
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _load_schema(path: Path) -> dict[str, Any]:
|
|
188
|
+
"""Load a JSON Schema object from disk."""
|
|
189
|
+
try:
|
|
190
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
191
|
+
except json.JSONDecodeError as exc:
|
|
192
|
+
raise typer.BadParameter(f"{path} is not valid JSON: {exc.msg}") from exc
|
|
193
|
+
if not isinstance(payload, dict):
|
|
194
|
+
raise typer.BadParameter("--schema-file must contain a JSON object schema.")
|
|
195
|
+
return payload
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _structured_text_format(schema: dict[str, Any]) -> dict[str, Any]:
|
|
199
|
+
"""Build a Responses json_schema text format."""
|
|
200
|
+
return {
|
|
201
|
+
"format": {
|
|
202
|
+
"type": "json_schema",
|
|
203
|
+
"name": "genaug_smoke",
|
|
204
|
+
"schema": schema,
|
|
205
|
+
"strict": True,
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _status_text(payload: object) -> str:
|
|
211
|
+
"""Return a compact status string from health JSON."""
|
|
212
|
+
if isinstance(payload, dict):
|
|
213
|
+
return str(payload.get("status") or payload)
|
|
214
|
+
return str(payload)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _response_output_text(response: dict[str, Any]) -> str:
|
|
218
|
+
"""Extract text from the common Responses output shape."""
|
|
219
|
+
output_text = response.get("output_text")
|
|
220
|
+
if isinstance(output_text, str):
|
|
221
|
+
return output_text
|
|
222
|
+
parts: list[str] = []
|
|
223
|
+
output = response.get("output")
|
|
224
|
+
if not isinstance(output, list):
|
|
225
|
+
return ""
|
|
226
|
+
for item in output:
|
|
227
|
+
if not isinstance(item, dict) or not isinstance(item.get("content"), list):
|
|
228
|
+
continue
|
|
229
|
+
for content in item["content"]:
|
|
230
|
+
if (
|
|
231
|
+
isinstance(content, dict)
|
|
232
|
+
and content.get("type") in {"output_text", "text"}
|
|
233
|
+
and isinstance(content.get("text"), str)
|
|
234
|
+
):
|
|
235
|
+
parts.append(content["text"])
|
|
236
|
+
return "".join(parts)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _metadata_value(metadata: object, *keys: str) -> object:
|
|
240
|
+
"""Return the first metadata value present for any key."""
|
|
241
|
+
if not isinstance(metadata, dict):
|
|
242
|
+
return None
|
|
243
|
+
for key in keys:
|
|
244
|
+
value = metadata.get(key)
|
|
245
|
+
if value:
|
|
246
|
+
return value
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _support_receipt(
|
|
251
|
+
*,
|
|
252
|
+
ready: object,
|
|
253
|
+
response: object,
|
|
254
|
+
metadata: object,
|
|
255
|
+
project: dict[str, Any] | None,
|
|
256
|
+
) -> dict[str, object]:
|
|
257
|
+
"""Build a redacted support receipt for replay/debug handoff."""
|
|
258
|
+
response_payload = response if isinstance(response, dict) else {}
|
|
259
|
+
model = response_payload.get("model")
|
|
260
|
+
if not model:
|
|
261
|
+
model = _metadata_value(metadata, "general_augment_model", "model")
|
|
262
|
+
return {
|
|
263
|
+
"source": "genaug-cli-smoke",
|
|
264
|
+
"project_id": str(project["id"]) if project and project.get("id") else None,
|
|
265
|
+
"project_slug": str(project["slug"]) if project and project.get("slug") else None,
|
|
266
|
+
"response_id": response_payload.get("id"),
|
|
267
|
+
"request_id": _metadata_value(
|
|
268
|
+
metadata,
|
|
269
|
+
"general_augment_request_id",
|
|
270
|
+
"request_id",
|
|
271
|
+
),
|
|
272
|
+
"trace_id": _metadata_value(
|
|
273
|
+
metadata,
|
|
274
|
+
"general_augment_trace_id",
|
|
275
|
+
"trace_id",
|
|
276
|
+
),
|
|
277
|
+
"model": model,
|
|
278
|
+
"cost_usd": _metadata_value(metadata, "general_augment_cost_usd", "cost_usd"),
|
|
279
|
+
"ready_status": _status_text(ready),
|
|
280
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Platform status command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from platform_cli.client import encode_path_segment, resolve_project
|
|
8
|
+
from platform_cli.output import panel, table
|
|
9
|
+
from platform_cli.runtime import Runtime
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def status(
|
|
13
|
+
ctx: typer.Context,
|
|
14
|
+
project: str | None = typer.Option(None, help="Optional project id, slug, or name."),
|
|
15
|
+
) -> None:
|
|
16
|
+
"""Show platform health, metrics reachability, and optional project usage."""
|
|
17
|
+
runtime: Runtime = ctx.obj
|
|
18
|
+
with runtime.client() as client:
|
|
19
|
+
live = client.public("GET", "/health/live")
|
|
20
|
+
ready = client.public("GET", "/health/ready")
|
|
21
|
+
metrics = client.public("GET", "/metrics")
|
|
22
|
+
rows: list[list[object]] = [
|
|
23
|
+
["Live", _status_text(live)],
|
|
24
|
+
["Ready", _status_text(ready)],
|
|
25
|
+
["Metrics", "available" if metrics is not None else "empty"],
|
|
26
|
+
]
|
|
27
|
+
table("Platform Status", ["Check", "State"], rows)
|
|
28
|
+
if project:
|
|
29
|
+
project_payload = resolve_project(client, project)
|
|
30
|
+
usage = client.admin(
|
|
31
|
+
"GET",
|
|
32
|
+
f"/projects/{encode_path_segment(str(project_payload['id']))}/usage",
|
|
33
|
+
)
|
|
34
|
+
totals = usage.get("totals", {}) if isinstance(usage, dict) else {}
|
|
35
|
+
agent_turns = totals.get("agent_turns_count", 0)
|
|
36
|
+
panel(
|
|
37
|
+
f"Project {project_payload.get('slug', project)}",
|
|
38
|
+
f"Agent turns: {agent_turns}\n"
|
|
39
|
+
f"Stored messages: {totals.get('messages_count', 0)}\n"
|
|
40
|
+
f"Tool calls: {totals.get('tool_calls_count', 0)}\n"
|
|
41
|
+
f"Cost: {totals.get('total_cost_usd', 0)}",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _status_text(payload: object) -> str:
|
|
46
|
+
"""Return a compact status string from health JSON."""
|
|
47
|
+
if isinstance(payload, dict):
|
|
48
|
+
return str(payload.get("status") or payload)
|
|
49
|
+
return str(payload)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Tool management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from platform_cli.client import encode_path_segment, resolve_project
|
|
8
|
+
from platform_cli.output import print_json, print_success, table
|
|
9
|
+
from platform_cli.runtime import Runtime
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Manage project tools.")
|
|
12
|
+
|
|
13
|
+
TOOL_DISCOVERY_MODES = {"auto", "always", "direct"}
|
|
14
|
+
DEFAULT_TOOL_DISCOVERY: dict[str, int | str] = {
|
|
15
|
+
"mode": "auto",
|
|
16
|
+
"direct_schema_tool_limit": 10,
|
|
17
|
+
"max_search_results": 5,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command("list")
|
|
22
|
+
def list_tools(
|
|
23
|
+
ctx: typer.Context,
|
|
24
|
+
project: str = typer.Option(..., help="Project id, slug, or name."),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""List tools with enabled and approval state."""
|
|
27
|
+
runtime: Runtime = ctx.obj
|
|
28
|
+
with runtime.client() as client:
|
|
29
|
+
project_payload = resolve_project(client, project)
|
|
30
|
+
tools_payload = client.admin("GET", "/tools")
|
|
31
|
+
enabled = set(_string_list(project_payload.get("enabled_tool_ids")))
|
|
32
|
+
tools = tools_payload if isinstance(tools_payload, list) else tools_payload.get("items", [])
|
|
33
|
+
rows = [
|
|
34
|
+
[
|
|
35
|
+
item.get("id", ""),
|
|
36
|
+
"enabled" if item.get("id") in enabled else "disabled",
|
|
37
|
+
item.get("risk_level", ""),
|
|
38
|
+
"yes" if item.get("requires_approval") else "no",
|
|
39
|
+
]
|
|
40
|
+
for item in tools
|
|
41
|
+
if isinstance(item, dict)
|
|
42
|
+
]
|
|
43
|
+
table("Tools", ["Tool", "State", "Risk", "Approval"], rows)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.command("toggle")
|
|
47
|
+
def toggle_tool(
|
|
48
|
+
ctx: typer.Context,
|
|
49
|
+
tool_id: str = typer.Argument(...),
|
|
50
|
+
project: str = typer.Option(..., help="Project id, slug, or name."),
|
|
51
|
+
enable: bool | None = typer.Option(None, "--enable/--disable", help="Force state."),
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Enable or disable a built-in tool."""
|
|
54
|
+
runtime: Runtime = ctx.obj
|
|
55
|
+
with runtime.client() as client:
|
|
56
|
+
project_payload = resolve_project(client, project)
|
|
57
|
+
enabled = set(_string_list(project_payload.get("enabled_tool_ids")))
|
|
58
|
+
next_enabled = tool_id not in enabled if enable is None else enable
|
|
59
|
+
if next_enabled:
|
|
60
|
+
enabled.add(tool_id)
|
|
61
|
+
state = "enabled"
|
|
62
|
+
else:
|
|
63
|
+
enabled.discard(tool_id)
|
|
64
|
+
state = "disabled"
|
|
65
|
+
client.admin(
|
|
66
|
+
"PUT",
|
|
67
|
+
f"/projects/{encode_path_segment(str(project_payload['id']))}/tools",
|
|
68
|
+
json={"tool_ids": sorted(enabled)},
|
|
69
|
+
)
|
|
70
|
+
print_success(f"{tool_id} {state}.")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.command("discovery")
|
|
74
|
+
def configure_tool_discovery(
|
|
75
|
+
ctx: typer.Context,
|
|
76
|
+
project: str = typer.Option(..., help="Project id, slug, or name."),
|
|
77
|
+
mode: str | None = typer.Option(
|
|
78
|
+
None,
|
|
79
|
+
"--mode",
|
|
80
|
+
help="Tool discovery mode: auto, always, or direct.",
|
|
81
|
+
),
|
|
82
|
+
direct_schema_tool_limit: int | None = typer.Option(
|
|
83
|
+
None,
|
|
84
|
+
"--direct-schema-tool-limit",
|
|
85
|
+
min=1,
|
|
86
|
+
help="Direct schema limit before catalog search is used.",
|
|
87
|
+
),
|
|
88
|
+
max_search_results: int | None = typer.Option(
|
|
89
|
+
None,
|
|
90
|
+
"--max-search-results",
|
|
91
|
+
min=1,
|
|
92
|
+
max=10,
|
|
93
|
+
help="Maximum tool schemas returned by one discovery search.",
|
|
94
|
+
),
|
|
95
|
+
json_output: bool = typer.Option(False, "--json", help="Print machine-readable JSON."),
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Show or update the tenant tool discovery behavior."""
|
|
98
|
+
updates: dict[str, int | str] = {}
|
|
99
|
+
if mode is not None:
|
|
100
|
+
normalized_mode = mode.strip().casefold()
|
|
101
|
+
if normalized_mode not in TOOL_DISCOVERY_MODES:
|
|
102
|
+
raise typer.BadParameter("--mode must be one of: auto, always, direct.")
|
|
103
|
+
updates["mode"] = normalized_mode
|
|
104
|
+
if direct_schema_tool_limit is not None:
|
|
105
|
+
updates["direct_schema_tool_limit"] = direct_schema_tool_limit
|
|
106
|
+
if max_search_results is not None:
|
|
107
|
+
updates["max_search_results"] = max_search_results
|
|
108
|
+
|
|
109
|
+
runtime: Runtime = ctx.obj
|
|
110
|
+
with runtime.client() as client:
|
|
111
|
+
project_payload = resolve_project(client, project)
|
|
112
|
+
current = _tool_discovery(project_payload.get("tool_discovery"))
|
|
113
|
+
if updates:
|
|
114
|
+
next_config = {**current, **updates}
|
|
115
|
+
project_payload = client.admin(
|
|
116
|
+
"PATCH",
|
|
117
|
+
f"/projects/{encode_path_segment(str(project_payload['id']))}",
|
|
118
|
+
json={"tool_discovery": next_config},
|
|
119
|
+
)
|
|
120
|
+
current = _tool_discovery(project_payload.get("tool_discovery"))
|
|
121
|
+
|
|
122
|
+
if json_output:
|
|
123
|
+
print_json(current)
|
|
124
|
+
return
|
|
125
|
+
if updates:
|
|
126
|
+
print_success("Tool discovery behavior saved.")
|
|
127
|
+
table(
|
|
128
|
+
"Tool Discovery",
|
|
129
|
+
["Field", "Value"],
|
|
130
|
+
[
|
|
131
|
+
["Mode", current["mode"]],
|
|
132
|
+
["Direct schema limit", current["direct_schema_tool_limit"]],
|
|
133
|
+
["Search result limit", current["max_search_results"]],
|
|
134
|
+
],
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _string_list(value: object) -> list[str]:
|
|
139
|
+
"""Return a string list from JSON."""
|
|
140
|
+
return [str(item) for item in value] if isinstance(value, list) else []
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _tool_discovery(value: object) -> dict[str, int | str]:
|
|
144
|
+
"""Return a normalized tool discovery config from project JSON."""
|
|
145
|
+
if not isinstance(value, dict):
|
|
146
|
+
return dict(DEFAULT_TOOL_DISCOVERY)
|
|
147
|
+
mode = str(value.get("mode") or DEFAULT_TOOL_DISCOVERY["mode"]).casefold()
|
|
148
|
+
if mode not in TOOL_DISCOVERY_MODES:
|
|
149
|
+
mode = str(DEFAULT_TOOL_DISCOVERY["mode"])
|
|
150
|
+
return {
|
|
151
|
+
"mode": mode,
|
|
152
|
+
"direct_schema_tool_limit": _positive_int(
|
|
153
|
+
value.get("direct_schema_tool_limit"),
|
|
154
|
+
default=int(DEFAULT_TOOL_DISCOVERY["direct_schema_tool_limit"]),
|
|
155
|
+
),
|
|
156
|
+
"max_search_results": min(
|
|
157
|
+
_positive_int(
|
|
158
|
+
value.get("max_search_results"),
|
|
159
|
+
default=int(DEFAULT_TOOL_DISCOVERY["max_search_results"]),
|
|
160
|
+
),
|
|
161
|
+
10,
|
|
162
|
+
),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _positive_int(value: object, *, default: int) -> int:
|
|
167
|
+
"""Return a positive integer from API JSON."""
|
|
168
|
+
if value is None:
|
|
169
|
+
return default
|
|
170
|
+
if isinstance(value, int):
|
|
171
|
+
parsed = value
|
|
172
|
+
elif isinstance(value, str):
|
|
173
|
+
try:
|
|
174
|
+
parsed = int(value)
|
|
175
|
+
except ValueError:
|
|
176
|
+
return default
|
|
177
|
+
else:
|
|
178
|
+
return default
|
|
179
|
+
return parsed if parsed >= 1 else default
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Tenant user management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from platform_cli.client import encode_path_segment, resolve_project
|
|
10
|
+
from platform_cli.output import print_json, print_success, table
|
|
11
|
+
from platform_cli.runtime import Runtime
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(help="Manage tenant users.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.command("list")
|
|
17
|
+
def list_users(
|
|
18
|
+
ctx: typer.Context,
|
|
19
|
+
project: Annotated[str, typer.Option(help="Project id, slug, or name.")],
|
|
20
|
+
page: Annotated[int, typer.Option(min=1, help="Page number.")] = 1,
|
|
21
|
+
page_size: Annotated[
|
|
22
|
+
int,
|
|
23
|
+
typer.Option("--page-size", min=1, max=500, help="Users per page."),
|
|
24
|
+
] = 50,
|
|
25
|
+
json_output: Annotated[
|
|
26
|
+
bool,
|
|
27
|
+
typer.Option("--json", help="Print machine-readable JSON."),
|
|
28
|
+
] = False,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""List users for one project."""
|
|
31
|
+
runtime: Runtime = ctx.obj
|
|
32
|
+
with runtime.client() as client:
|
|
33
|
+
project_payload = resolve_project(client, project)
|
|
34
|
+
response = client.admin(
|
|
35
|
+
"GET",
|
|
36
|
+
f"/projects/{encode_path_segment(str(project_payload['id']))}/users",
|
|
37
|
+
params={"page": page, "page_size": page_size},
|
|
38
|
+
)
|
|
39
|
+
if json_output:
|
|
40
|
+
print_json(response)
|
|
41
|
+
return
|
|
42
|
+
items = response.get("items", []) if isinstance(response, dict) else []
|
|
43
|
+
rows = [
|
|
44
|
+
[
|
|
45
|
+
item.get("id", ""),
|
|
46
|
+
item.get("phone_e164", ""),
|
|
47
|
+
item.get("display_name", ""),
|
|
48
|
+
item.get("message_count", 0),
|
|
49
|
+
item.get("last_active_at", ""),
|
|
50
|
+
]
|
|
51
|
+
for item in items
|
|
52
|
+
if isinstance(item, dict)
|
|
53
|
+
]
|
|
54
|
+
table("Users", ["ID", "Phone", "Name", "Messages", "Last Active"], rows)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command("detail")
|
|
58
|
+
def user_detail(
|
|
59
|
+
ctx: typer.Context,
|
|
60
|
+
user_id: Annotated[str, typer.Argument(help="General Augment user id.")],
|
|
61
|
+
project: Annotated[str, typer.Option(help="Project id, slug, or name.")],
|
|
62
|
+
json_output: Annotated[
|
|
63
|
+
bool,
|
|
64
|
+
typer.Option("--json", help="Print machine-readable JSON."),
|
|
65
|
+
] = False,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Show one tenant user's memory and credential summary."""
|
|
68
|
+
runtime: Runtime = ctx.obj
|
|
69
|
+
with runtime.client() as client:
|
|
70
|
+
project_payload = resolve_project(client, project)
|
|
71
|
+
response = client.admin(
|
|
72
|
+
"GET",
|
|
73
|
+
(
|
|
74
|
+
f"/projects/{encode_path_segment(str(project_payload['id']))}/users/"
|
|
75
|
+
f"{encode_path_segment(user_id)}"
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
if json_output:
|
|
79
|
+
print_json(response)
|
|
80
|
+
return
|
|
81
|
+
user = response.get("user", {}) if isinstance(response, dict) else {}
|
|
82
|
+
memory_facts = response.get("memory_facts", []) if isinstance(response, dict) else []
|
|
83
|
+
credentials = response.get("credentials", []) if isinstance(response, dict) else []
|
|
84
|
+
table(
|
|
85
|
+
"User detail",
|
|
86
|
+
["Field", "Value"],
|
|
87
|
+
[
|
|
88
|
+
["User ID", _value(user, "id")],
|
|
89
|
+
["Phone", _value(user, "phone_e164")],
|
|
90
|
+
["Display Name", _value(user, "display_name")],
|
|
91
|
+
["Messages", _value(response, "message_count")],
|
|
92
|
+
["Memory Facts", len(memory_facts) if isinstance(memory_facts, list) else 0],
|
|
93
|
+
["Credentials", _credential_summary(credentials)],
|
|
94
|
+
],
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command("delete")
|
|
99
|
+
def delete_user(
|
|
100
|
+
ctx: typer.Context,
|
|
101
|
+
user_id: Annotated[str, typer.Argument(help="General Augment user id.")],
|
|
102
|
+
project: Annotated[str, typer.Option(help="Project id, slug, or name.")],
|
|
103
|
+
yes: Annotated[
|
|
104
|
+
bool,
|
|
105
|
+
typer.Option("--yes", help="Confirm deleting this user and cascaded tenant data."),
|
|
106
|
+
] = False,
|
|
107
|
+
json_output: Annotated[
|
|
108
|
+
bool,
|
|
109
|
+
typer.Option("--json", help="Print machine-readable JSON."),
|
|
110
|
+
] = False,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Delete one tenant user and cascaded tenant data."""
|
|
113
|
+
if not yes and not typer.confirm(f"Delete user {user_id} and cascaded tenant data?"):
|
|
114
|
+
raise typer.Exit(1)
|
|
115
|
+
runtime: Runtime = ctx.obj
|
|
116
|
+
with runtime.client() as client:
|
|
117
|
+
project_payload = resolve_project(client, project)
|
|
118
|
+
response = client.admin(
|
|
119
|
+
"DELETE",
|
|
120
|
+
(
|
|
121
|
+
f"/projects/{encode_path_segment(str(project_payload['id']))}/users/"
|
|
122
|
+
f"{encode_path_segment(user_id)}"
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
if json_output:
|
|
126
|
+
print_json(response)
|
|
127
|
+
return
|
|
128
|
+
deleted_user_id = response.get("user_id", user_id) if isinstance(response, dict) else user_id
|
|
129
|
+
print_success(f"Deleted user {deleted_user_id}.")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _credential_summary(value: object) -> str:
|
|
133
|
+
"""Return a compact credential summary without secret values."""
|
|
134
|
+
if not isinstance(value, list) or not value:
|
|
135
|
+
return "none"
|
|
136
|
+
labels: list[str] = []
|
|
137
|
+
for item in value:
|
|
138
|
+
if not isinstance(item, dict):
|
|
139
|
+
continue
|
|
140
|
+
provider = item.get("provider", "unknown")
|
|
141
|
+
status = item.get("status", "unknown")
|
|
142
|
+
labels.append(f"{provider}:{status}")
|
|
143
|
+
return ", ".join(labels) if labels else "none"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _value(payload: object, key: str) -> object:
|
|
147
|
+
"""Safely read a value from a response mapping."""
|
|
148
|
+
if isinstance(payload, dict):
|
|
149
|
+
return payload.get(key, "")
|
|
150
|
+
return ""
|