failproofai 1.0.12-beta.2 → 1.0.12

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 (196) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +4 -4
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.rsc +2 -2
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +2 -2
  12. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.rsc +3 -3
  18. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  30. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  32. package/.next/standalone/.next/server/app/index.html +1 -1
  33. package/.next/standalone/.next/server/app/index.rsc +3 -3
  34. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  35. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
  36. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  37. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  38. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  40. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  41. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  44. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  47. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  48. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  51. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +8 -8
  54. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  56. package/.next/standalone/.next/server/chunks/_0tovk6q0miud6._.js +1 -1
  57. package/.next/standalone/.next/server/chunks/_0trp3ycqv17cl._.js +1 -1
  58. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4vkpxxu7._.js +1 -1
  59. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__08ch3staq3ejw._.js +1 -1
  60. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bigvy9xmv2ka._.js +1 -1
  61. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h86bl2qf0k_o._.js +1 -1
  62. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pia5h3p8re4c._.js +1 -1
  63. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pwj2h2efe5f-._.js +1 -1
  64. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-ey9fgesfbc0._.js +1 -1
  65. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15usvs-ms-0hf._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__00l2u6j86cb8y._.js → [root-of-the-server]__167mipqmjprq5._.js} +2 -2
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__16gb3_92zvk5x._.js +1 -1
  68. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1avi2kypusyb_._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1r7drlqli-ado._.js +1 -1
  70. package/.next/standalone/.next/server/chunks/ssr/_0bseae9u7g98r._.js +1 -1
  71. package/.next/standalone/.next/server/chunks/ssr/_0eoura3_9gjb-._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/ssr/_0mouhaflye1ro._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/ssr/_0pedwblqdoh58._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/ssr/{_1fw0_nzkae5ao._.js → _1d98fyfrcaqhy._.js} +2 -2
  75. package/.next/standalone/.next/server/chunks/ssr/_1djqs9i27e4kg._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/_20hn4gn87nsjc._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo5_vm5f.js +2 -2
  78. package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9snixb5fd-._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47q0xq5w._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/app_components_project-list_tsx_1ke6anr0vt64h._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x33jqb6._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpcz0uxur._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h47jmhhd._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_1fv03uigni8s5.js → node_modules_html-to-image_es_index_0tqc05n832v7a.js} +1 -1
  85. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_0janj0i6dqko7._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/ssr/package_json_[json]_cjs_013tntikdft59._.js +1 -1
  87. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  88. package/.next/standalone/.next/server/pages/404.html +1 -1
  89. package/.next/standalone/.next/server/pages/500.html +1 -1
  90. package/.next/standalone/.next/server/preview-props.json +3 -3
  91. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  92. package/.next/standalone/.next/server/server-reference-manifest.json +23 -23
  93. package/.next/standalone/.next/static/chunks/{1c_jf6g5wnslk.js → 0el9n3h9_zp5m.js} +1 -1
  94. package/.next/standalone/.next/static/chunks/0k7g_8x3o6gd-.js +1 -0
  95. package/.next/standalone/.next/static/chunks/{2o6d37j425uci.js → 0umzct31duk5g.js} +1 -1
  96. package/.next/standalone/.next/static/chunks/{42xptz88mtq3x.js → 0wobqgosirs8p.js} +1 -1
  97. package/.next/standalone/.next/static/chunks/12rug3jilwqzs.js +1 -0
  98. package/.next/standalone/.next/static/chunks/{0ay__-d2h1xb5.js → 1kduj74-715jh.js} +1 -1
  99. package/.next/standalone/.next/static/chunks/{40oworn_-x12p.js → 2701amhrbsbot.js} +1 -1
  100. package/.next/standalone/.next/static/chunks/{0tbm3vjm62h-2.js → 27cavoc8paelg.js} +1 -1
  101. package/.next/standalone/.next/static/chunks/{1my3-5p47t7r-.js → 2ve23a7keupjw.js} +1 -1
  102. package/.next/standalone/.next/static/chunks/{21hckpmsc4g70.js → 3bv6xaoitjpnh.js} +2 -2
  103. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +19 -0
  104. package/.next/standalone/fp-cloud-cli/README.md +120 -0
  105. package/.next/standalone/fp-cloud-cli/fp_cli/app.py +15 -1
  106. package/.next/standalone/fp-cloud-cli/fp_cli/client.py +198 -7
  107. package/.next/standalone/fp-cloud-cli/fp_cli/cloud_output.py +523 -0
  108. package/.next/standalone/fp-cloud-cli/fp_cli/commands/_cloud.py +45 -0
  109. package/.next/standalone/fp-cloud-cli/fp_cli/commands/_write.py +3 -1
  110. package/.next/standalone/fp-cloud-cli/fp_cli/commands/account_cmds.py +171 -0
  111. package/.next/standalone/fp-cloud-cli/fp_cli/commands/agent_cmds.py +80 -7
  112. package/.next/standalone/fp-cloud-cli/fp_cli/commands/alerts_cmds.py +23 -0
  113. package/.next/standalone/fp-cloud-cli/fp_cli/commands/audits_cmds.py +2 -0
  114. package/.next/standalone/fp-cloud-cli/fp_cli/commands/contracts_cmds.py +67 -0
  115. package/.next/standalone/fp-cloud-cli/fp_cli/commands/dashboards_cmds.py +159 -0
  116. package/.next/standalone/fp-cloud-cli/fp_cli/commands/evaluators_cmds.py +380 -0
  117. package/.next/standalone/fp-cloud-cli/fp_cli/commands/events_cmds.py +14 -1
  118. package/.next/standalone/fp-cloud-cli/fp_cli/commands/incidents_cmds.py +82 -6
  119. package/.next/standalone/fp-cloud-cli/fp_cli/commands/list_cmds.py +4 -1
  120. package/.next/standalone/fp-cloud-cli/fp_cli/commands/orgs_cmds.py +17 -0
  121. package/.next/standalone/fp-cloud-cli/fp_cli/commands/policies_cmds.py +143 -1
  122. package/.next/standalone/fp-cloud-cli/fp_cli/commands/queries_cmds.py +70 -0
  123. package/.next/standalone/fp-cloud-cli/fp_cli/commands/sentiment_cmds.py +49 -0
  124. package/.next/standalone/fp-cloud-cli/fp_cli/commands/sessions_cmds.py +95 -1
  125. package/.next/standalone/fp-cloud-cli/fp_cli/commands/settings_cmds.py +2 -0
  126. package/.next/standalone/fp-cloud-cli/fp_cli/commands/usage_cmds.py +21 -1
  127. package/.next/standalone/fp-cloud-cli/fp_cli/commands/users_cmds.py +14 -0
  128. package/.next/standalone/fp-cloud-cli/fp_cli/errors.py +3 -0
  129. package/.next/standalone/fp-cloud-cli/fp_cli/evaluator_spec.py +266 -0
  130. package/.next/standalone/fp-cloud-cli/fp_cli/output.py +187 -81
  131. package/.next/standalone/fp-cloud-cli/fp_cli/theme.py +21 -24
  132. package/.next/standalone/fp-cloud-cli/skill/SKILL.md +124 -23
  133. package/.next/standalone/fp-cloud-cli/skill/agents/openai.yaml +2 -1
  134. package/.next/standalone/fp-cloud-cli/skill/references/commands.md +168 -16
  135. package/.next/standalone/fp-cloud-cli/tests/fake_cloud_smoke.py +312 -0
  136. package/.next/standalone/fp-cloud-cli/tests/test_cloud_account.py +125 -0
  137. package/.next/standalone/fp-cloud-cli/tests/test_cloud_agent.py +253 -0
  138. package/.next/standalone/fp-cloud-cli/tests/test_cloud_human_views.py +287 -0
  139. package/.next/standalone/fp-cloud-cli/tests/test_cloud_observability.py +116 -0
  140. package/.next/standalone/fp-cloud-cli/tests/test_cloud_transport.py +141 -0
  141. package/.next/standalone/fp-cloud-cli/tests/test_commands.py +3 -3
  142. package/.next/standalone/fp-cloud-cli/tests/test_dashboards_agent.py +3 -3
  143. package/.next/standalone/fp-cloud-cli/tests/test_evaluator_authoring.py +449 -0
  144. package/.next/standalone/fp-cloud-cli/tests/test_facets.py +1 -1
  145. package/.next/standalone/fp-cloud-cli/tests/test_help_table_coverage.py +15 -0
  146. package/.next/standalone/fp-cloud-cli/tests/test_operator.py +1 -1
  147. package/.next/standalone/fp-cloud-cli/tests/test_orgs.py +1 -1
  148. package/.next/standalone/fp-cloud-cli/tests/test_output.py +37 -37
  149. package/.next/standalone/fp-cloud-cli/tests/test_round4_behaviour.py +144 -0
  150. package/.next/standalone/fp-cloud-cli/tests/test_screen_language.py +80 -0
  151. package/.next/standalone/fp-cloud-cli/tests/test_session_reevaluate.py +33 -0
  152. package/.next/standalone/fp-cloud-cli/tests/test_staging_parity.py +198 -0
  153. package/.next/standalone/fp-cloud-cli/tests/test_whoami.py +1 -1
  154. package/.next/standalone/hermes-plugin/plugin.yaml +1 -1
  155. package/.next/standalone/package.json +9 -9
  156. package/.next/standalone/sdk/typescript/integration/fixtures/ai-4/package-lock.json +68 -176
  157. package/.next/standalone/sdk/typescript/integration/fixtures/ai-4/package.json +2 -2
  158. package/.next/standalone/sdk/typescript/integration/fixtures/ai-5/package-lock.json +4 -4
  159. package/.next/standalone/sdk/typescript/integration/fixtures/ai-5/package.json +1 -1
  160. package/.next/standalone/sdk/typescript/integration/fixtures/ai-6/package-lock.json +7 -7
  161. package/.next/standalone/sdk/typescript/integration/fixtures/ai-6/package.json +1 -1
  162. package/.next/standalone/sdk/typescript/integration/fixtures/ai-7/package-lock.json +7 -7
  163. package/.next/standalone/sdk/typescript/integration/fixtures/ai-7/package.json +1 -1
  164. package/.next/standalone/sdk/typescript/integration/fixtures/langchain-0.3/package-lock.json +98 -115
  165. package/.next/standalone/sdk/typescript/integration/fixtures/langchain-0.3/package.json +3 -3
  166. package/.next/standalone/sdk/typescript/integration/fixtures/langchain-1/package-lock.json +4 -4
  167. package/.next/standalone/sdk/typescript/integration/fixtures/langchain-1/package.json +1 -1
  168. package/.next/standalone/sdk/typescript/integration/fixtures/langchain-dup-core/package-lock.json +4 -4
  169. package/.next/standalone/sdk/typescript/integration/fixtures/langchain-dup-core/package.json +1 -1
  170. package/.next/standalone/sdk/typescript/integration/fixtures/llamaindex-0.11/package-lock.json +149 -171
  171. package/.next/standalone/sdk/typescript/integration/fixtures/llamaindex-0.11/package.json +2 -2
  172. package/.next/standalone/sdk/typescript/integration/fixtures/llamaindex-0.12/package-lock.json +4 -4
  173. package/.next/standalone/sdk/typescript/integration/fixtures/llamaindex-0.12/package.json +1 -1
  174. package/.next/standalone/sdk/typescript/integration/fixtures/mastra-0/package-lock.json +25 -25
  175. package/.next/standalone/sdk/typescript/integration/fixtures/mastra-0/package.json +1 -1
  176. package/.next/standalone/sdk/typescript/integration/fixtures/mastra-1/package-lock.json +7 -7
  177. package/.next/standalone/sdk/typescript/integration/fixtures/mastra-1/package.json +1 -1
  178. package/.next/standalone/sdk/typescript/integration/fixtures/nextjs/package-lock.json +10 -10
  179. package/.next/standalone/sdk/typescript/integration/fixtures/nextjs/package.json +1 -1
  180. package/.next/standalone/sdk/typescript/integration/fixtures/runtimes/package-lock.json +7 -7
  181. package/.next/standalone/sdk/typescript/integration/fixtures/runtimes/package.json +1 -1
  182. package/.next/standalone/server.js +1 -1
  183. package/dist/cli.mjs +85 -37
  184. package/dist/worker.mjs +73 -28
  185. package/hermes-plugin/plugin.yaml +1 -1
  186. package/package.json +9 -9
  187. package/src/hooks/agent-skill.ts +3 -0
  188. package/src/hooks/fp-home.ts +1 -1
  189. package/src/hooks/handler.ts +22 -3
  190. package/src/hooks/semantic/intent.ts +127 -24
  191. package/src/hooks/semantic/jev-review.ts +21 -2
  192. package/.next/standalone/.next/static/chunks/20py8k1gd_i1m.js +0 -1
  193. package/.next/standalone/.next/static/chunks/2hmex93ppj1ke.js +0 -1
  194. /package/.next/standalone/.next/static/{LHCtFxVG0mdvd4lsXAOFC → D7LY1YSrw96ZxKSpxxrFw}/_buildManifest.js +0 -0
  195. /package/.next/standalone/.next/static/{LHCtFxVG0mdvd4lsXAOFC → D7LY1YSrw96ZxKSpxxrFw}/_clientMiddlewareManifest.js +0 -0
  196. /package/.next/standalone/.next/static/{LHCtFxVG0mdvd4lsXAOFC → D7LY1YSrw96ZxKSpxxrFw}/_ssgManifest.js +0 -0
@@ -0,0 +1,171 @@
1
+ """Additive account, billing and integration commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List, Optional
6
+
7
+ import typer
8
+
9
+ from .. import _click_compat as click, client as api, permissions
10
+ from .._context import GLOBALS_EPILOG, require_auth
11
+ from . import _cloud, _write
12
+
13
+
14
+ def request(ctx, method, path, body=None, params=None):
15
+ if method != "GET":
16
+ _write.confirm(ctx.obj, f"{method} {path}", assume_yes=False, destructive=method == "DELETE")
17
+ result = api.cloud_request(require_auth(ctx.obj), method, path, body=body, params=params)
18
+ if method != "GET":
19
+ _write.record_action("cloud_resource_changed", resource=path.split("/")[2], success=True, destructive=method == "DELETE")
20
+ _cloud.emit(ctx.obj, result, "Cloud", view=path, method=method, body=body, params=params)
21
+
22
+
23
+ def roles_list(ctx: typer.Context) -> None:
24
+ """List named permission sets. Needs users:read."""
25
+ request(ctx, "GET", "/api/permission-sets")
26
+
27
+
28
+ def roles_show(ctx: typer.Context, name: str = typer.Argument(...)) -> None:
29
+ """Show one permission set by name."""
30
+ request(ctx, "GET", f"/api/permission-sets/{name}")
31
+
32
+
33
+ def grants(values):
34
+ if not values:
35
+ return []
36
+ try:
37
+ return permissions.parse_permission_tokens(values)
38
+ except permissions.PermissionTokenError as exc:
39
+ raise click.UsageError(str(exc)) from exc
40
+
41
+
42
+ def roles_create(ctx: typer.Context, name: str = typer.Option(..., "--name"),
43
+ permission: Optional[List[str]] = typer.Option(None, "--permission", "--add"),
44
+ description: Optional[str] = typer.Option(None, "--description"),
45
+ file: Optional[str] = typer.Option(None, "--file")) -> None:
46
+ """Create a custom permission set. Needs settings:write."""
47
+ if not 1 <= len(name.strip()) <= 64:
48
+ raise click.UsageError("Role name must be 1–64 characters")
49
+ body = _write.read_body(ctx.obj, file, {"permissions": grants(permission) if permission is not None else None, "description": description})
50
+ body["name"] = name.strip()
51
+ body.setdefault("permissions", [])
52
+ if not isinstance(body["permissions"], list):
53
+ raise click.UsageError("permissions must be an array")
54
+ body["permissions"] = grants(body["permissions"])
55
+ request(ctx, "POST", "/api/permission-sets", body)
56
+
57
+
58
+ def roles_update(ctx: typer.Context, name: str = typer.Argument(...),
59
+ permission: Optional[List[str]] = typer.Option(None, "--permission", help="Full replacement grants."),
60
+ add: Optional[List[str]] = typer.Option(None, "--add"),
61
+ remove: Optional[List[str]] = typer.Option(None, "--remove"),
62
+ description: Optional[str] = typer.Option(None, "--description"),
63
+ file: Optional[str] = typer.Option(None, "--file")) -> None:
64
+ """Update a custom set; affected members receive the new grants."""
65
+ if permission is not None and (add or remove):
66
+ raise click.UsageError("Use --permission or --add/--remove, not both")
67
+ added, removed = grants(add or []), grants(remove or [])
68
+ body = _write.read_body(ctx.obj, file, {"permissions": grants(permission) if permission is not None else None, "description": description})
69
+ if file and (add or remove):
70
+ raise click.UsageError("Use --file or discrete flags")
71
+ if not body and not added and not removed:
72
+ raise click.UsageError("Pass --permission, --add, --remove, --description or --file")
73
+ current = api.cloud_request(require_auth(ctx.obj), "GET", f"/api/permission-sets/{name}")
74
+ if current.get("is_builtin"):
75
+ raise click.UsageError("Builtin permission sets are immutable")
76
+ body.setdefault("permissions", list(dict.fromkeys([v for v in current.get("permissions", []) if v not in removed] + added)))
77
+ body["permissions"] = grants(body["permissions"])
78
+ body.setdefault("description", current.get("description"))
79
+ request(ctx, "PUT", f"/api/permission-sets/{name}", body)
80
+
81
+
82
+ def roles_delete(ctx: typer.Context, name: str = typer.Argument(...)) -> None:
83
+ """Delete a custom permission set; members keep materialized permissions."""
84
+ request(ctx, "DELETE", f"/api/permission-sets/{name}")
85
+
86
+
87
+ def plan(ctx: typer.Context) -> None:
88
+ """Show current offers and team-trial state."""
89
+ if ctx.invoked_subcommand:
90
+ return
91
+ _cloud.session_only(ctx.obj, "plan")
92
+ cctx = require_auth(ctx.obj)
93
+ data = {"offers": api.cloud_request(cctx, "GET", "/api/offers/me"),
94
+ "team_trial": api.cloud_request(cctx, "GET", "/api/team-trial/me")}
95
+ _cloud.emit(ctx.obj, data, "Plan")
96
+
97
+
98
+ def claim(ctx: typer.Context, offer: str = typer.Argument(...)) -> None:
99
+ """Claim a named offer."""
100
+ _cloud.session_only(ctx.obj, "plan claim")
101
+ request(ctx, "POST", "/api/offers/claim", {"offer_key": offer})
102
+
103
+
104
+ def otel_status(ctx: typer.Context) -> None:
105
+ """Show Cloud OTEL ingest status."""
106
+ request(ctx, "GET", "/api/otel/status")
107
+
108
+
109
+ def otel_enable(ctx: typer.Context) -> None:
110
+ """Enable OTEL ingestion for the Cloud org."""
111
+ request(ctx, "PUT", "/api/settings/otel.ingest_enabled", {"value": True})
112
+
113
+
114
+ def otel_disable(ctx: typer.Context) -> None:
115
+ """Disable OTEL ingestion for the Cloud org."""
116
+ request(ctx, "PUT", "/api/settings/otel.ingest_enabled", {"value": False})
117
+
118
+
119
+ def slack_status(ctx: typer.Context) -> None:
120
+ """Show Slack connection status (session only)."""
121
+ _cloud.session_only(ctx.obj, "slack status")
122
+ request(ctx, "GET", "/api/integrations/slack")
123
+
124
+
125
+ def slack_disconnect(ctx: typer.Context) -> None:
126
+ """Disconnect Slack (session only)."""
127
+ _cloud.session_only(ctx.obj, "slack disconnect")
128
+ request(ctx, "DELETE", "/api/integrations/slack")
129
+
130
+
131
+ def context_list(ctx: typer.Context) -> None:
132
+ """List model context-window overrides."""
133
+ request(ctx, "GET", "/api/settings/model-context-windows")
134
+
135
+
136
+ def context_set(ctx: typer.Context, model: str = typer.Argument(...),
137
+ tokens: int = typer.Argument(...)) -> None:
138
+ """Set an override; zero means unknown."""
139
+ if not 1 <= len(model.strip()) <= 256 or not 0 <= tokens <= 1_000_000:
140
+ raise click.UsageError("Model must be 1–256 characters; tokens must be 0–1000000")
141
+ request(ctx, "PUT", "/api/settings/model-context-windows", {"model_id": model.strip(), "context_window": tokens})
142
+
143
+
144
+ def context_delete(ctx: typer.Context, model: str = typer.Argument(...)) -> None:
145
+ """Remove a model override."""
146
+ if not model.strip():
147
+ raise click.UsageError("Model cannot be empty")
148
+ request(ctx, "DELETE", "/api/settings/model-context-windows", params={"model": model})
149
+
150
+
151
+ def register_context_windows(settings_app):
152
+ group = typer.Typer(no_args_is_help=True, context_settings={"help_option_names": ["-h", "--help"]})
153
+ for name, fn in (("list", context_list), ("set", context_set), ("delete", context_delete)):
154
+ group.command(name, epilog=GLOBALS_EPILOG)(fn)
155
+ settings_app.add_typer(group, name="context-windows")
156
+
157
+
158
+ def register(app):
159
+ for name, help_text, commands in (
160
+ ("roles", "Manage permission sets.", (("list", roles_list), ("show", roles_show), ("create", roles_create), ("update", roles_update), ("delete", roles_delete))),
161
+ ("otel", "Control Cloud OTEL ingest.", (("status", otel_status), ("enable", otel_enable), ("disable", otel_disable))),
162
+ ("slack", "Manage the Slack integration (session only).", (("status", slack_status), ("disconnect", slack_disconnect))),
163
+ ):
164
+ group = typer.Typer(no_args_is_help=True, help=help_text, context_settings={"help_option_names": ["-h", "--help"]})
165
+ for command, fn in commands:
166
+ group.command(command, epilog=GLOBALS_EPILOG)(fn)
167
+ app.add_typer(group, name=name)
168
+ group = typer.Typer(invoke_without_command=True, context_settings={"help_option_names": ["-h", "--help"]})
169
+ group.callback(epilog=GLOBALS_EPILOG)(plan)
170
+ group.command("claim", epilog=GLOBALS_EPILOG)(claim)
171
+ app.add_typer(group, name="plan")
@@ -21,10 +21,10 @@ import typer
21
21
 
22
22
  from .. import _click_compat as click # the Click Typer is running; see _click_compat
23
23
  from .. import client as api
24
- from .. import output
24
+ from .. import cloud_output, output
25
25
  from .._context import GLOBALS_EPILOG, AppState, deny_in_key_mode, require_auth
26
- from ..errors import NotFoundError
27
- from . import _write
26
+ from ..errors import FpCliError, NetworkError, NotFoundError
27
+ from . import _cloud, _write
28
28
 
29
29
  # The assistant has NO server route at all: `/agent/chat` and `/agent/health` are
30
30
  # implemented by the dashboard itself, and the conversation routes are per-human
@@ -279,6 +279,7 @@ def agent_ask(
279
279
  chat: Optional[str] = typer.Option(None, "--chat", help="Continue this chat — its short id from `agent chats` (a prefix) or full id. Omit to start a NEW chat (its id is printed)."),
280
280
  model: Optional[str] = typer.Option(None, "--model", help="Model to use (see `agent models`)."),
281
281
  page_context: Optional[str] = typer.Option(None, "--page-context", help="Optional context string to ground the answer."),
282
+ approve: bool = typer.Option(False, "--approve", help="Explicitly approve requested writes. Without this, scripts/--json decline."),
282
283
  ) -> None:
283
284
  """Ask the assistant a question — every ask is saved to a chat.
284
285
 
@@ -301,7 +302,7 @@ def agent_ask(
301
302
  * `fp agent ask "summarize p95 latency" --model claude-opus-4-7`
302
303
  """
303
304
  state: AppState = ctx.obj
304
- deny_in_key_mode(state, "agent", _KEY_MODE_REASON)
305
+ _cloud.session_only(state, "agent ask")
305
306
  if not message:
306
307
  message = sys.stdin.read().strip() if not sys.stdin.isatty() else typer.prompt("question", err=True)
307
308
  if not message:
@@ -323,11 +324,49 @@ def agent_ask(
323
324
  ]
324
325
  thread.append({"role": "user", "content": {"text": message}})
325
326
 
327
+ artifacts = []
328
+
329
+ def on_artifact(block):
330
+ rendered = run_artifact(cctx, block)
331
+ artifacts.append(rendered)
332
+ if not state.json:
333
+ render_artifact(rendered)
334
+
335
+ def on_ask_user(event):
336
+ decision = "reject"
337
+ if event.get("kind") == "approval":
338
+ if approve:
339
+ decision = "approve"
340
+ elif not state.json and sys.stdin.isatty():
341
+ output.info(str(event.get("question", "Approve this write?")))
342
+ if event.get("details"):
343
+ output.info(str(event["details"]))
344
+ if typer.confirm("Approve this change", default=False, err=True):
345
+ decision = "approve"
346
+ answer = {"callId": event["callId"], "decision": decision}
347
+ try:
348
+ api.cloud_request(cctx, "POST", "/api/agent/answer", body=answer)
349
+ except FpCliError as exc:
350
+ # Unwind the stream and Rich status before the central error renderer
351
+ # prints one line. Preserve the transport's exit code and request id.
352
+ reason = (
353
+ "cannot reach FailproofAI Cloud" if isinstance(exc, NetworkError)
354
+ else " ".join(exc.message.split()).rstrip(".")
355
+ )
356
+ exc.message = (
357
+ f"Assistant approval answer failed: {reason}; delivery unconfirmed."
358
+ )
359
+ exc.hint = None
360
+ raise exc from None
361
+ if decision == "reject" and not state.json:
362
+ output.hint("Declined assistant request; no write approved.")
363
+
326
364
  # The reply streams server-side (an LLM turn + tool calls can take many seconds); show a
327
365
  # themed spinner on stderr so the CLI doesn't look hung. No-op under --json / non-TTY.
328
366
  with output.thinking("thinking…", enabled=not state.json):
329
367
  result = api.agent_chat_oneshot(
330
- cctx, messages=thread, conversation_id=full_chat_id, page_context=page_context, model=model
368
+ cctx, messages=thread, conversation_id=full_chat_id, page_context=page_context, model=model,
369
+ on_ask_user=on_ask_user, on_artifact=on_artifact,
331
370
  )
332
371
 
333
372
  chat_id = full_chat_id
@@ -337,7 +376,10 @@ def agent_ask(
337
376
  # create-with-empty-title then a follow-up rename PATCH.
338
377
  chat_id = (api.create_conversation(cctx, title=_title_from_question(message)) or {}).get("id")
339
378
  if chat_id:
340
- full = thread + [{"role": "assistant", "content": {"text": result.get("answer", "")}}]
379
+ content = {"text": result.get("answer", "")}
380
+ if artifacts:
381
+ content["artifacts"] = [{k: v for k, v in a.items() if k != "result"} for a in artifacts]
382
+ full = thread + [{"role": "assistant", "content": content}]
341
383
  api.replace_messages(cctx, chat_id, full)
342
384
  if full_chat_id is not None and not prior:
343
385
  # Continuing a chat that had no messages yet → title it from this first question
@@ -350,7 +392,7 @@ def agent_ask(
350
392
  mode="continue" if chat else "new",
351
393
  )
352
394
  if state.json:
353
- output.emit_json({**result, "chat_id": chat_id})
395
+ output.emit_json({**result, "chat_id": chat_id, **({"artifacts": artifacts} if artifacts else {})})
354
396
  else:
355
397
  for tool in result.get("tools", []):
356
398
  output.agent_tool_used(tool)
@@ -404,4 +446,35 @@ def register(app: typer.Typer) -> None:
404
446
  agent.command("show", epilog=GLOBALS_EPILOG)(agent_show)
405
447
  agent.command("rename", epilog=GLOBALS_EPILOG)(agent_rename)
406
448
  agent.command("delete", epilog=GLOBALS_EPILOG)(agent_delete)
449
+ agent.command("brief", epilog=GLOBALS_EPILOG)(agent_brief)
407
450
  app.add_typer(agent, name="agent")
451
+
452
+
453
+ def run_artifact(cctx, block):
454
+ """Only execute through queries:run; never follow arbitrary URLs or actions."""
455
+ rendered = dict(block)
456
+ sql = block.get("sql")
457
+ if sql:
458
+ import re
459
+ if not isinstance(sql, str) or not re.match(r"^\s*(select|with)\b", sql, re.I):
460
+ raise click.UsageError("Assistant artifact SQL must be SELECT or WITH")
461
+ rendered["result"] = api.cloud_request(cctx, "POST", "/api/queries/run", body={"sql": sql, "params": []})
462
+ return rendered
463
+
464
+
465
+ def render_artifact(block):
466
+ cloud_output.artifact(block)
467
+
468
+
469
+ def agent_brief(ctx: typer.Context, range: str = typer.Option("24h", "--range")) -> None:
470
+ """Print the agent-curated home brief (24h or 7d). Session only."""
471
+ _cloud.session_only(ctx.obj, "agent brief")
472
+ if range not in ("24h", "7d"):
473
+ raise click.UsageError("--range must be 24h or 7d")
474
+ cctx = require_auth(ctx.obj)
475
+ data = api.cloud_request(cctx, "POST", "/api/agent/home-brief", body={"range": range})
476
+ blocks = [run_artifact(cctx, block) for block in data.get("blocks", [])]
477
+ if ctx.obj.json:
478
+ output.emit_json({**data, "blocks": blocks})
479
+ else:
480
+ cloud_output.brief({**data, "blocks": blocks})
@@ -442,4 +442,27 @@ def register(app: typer.Typer) -> None:
442
442
  alerts_app.command("update", epilog=GLOBALS_EPILOG)(alerts_update)
443
443
  alerts_app.command("delete", epilog=GLOBALS_EPILOG)(alerts_delete)
444
444
  alerts_app.command("test", epilog=GLOBALS_EPILOG)(alerts_test)
445
+ alerts_app.command("recipients", epilog=GLOBALS_EPILOG)(alerts_recipients)
446
+ alerts_app.command("issues", epilog=GLOBALS_EPILOG)(alerts_issues)
445
447
  app.add_typer(alerts_app, name="alerts")
448
+
449
+
450
+ def alerts_recipients(ctx: typer.Context) -> None:
451
+ """List available alert recipients."""
452
+ from .account_cmds import request
453
+ request(ctx, "GET", "/api/alerts/recipients")
454
+
455
+
456
+ def alerts_issues(ctx: typer.Context, alert_id: str = typer.Argument(...),
457
+ create: bool = typer.Option(False, "--create"),
458
+ title: Optional[str] = typer.Option(None, "--title"),
459
+ summary: Optional[str] = typer.Option(None, "--summary"),
460
+ file: Optional[str] = typer.Option(None, "--file")) -> None:
461
+ """List alert issues, or --create one with --title/--summary or --file."""
462
+ from .account_cmds import request
463
+ if not create and any(v is not None for v in (title, summary, file)):
464
+ raise click.UsageError("Pass --create to use issue body flags")
465
+ body = _write.read_body(ctx.obj, file, {"title": title, "summary": summary}) if create else None
466
+ if create and (not isinstance(body.get("title"), str) or not body["title"].strip()):
467
+ raise click.UsageError("Creating an issue needs --title or a title in --file")
468
+ request(ctx, "POST" if create else "GET", f"/api/alerts/{alert_id}/issues", body)
@@ -1051,4 +1051,6 @@ def register(app: typer.Typer) -> None:
1051
1051
  audits_app.command("resolve", epilog=GLOBALS_EPILOG)(findings_resolve)
1052
1052
  audits_app.command("reopen", epilog=GLOBALS_EPILOG)(findings_reopen)
1053
1053
  audits_app.command("assign", epilog=GLOBALS_EPILOG)(findings_assign)
1054
+ from . import contracts_cmds
1055
+ contracts_cmds.register(audits_app)
1054
1056
  app.add_typer(audits_app, name="audits")
@@ -0,0 +1,67 @@
1
+ """Org-wide agent contracts, alongside scheduled audits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from .. import _click_compat as click, client as api
10
+ from .._context import GLOBALS_EPILOG, require_auth
11
+ from . import _write
12
+ from .account_cmds import request
13
+
14
+ PATH = "/api/audits/contracts"
15
+
16
+
17
+ def list_contracts(ctx: typer.Context) -> None:
18
+ """List agent contracts."""
19
+ request(ctx, "GET", PATH)
20
+
21
+
22
+ def show(ctx: typer.Context, id: str = typer.Argument(...)) -> None:
23
+ """Show one contract, including its Markdown."""
24
+ request(ctx, "GET", f"{PATH}/{id}")
25
+
26
+
27
+ def validate(body):
28
+ if set(body) - {"agent_id", "body", "enabled"}:
29
+ raise click.UsageError("Contracts accept agent_id, body and enabled")
30
+ if not isinstance(body.get("agent_id"), str) or not body["agent_id"].strip():
31
+ raise click.UsageError("Contract needs an agent_id from your telemetry")
32
+ if not isinstance(body.get("body"), str) or not body["body"].strip() or len(body["body"]) > 5000 or "\0" in body["body"]:
33
+ raise click.UsageError("Contract body must be 1–5000 characters without NUL")
34
+ if "enabled" in body and not isinstance(body["enabled"], bool):
35
+ raise click.UsageError("Contract enabled must be boolean")
36
+
37
+
38
+ def create(ctx: typer.Context, agent_id: Optional[str] = typer.Option(None, "--agent-id"),
39
+ body: Optional[str] = typer.Option(None, "--body", help="Markdown or @file."),
40
+ file: Optional[str] = typer.Option(None, "--file", help="Contract API body JSON.")) -> None:
41
+ """Write an agent contract."""
42
+ data = _write.read_body(ctx.obj, file, {"agent_id": agent_id, "body": _write.read_text_arg(body[1:]) if body and body.startswith("@") else body})
43
+ validate(data)
44
+ request(ctx, "POST", PATH, data)
45
+
46
+
47
+ def update(ctx: typer.Context, id: str = typer.Argument(...),
48
+ agent_id: Optional[str] = typer.Option(None, "--agent-id"),
49
+ body: Optional[str] = typer.Option(None, "--body"),
50
+ enabled: Optional[bool] = typer.Option(None, "--enabled/--disabled"),
51
+ file: Optional[str] = typer.Option(None, "--file")) -> None:
52
+ """Update selected fields, preserving the rest."""
53
+ changes = _write.read_body(ctx.obj, file, {"agent_id": agent_id, "body": _write.read_text_arg(body[1:]) if body and body.startswith("@") else body, "enabled": enabled})
54
+ if not changes:
55
+ raise click.UsageError("Pass --agent-id, --body, --enabled/--disabled or --file")
56
+ current = api.cloud_request(require_auth(ctx.obj), "GET", f"{PATH}/{id}")
57
+ data = {k: current[k] for k in ("agent_id", "body", "enabled") if k in current}
58
+ data.update(changes)
59
+ validate(data)
60
+ request(ctx, "PUT", f"{PATH}/{id}", data)
61
+
62
+
63
+ def register(audits_app):
64
+ group = typer.Typer(no_args_is_help=True, context_settings={"help_option_names": ["-h", "--help"]})
65
+ for name, fn in (("list", list_contracts), ("show", show), ("create", create), ("update", update)):
66
+ group.command(name, epilog=GLOBALS_EPILOG)(fn)
67
+ audits_app.add_typer(group, name="contracts")
@@ -0,0 +1,159 @@
1
+ """Org dashboards and their saved-query tiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from .. import _click_compat as click, client as api
10
+ from .._context import GLOBALS_EPILOG, require_auth, validate_choice
11
+ from . import _cloud, _write
12
+
13
+ CHARTS = ("line", "area", "bar", "stacked_bar", "pie", "kpi", "table")
14
+ TILE_FIELDS = ("query_id", "title", "chart_type", "chart_config", "param_bindings", "pos_x", "pos_y", "pos_w", "pos_h")
15
+
16
+
17
+ def request(ctx, method, path, body=None):
18
+ if method != "GET":
19
+ _write.confirm(ctx.obj, f"{method} {path}", assume_yes=False, destructive=method == "DELETE")
20
+ data = api.cloud_request(require_auth(ctx.obj), method, path, body=body)
21
+ if method != "GET":
22
+ _write.record_action("dashboard_changed", resource="dashboard", success=True, destructive=method == "DELETE")
23
+ _cloud.emit(ctx.obj, data, "Dashboards", view=path, method=method, body=body)
24
+
25
+
26
+ def list_dashboards(ctx: typer.Context) -> None:
27
+ """List org dashboards."""
28
+ request(ctx, "GET", "/api/dashboards")
29
+
30
+
31
+ def show(ctx: typer.Context, id: str = typer.Argument(...)) -> None:
32
+ """Show a dashboard and its tiles."""
33
+ request(ctx, "GET", f"/api/dashboards/{id}")
34
+
35
+
36
+ def create(ctx: typer.Context, name: str = typer.Option(..., "--name"),
37
+ description: str = typer.Option("", "--description"),
38
+ file: Optional[str] = typer.Option(None, "--file", help="Dashboard definition JSON.")) -> None:
39
+ """Create a dashboard (no tiles yet)."""
40
+ if not name.strip():
41
+ raise click.UsageError("--name cannot be empty")
42
+ definition = _write.read_body(ctx.obj, file) if file else {}
43
+ request(ctx, "POST", "/api/dashboards", {"name": name, "description": description, "definition": definition})
44
+
45
+
46
+ def update(ctx: typer.Context, id: str = typer.Argument(...),
47
+ name: Optional[str] = typer.Option(None, "--name"),
48
+ description: Optional[str] = typer.Option(None, "--description"),
49
+ file: Optional[str] = typer.Option(None, "--file", help="Replacement dashboard definition JSON.")) -> None:
50
+ """Update selected fields, preserving the others."""
51
+ if name is None and description is None and file is None:
52
+ raise click.UsageError("Pass --name, --description or --file")
53
+ if name is not None and not name.strip():
54
+ raise click.UsageError("--name cannot be empty")
55
+ definition = _write.read_body(ctx.obj, file) if file else None
56
+ current = api.cloud_request(require_auth(ctx.obj), "GET", f"/api/dashboards/{id}")
57
+ # Show returns {dashboard, tiles}; older deployments return the row itself.
58
+ current = current.get("dashboard", current)
59
+ body = {k: current.get(k, {} if k == "definition" else "") for k in ("name", "description", "definition")}
60
+ body.update({k: v for k, v in {"name": name, "description": description, "definition": definition}.items() if v is not None})
61
+ request(ctx, "PUT", f"/api/dashboards/{id}", body)
62
+
63
+
64
+ def delete(ctx: typer.Context, id: str = typer.Argument(...)) -> None:
65
+ """Delete a dashboard (saved queries are retained)."""
66
+ request(ctx, "DELETE", f"/api/dashboards/{id}")
67
+
68
+
69
+ def tiles(ctx: typer.Context, id: str = typer.Argument(..., metavar="DASHBOARD_ID")) -> None:
70
+ """Manage tiles: `fp dashboards tiles ID list|add|update|remove|layout`."""
71
+ ctx.meta["dashboard_id"] = id
72
+
73
+
74
+ def tile_path(ctx):
75
+ return f"/api/dashboards/{ctx.meta['dashboard_id']}/tiles"
76
+
77
+
78
+ def tile_list(ctx: typer.Context) -> None:
79
+ """List tiles."""
80
+ request(ctx, "GET", tile_path(ctx))
81
+
82
+
83
+ def tile_add(ctx: typer.Context, query: str = typer.Option(..., "--query"),
84
+ chart: str = typer.Option(..., "--chart"), title: str = typer.Option("", "--title"),
85
+ file: Optional[str] = typer.Option(None, "--file", help="Tile config/geometry JSON.")) -> None:
86
+ """Add a saved-query tile."""
87
+ validate_choice(chart, CHARTS, flag="--chart")
88
+ body = {"query_id": query, "title": title, "chart_type": chart, "chart_config": {},
89
+ "param_bindings": {}, "pos_x": 0, "pos_y": 0, "pos_w": 6, "pos_h": 4}
90
+ if file:
91
+ body.update(_write.read_body(ctx.obj, file))
92
+ validate_tile(body)
93
+ request(ctx, "POST", tile_path(ctx), body)
94
+
95
+
96
+ def validate_tile(body):
97
+ if set(body) - set(TILE_FIELDS):
98
+ raise click.UsageError("Unknown tile fields")
99
+ validate_choice(body.get("chart_type"), CHARTS, flag="--chart")
100
+ if not body.get("query_id") or not body.get("chart_type"):
101
+ raise click.UsageError("Tile needs query_id and chart_type")
102
+ for key in ("pos_x", "pos_y", "pos_w", "pos_h"):
103
+ value = body.get(key)
104
+ if isinstance(value, bool) or not isinstance(value, int) or value < (1 if key in ("pos_w", "pos_h") else 0):
105
+ raise click.UsageError("Tile position must be non-negative with positive size")
106
+ for key in ("chart_config", "param_bindings"):
107
+ if not isinstance(body.get(key), dict):
108
+ raise click.UsageError(f"{key} must be a JSON object")
109
+
110
+
111
+ def tile_update(ctx: typer.Context, tile: str = typer.Argument(...),
112
+ query: Optional[str] = typer.Option(None, "--query"),
113
+ chart: Optional[str] = typer.Option(None, "--chart"),
114
+ title: Optional[str] = typer.Option(None, "--title"),
115
+ file: Optional[str] = typer.Option(None, "--file")) -> None:
116
+ """Update a tile, preserving unmentioned config and geometry."""
117
+ body = _write.read_body(ctx.obj, file, {"query_id": query, "chart_type": chart, "title": title})
118
+ if not body:
119
+ raise click.UsageError("Pass --query, --chart, --title or --file")
120
+ data = api.cloud_request(require_auth(ctx.obj), "GET", tile_path(ctx))
121
+ matches = [r for r in _cloud.rows(data, "tiles") if str(r.get("id")) == tile]
122
+ if len(matches) != 1:
123
+ from ..errors import NotFoundError
124
+ raise NotFoundError(f"no tile {tile}; run `fp dashboards tiles {ctx.meta['dashboard_id']} list`")
125
+ merged = {k: matches[0][k] for k in TILE_FIELDS}
126
+ merged.update(body)
127
+ validate_tile(merged)
128
+ request(ctx, "PUT", f"{tile_path(ctx)}/{tile}", merged)
129
+
130
+
131
+ def tile_remove(ctx: typer.Context, tile: str = typer.Argument(...)) -> None:
132
+ """Remove a tile."""
133
+ request(ctx, "DELETE", f"{tile_path(ctx)}/{tile}")
134
+
135
+
136
+ def tile_layout(ctx: typer.Context, file: str = typer.Option(..., "--file")) -> None:
137
+ """Apply geometry JSON: {tiles: [{id,pos_x,pos_y,pos_w,pos_h}]}."""
138
+ body = _write.read_body(ctx.obj, file)
139
+ if not isinstance(body.get("tiles"), list):
140
+ raise click.UsageError("Layout must contain a tiles array")
141
+ for row in body["tiles"]:
142
+ if not isinstance(row, dict) or not row.get("id"):
143
+ raise click.UsageError("Layout tiles need ids")
144
+ if set(row) != {"id", "pos_x", "pos_y", "pos_w", "pos_h"}:
145
+ raise click.UsageError("Layout tiles must contain id,pos_x,pos_y,pos_w,pos_h")
146
+ validate_tile({**{k: v for k, v in row.items() if k != "id"}, "query_id": "layout", "chart_type": "table", "chart_config": {}, "param_bindings": {}, "title": ""})
147
+ request(ctx, "PUT", f"{tile_path(ctx)}/layout", body)
148
+
149
+
150
+ def register(app: typer.Typer) -> None:
151
+ group = typer.Typer(no_args_is_help=True, help="Manage org dashboards and tiles.", context_settings={"help_option_names": ["-h", "--help"]})
152
+ for name, fn in (("list", list_dashboards), ("show", show), ("create", create), ("update", update), ("delete", delete)):
153
+ group.command(name, epilog=GLOBALS_EPILOG)(fn)
154
+ tile_group = typer.Typer(no_args_is_help=True, context_settings={"help_option_names": ["-h", "--help"]})
155
+ tile_group.callback()(tiles)
156
+ for name, fn in (("list", tile_list), ("add", tile_add), ("update", tile_update), ("remove", tile_remove), ("layout", tile_layout)):
157
+ tile_group.command(name, epilog=GLOBALS_EPILOG)(fn)
158
+ group.add_typer(tile_group, name="tiles")
159
+ app.add_typer(group, name="dashboards")