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.
Files changed (42) hide show
  1. general_augment_cli-0.1.0.dist-info/METADATA +180 -0
  2. general_augment_cli-0.1.0.dist-info/RECORD +42 -0
  3. general_augment_cli-0.1.0.dist-info/WHEEL +4 -0
  4. general_augment_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. platform_cli/__init__.py +5 -0
  6. platform_cli/branding.py +27 -0
  7. platform_cli/client.py +179 -0
  8. platform_cli/commands/__init__.py +1 -0
  9. platform_cli/commands/approvals.py +150 -0
  10. platform_cli/commands/auth.py +96 -0
  11. platform_cli/commands/billing.py +143 -0
  12. platform_cli/commands/channels.py +212 -0
  13. platform_cli/commands/deploy.py +72 -0
  14. platform_cli/commands/dev.py +38 -0
  15. platform_cli/commands/doctor.py +170 -0
  16. platform_cli/commands/identity.py +433 -0
  17. platform_cli/commands/init.py +55 -0
  18. platform_cli/commands/integrate.py +94 -0
  19. platform_cli/commands/keys.py +116 -0
  20. platform_cli/commands/logs.py +43 -0
  21. platform_cli/commands/mcp.py +258 -0
  22. platform_cli/commands/memory.py +316 -0
  23. platform_cli/commands/mock.py +30 -0
  24. platform_cli/commands/model_providers.py +226 -0
  25. platform_cli/commands/observability.py +174 -0
  26. platform_cli/commands/onboarding.py +72 -0
  27. platform_cli/commands/projects.py +302 -0
  28. platform_cli/commands/skills.py +116 -0
  29. platform_cli/commands/smoke.py +280 -0
  30. platform_cli/commands/status.py +49 -0
  31. platform_cli/commands/tools.py +179 -0
  32. platform_cli/commands/users.py +150 -0
  33. platform_cli/commands/validate.py +96 -0
  34. platform_cli/commands/verify.py +648 -0
  35. platform_cli/config.py +114 -0
  36. platform_cli/errors.py +103 -0
  37. platform_cli/local_mock.py +1392 -0
  38. platform_cli/main.py +130 -0
  39. platform_cli/openapi.py +1048 -0
  40. platform_cli/output.py +47 -0
  41. platform_cli/readiness.py +176 -0
  42. 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 ""