glaip-sdk 0.1.2__py3-none-any.whl → 0.7.17__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 (217) hide show
  1. glaip_sdk/__init__.py +44 -4
  2. glaip_sdk/_version.py +9 -0
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1413 -0
  5. glaip_sdk/branding.py +126 -2
  6. glaip_sdk/cli/account_store.py +555 -0
  7. glaip_sdk/cli/auth.py +260 -15
  8. glaip_sdk/cli/commands/__init__.py +2 -2
  9. glaip_sdk/cli/commands/accounts.py +746 -0
  10. glaip_sdk/cli/commands/agents/__init__.py +116 -0
  11. glaip_sdk/cli/commands/agents/_common.py +562 -0
  12. glaip_sdk/cli/commands/agents/create.py +155 -0
  13. glaip_sdk/cli/commands/agents/delete.py +64 -0
  14. glaip_sdk/cli/commands/agents/get.py +89 -0
  15. glaip_sdk/cli/commands/agents/list.py +129 -0
  16. glaip_sdk/cli/commands/agents/run.py +264 -0
  17. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  18. glaip_sdk/cli/commands/agents/update.py +112 -0
  19. glaip_sdk/cli/commands/common_config.py +104 -0
  20. glaip_sdk/cli/commands/configure.py +728 -113
  21. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  22. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  23. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  24. glaip_sdk/cli/commands/mcps/create.py +152 -0
  25. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  26. glaip_sdk/cli/commands/mcps/get.py +212 -0
  27. glaip_sdk/cli/commands/mcps/list.py +69 -0
  28. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  29. glaip_sdk/cli/commands/mcps/update.py +190 -0
  30. glaip_sdk/cli/commands/models.py +12 -8
  31. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  32. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  33. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  34. glaip_sdk/cli/commands/tools/_common.py +80 -0
  35. glaip_sdk/cli/commands/tools/create.py +228 -0
  36. glaip_sdk/cli/commands/tools/delete.py +61 -0
  37. glaip_sdk/cli/commands/tools/get.py +103 -0
  38. glaip_sdk/cli/commands/tools/list.py +69 -0
  39. glaip_sdk/cli/commands/tools/script.py +49 -0
  40. glaip_sdk/cli/commands/tools/update.py +102 -0
  41. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  42. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  43. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  44. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  45. glaip_sdk/cli/commands/transcripts_original.py +756 -0
  46. glaip_sdk/cli/commands/update.py +163 -17
  47. glaip_sdk/cli/config.py +49 -4
  48. glaip_sdk/cli/constants.py +38 -0
  49. glaip_sdk/cli/context.py +8 -0
  50. glaip_sdk/cli/core/__init__.py +79 -0
  51. glaip_sdk/cli/core/context.py +124 -0
  52. glaip_sdk/cli/core/output.py +851 -0
  53. glaip_sdk/cli/core/prompting.py +649 -0
  54. glaip_sdk/cli/core/rendering.py +187 -0
  55. glaip_sdk/cli/display.py +41 -20
  56. glaip_sdk/cli/entrypoint.py +20 -0
  57. glaip_sdk/cli/hints.py +57 -0
  58. glaip_sdk/cli/io.py +6 -3
  59. glaip_sdk/cli/main.py +340 -143
  60. glaip_sdk/cli/masking.py +21 -33
  61. glaip_sdk/cli/pager.py +12 -13
  62. glaip_sdk/cli/parsers/__init__.py +1 -3
  63. glaip_sdk/cli/resolution.py +2 -1
  64. glaip_sdk/cli/slash/__init__.py +0 -9
  65. glaip_sdk/cli/slash/accounts_controller.py +580 -0
  66. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  67. glaip_sdk/cli/slash/agent_session.py +62 -21
  68. glaip_sdk/cli/slash/prompt.py +21 -0
  69. glaip_sdk/cli/slash/remote_runs_controller.py +568 -0
  70. glaip_sdk/cli/slash/session.py +1105 -153
  71. glaip_sdk/cli/slash/tui/__init__.py +36 -0
  72. glaip_sdk/cli/slash/tui/accounts.tcss +177 -0
  73. glaip_sdk/cli/slash/tui/accounts_app.py +1853 -0
  74. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  75. glaip_sdk/cli/slash/tui/clipboard.py +195 -0
  76. glaip_sdk/cli/slash/tui/context.py +92 -0
  77. glaip_sdk/cli/slash/tui/indicators.py +341 -0
  78. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  79. glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
  80. glaip_sdk/cli/slash/tui/layouts/harlequin.py +184 -0
  81. glaip_sdk/cli/slash/tui/loading.py +80 -0
  82. glaip_sdk/cli/slash/tui/remote_runs_app.py +760 -0
  83. glaip_sdk/cli/slash/tui/terminal.py +407 -0
  84. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  85. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  86. glaip_sdk/cli/slash/tui/theme/manager.py +112 -0
  87. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  88. glaip_sdk/cli/slash/tui/toast.py +388 -0
  89. glaip_sdk/cli/transcript/__init__.py +12 -52
  90. glaip_sdk/cli/transcript/cache.py +255 -44
  91. glaip_sdk/cli/transcript/capture.py +66 -1
  92. glaip_sdk/cli/transcript/history.py +815 -0
  93. glaip_sdk/cli/transcript/viewer.py +72 -463
  94. glaip_sdk/cli/tui_settings.py +125 -0
  95. glaip_sdk/cli/update_notifier.py +227 -10
  96. glaip_sdk/cli/validators.py +5 -6
  97. glaip_sdk/client/__init__.py +3 -1
  98. glaip_sdk/client/_schedule_payloads.py +89 -0
  99. glaip_sdk/client/agent_runs.py +147 -0
  100. glaip_sdk/client/agents.py +576 -44
  101. glaip_sdk/client/base.py +26 -0
  102. glaip_sdk/client/hitl.py +136 -0
  103. glaip_sdk/client/main.py +25 -14
  104. glaip_sdk/client/mcps.py +165 -24
  105. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  106. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +63 -47
  107. glaip_sdk/client/payloads/agent/responses.py +43 -0
  108. glaip_sdk/client/run_rendering.py +546 -92
  109. glaip_sdk/client/schedules.py +439 -0
  110. glaip_sdk/client/shared.py +21 -0
  111. glaip_sdk/client/tools.py +206 -32
  112. glaip_sdk/config/constants.py +33 -2
  113. glaip_sdk/guardrails/__init__.py +80 -0
  114. glaip_sdk/guardrails/serializer.py +89 -0
  115. glaip_sdk/hitl/__init__.py +48 -0
  116. glaip_sdk/hitl/base.py +64 -0
  117. glaip_sdk/hitl/callback.py +43 -0
  118. glaip_sdk/hitl/local.py +121 -0
  119. glaip_sdk/hitl/remote.py +523 -0
  120. glaip_sdk/mcps/__init__.py +21 -0
  121. glaip_sdk/mcps/base.py +345 -0
  122. glaip_sdk/models/__init__.py +136 -0
  123. glaip_sdk/models/_provider_mappings.py +101 -0
  124. glaip_sdk/models/_validation.py +97 -0
  125. glaip_sdk/models/agent.py +48 -0
  126. glaip_sdk/models/agent_runs.py +117 -0
  127. glaip_sdk/models/common.py +42 -0
  128. glaip_sdk/models/constants.py +141 -0
  129. glaip_sdk/models/mcp.py +33 -0
  130. glaip_sdk/models/model.py +170 -0
  131. glaip_sdk/models/schedule.py +224 -0
  132. glaip_sdk/models/tool.py +33 -0
  133. glaip_sdk/payload_schemas/__init__.py +1 -13
  134. glaip_sdk/payload_schemas/agent.py +1 -0
  135. glaip_sdk/payload_schemas/guardrails.py +34 -0
  136. glaip_sdk/registry/__init__.py +55 -0
  137. glaip_sdk/registry/agent.py +164 -0
  138. glaip_sdk/registry/base.py +139 -0
  139. glaip_sdk/registry/mcp.py +253 -0
  140. glaip_sdk/registry/tool.py +445 -0
  141. glaip_sdk/rich_components.py +58 -2
  142. glaip_sdk/runner/__init__.py +76 -0
  143. glaip_sdk/runner/base.py +84 -0
  144. glaip_sdk/runner/deps.py +115 -0
  145. glaip_sdk/runner/langgraph.py +1055 -0
  146. glaip_sdk/runner/logging_config.py +77 -0
  147. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  148. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  149. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  150. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +116 -0
  151. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  152. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  153. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +242 -0
  154. glaip_sdk/schedules/__init__.py +22 -0
  155. glaip_sdk/schedules/base.py +291 -0
  156. glaip_sdk/tools/__init__.py +22 -0
  157. glaip_sdk/tools/base.py +488 -0
  158. glaip_sdk/utils/__init__.py +59 -12
  159. glaip_sdk/utils/a2a/__init__.py +34 -0
  160. glaip_sdk/utils/a2a/event_processor.py +188 -0
  161. glaip_sdk/utils/agent_config.py +8 -2
  162. glaip_sdk/utils/bundler.py +403 -0
  163. glaip_sdk/utils/client.py +111 -0
  164. glaip_sdk/utils/client_utils.py +39 -7
  165. glaip_sdk/utils/datetime_helpers.py +58 -0
  166. glaip_sdk/utils/discovery.py +78 -0
  167. glaip_sdk/utils/display.py +23 -15
  168. glaip_sdk/utils/export.py +143 -0
  169. glaip_sdk/utils/general.py +0 -33
  170. glaip_sdk/utils/import_export.py +12 -7
  171. glaip_sdk/utils/import_resolver.py +524 -0
  172. glaip_sdk/utils/instructions.py +101 -0
  173. glaip_sdk/utils/rendering/__init__.py +115 -1
  174. glaip_sdk/utils/rendering/formatting.py +5 -30
  175. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  176. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  177. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  178. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  179. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  180. glaip_sdk/utils/rendering/models.py +1 -0
  181. glaip_sdk/utils/rendering/renderer/__init__.py +9 -47
  182. glaip_sdk/utils/rendering/renderer/base.py +299 -1434
  183. glaip_sdk/utils/rendering/renderer/config.py +1 -5
  184. glaip_sdk/utils/rendering/renderer/debug.py +26 -20
  185. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  186. glaip_sdk/utils/rendering/renderer/stream.py +4 -33
  187. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  188. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  189. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  190. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  191. glaip_sdk/utils/rendering/state.py +204 -0
  192. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  193. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
  194. glaip_sdk/utils/rendering/steps/format.py +176 -0
  195. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  196. glaip_sdk/utils/rendering/timing.py +36 -0
  197. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  198. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  199. glaip_sdk/utils/resource_refs.py +25 -13
  200. glaip_sdk/utils/runtime_config.py +426 -0
  201. glaip_sdk/utils/serialization.py +18 -0
  202. glaip_sdk/utils/sync.py +162 -0
  203. glaip_sdk/utils/tool_detection.py +301 -0
  204. glaip_sdk/utils/tool_storage_provider.py +140 -0
  205. glaip_sdk/utils/validation.py +16 -24
  206. {glaip_sdk-0.1.2.dist-info → glaip_sdk-0.7.17.dist-info}/METADATA +69 -23
  207. glaip_sdk-0.7.17.dist-info/RECORD +224 -0
  208. {glaip_sdk-0.1.2.dist-info → glaip_sdk-0.7.17.dist-info}/WHEEL +2 -1
  209. glaip_sdk-0.7.17.dist-info/entry_points.txt +2 -0
  210. glaip_sdk-0.7.17.dist-info/top_level.txt +1 -0
  211. glaip_sdk/cli/commands/agents.py +0 -1369
  212. glaip_sdk/cli/commands/mcps.py +0 -1187
  213. glaip_sdk/cli/commands/tools.py +0 -584
  214. glaip_sdk/cli/utils.py +0 -1278
  215. glaip_sdk/models.py +0 -240
  216. glaip_sdk-0.1.2.dist-info/RECORD +0 -82
  217. glaip_sdk-0.1.2.dist-info/entry_points.txt +0 -3
@@ -0,0 +1,125 @@
1
+ """Typed loader/saver for TUI preferences stored in config.yaml.
2
+
3
+ This module implements the TUI preferences store as defined in
4
+ `specs/architecture/tui-preferences-store/spec.md`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Literal, cast
11
+
12
+ from glaip_sdk.cli.account_store import AccountStore, get_account_store
13
+
14
+ ThemeModeValue = Literal["auto", "light", "dark"]
15
+
16
+ _DEFAULT_THEME_MODE: ThemeModeValue = "auto"
17
+ _DEFAULT_LEADER = "ctrl+x"
18
+ _DEFAULT_MOUSE_CAPTURE = False
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class TUISettings:
23
+ """Resolved TUI preferences from config.yaml."""
24
+
25
+ theme_mode: ThemeModeValue = _DEFAULT_THEME_MODE
26
+ theme_name: str | None = None
27
+ leader: str = _DEFAULT_LEADER
28
+ keybind_overrides: dict[str, str] = field(default_factory=dict)
29
+ mouse_capture: bool = _DEFAULT_MOUSE_CAPTURE
30
+
31
+
32
+ def load_tui_settings(*, store: AccountStore | None = None) -> TUISettings:
33
+ """Load TUI preferences from the CLI config file."""
34
+ store = store or get_account_store()
35
+ config = store.load_config()
36
+
37
+ tui = _as_dict(config.get("tui"))
38
+ theme = _as_dict(tui.get("theme"))
39
+ mode = _coerce_theme_mode(theme.get("mode"))
40
+ name = _normalize_theme_name(theme.get("name"))
41
+
42
+ keybinds = _as_dict(tui.get("keybinds"))
43
+ leader = keybinds.get("leader")
44
+ if not isinstance(leader, str) or not leader.strip():
45
+ leader = _DEFAULT_LEADER
46
+
47
+ overrides = _coerce_keybind_overrides(keybinds.get("overrides"))
48
+
49
+ mouse_capture = tui.get("mouse_capture")
50
+ if not isinstance(mouse_capture, bool):
51
+ mouse_capture = _DEFAULT_MOUSE_CAPTURE
52
+
53
+ return TUISettings(
54
+ theme_mode=mode,
55
+ theme_name=name,
56
+ leader=leader,
57
+ keybind_overrides=overrides,
58
+ mouse_capture=mouse_capture,
59
+ )
60
+
61
+
62
+ def update_tui_settings(patch: dict[str, Any], *, store: AccountStore | None = None) -> None:
63
+ """Update TUI preferences, preserving unrelated config keys."""
64
+ store = store or get_account_store()
65
+ config = store.load_config()
66
+
67
+ existing = _as_dict(config.get("tui"))
68
+ config["tui"] = _merge_dict(existing, patch)
69
+
70
+ store.save_config_updates(config)
71
+
72
+
73
+ def persist_tui_theme(*, mode: ThemeModeValue, name: str | None, store: AccountStore | None = None) -> None:
74
+ """Persist theme preferences in the tui.theme namespace."""
75
+ update_tui_settings(
76
+ {"theme": {"mode": _coerce_theme_mode(mode), "name": _serialize_theme_name(name)}},
77
+ store=store,
78
+ )
79
+
80
+
81
+ def _as_dict(value: Any) -> dict[str, Any]:
82
+ if isinstance(value, dict):
83
+ return dict(value)
84
+ return {}
85
+
86
+
87
+ def _coerce_theme_mode(value: Any) -> ThemeModeValue:
88
+ if isinstance(value, str):
89
+ lowered = value.strip().lower()
90
+ if lowered in {"auto", "light", "dark"}:
91
+ return cast(ThemeModeValue, lowered)
92
+ return _DEFAULT_THEME_MODE
93
+
94
+
95
+ def _normalize_theme_name(value: Any) -> str | None:
96
+ if not isinstance(value, str):
97
+ return None
98
+ cleaned = value.strip()
99
+ if not cleaned or cleaned.lower() == "default":
100
+ return None
101
+ return cleaned
102
+
103
+
104
+ def _serialize_theme_name(name: str | None) -> str:
105
+ if isinstance(name, str):
106
+ cleaned = name.strip()
107
+ if cleaned:
108
+ return cleaned
109
+ return "default"
110
+
111
+
112
+ def _coerce_keybind_overrides(value: Any) -> dict[str, str]:
113
+ if not isinstance(value, dict):
114
+ return {}
115
+ return {key: val for key, val in value.items() if isinstance(key, str) and isinstance(val, str)}
116
+
117
+
118
+ def _merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
119
+ merged = dict(base)
120
+ for key, value in patch.items():
121
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
122
+ merged[key] = _merge_dict(cast(dict[str, Any], merged[key]), value)
123
+ else:
124
+ merged[key] = value
125
+ return merged
@@ -8,7 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import importlib
10
10
  import logging
11
- import os
11
+ import sys
12
12
  from collections.abc import Callable, Iterable, Iterator
13
13
  from contextlib import contextmanager
14
14
  from typing import Any, Literal
@@ -22,11 +22,19 @@ from rich.console import Console
22
22
  from glaip_sdk.branding import (
23
23
  ACCENT_STYLE,
24
24
  ERROR_STYLE,
25
+ INFO_STYLE,
25
26
  SUCCESS_STYLE,
26
27
  WARNING_STYLE,
27
28
  )
28
- from glaip_sdk.cli.commands.update import update_command
29
- from glaip_sdk.cli.utils import command_hint, format_command_hint
29
+ from glaip_sdk.cli.commands.update import (
30
+ PACKAGE_NAME,
31
+ _build_command_parts,
32
+ _build_manual_upgrade_command,
33
+ _is_uv_managed_environment,
34
+ update_command,
35
+ )
36
+ from glaip_sdk.cli.constants import UPDATE_CHECK_ENABLED
37
+ from glaip_sdk.cli.hints import command_hint, format_command_hint
30
38
  from glaip_sdk.rich_components import AIPPanel
31
39
 
32
40
  FetchLatestVersion = Callable[[], str | None]
@@ -35,6 +43,7 @@ PYPI_JSON_URL = "https://pypi.org/pypi/{package}/json"
35
43
  DEFAULT_TIMEOUT = 1.5 # seconds
36
44
 
37
45
  _LOGGER = logging.getLogger(__name__)
46
+ _UPDATE_VERSIONS_KEY = "_update_notifier_versions"
38
47
 
39
48
 
40
49
  def _parse_version(value: str) -> Version | None:
@@ -72,7 +81,11 @@ def _fetch_latest_version(package_name: str) -> str | None:
72
81
 
73
82
  def _should_check_for_updates() -> bool:
74
83
  """Return False when update checks are explicitly disabled."""
75
- return os.getenv("AIP_NO_UPDATE_CHECK") is None
84
+ # Check module attribute first (for test overrides), then fall back to imported constant
85
+ module = sys.modules.get(__name__)
86
+ if module and hasattr(module, "UPDATE_CHECK_ENABLED"):
87
+ return getattr(module, "UPDATE_CHECK_ENABLED")
88
+ return UPDATE_CHECK_ENABLED
76
89
 
77
90
 
78
91
  def _build_update_panel(
@@ -140,6 +153,7 @@ def maybe_notify_update(
140
153
  "- choose Update now or Skip to continue."
141
154
  )
142
155
  active_console.print(message)
156
+ _stash_update_versions(ctx, current_version, latest_version)
143
157
  _handle_update_decision(active_console, ctx)
144
158
  return
145
159
 
@@ -155,6 +169,7 @@ def maybe_notify_update(
155
169
  )
156
170
  active_console.print(panel)
157
171
  if should_prompt:
172
+ _stash_update_versions(ctx, current_version, latest_version)
158
173
  _handle_update_decision(active_console, ctx)
159
174
 
160
175
 
@@ -195,7 +210,13 @@ def _prompt_update_decision(console: Console) -> Literal["update", "skip"]:
195
210
 
196
211
  while True:
197
212
  try:
198
- response = console.input("Choice [1/2]: ").strip().lower()
213
+ raw_response = console.input("Choice [1/2]: ")
214
+ # Strip whitespace and convert to lowercase
215
+ response = raw_response.strip().lower()
216
+ # Remove any non-printable control characters (but keep printable chars)
217
+ # This handles cases where ANSI escape sequences or other control chars leak into input
218
+ response = "".join(char for char in response if char.isprintable() or char.isspace())
219
+ response = response.strip() # Strip again after filtering
199
220
  except (KeyboardInterrupt, EOFError):
200
221
  console.print(f"\n[{WARNING_STYLE}]Update skipped.[/]")
201
222
  return "skip"
@@ -208,19 +229,93 @@ def _prompt_update_decision(console: Console) -> Literal["update", "skip"]:
208
229
  console.print(f"[{ERROR_STYLE}]Please enter 1 to update now or 2 to skip.[/]")
209
230
 
210
231
 
232
+ def _get_manual_upgrade_command(is_uv: bool) -> str:
233
+ """Get the manual upgrade command for the given environment type.
234
+
235
+ Args:
236
+ is_uv: True if running in uv tool environment, False for pip environment.
237
+
238
+ Returns:
239
+ Manual upgrade command string.
240
+ """
241
+ try:
242
+ return _build_manual_upgrade_command(include_prerelease=False, is_uv=is_uv)
243
+ except Exception:
244
+ # Fallback: rebuild from shared command parts to avoid hardcoded strings.
245
+ try:
246
+ command_parts, _ = _build_command_parts(
247
+ package_name=PACKAGE_NAME,
248
+ is_uv=is_uv,
249
+ force_reinstall=False,
250
+ include_prerelease=False,
251
+ )
252
+ except Exception:
253
+ command_parts = (
254
+ ["uv", "tool", "install", "--upgrade", PACKAGE_NAME]
255
+ if is_uv
256
+ else ["pip", "install", "--upgrade", PACKAGE_NAME]
257
+ )
258
+ return " ".join(command_parts)
259
+
260
+
261
+ def _show_proactive_uv_guidance(console: Console, is_uv: bool) -> None:
262
+ """Show proactive guidance for uv environments before update attempt.
263
+
264
+ Args:
265
+ console: Rich console for output.
266
+ is_uv: True if running in uv tool environment.
267
+ """
268
+ if not is_uv:
269
+ return
270
+
271
+ manual_cmd = _get_manual_upgrade_command(is_uv=True)
272
+ console.print(
273
+ f"[{INFO_STYLE}]💡 Detected uv tool environment.[/] "
274
+ f"If automatic update fails, run: [{ACCENT_STYLE}]{manual_cmd}[/]"
275
+ )
276
+
277
+
278
+ def _show_error_guidance(console: Console, is_uv: bool) -> None:
279
+ """Show error guidance with correct manual command based on environment.
280
+
281
+ Args:
282
+ console: Rich console for output.
283
+ is_uv: True if running in uv tool environment.
284
+ """
285
+ try:
286
+ manual_cmd = _get_manual_upgrade_command(is_uv=is_uv)
287
+ console.print(f"[{INFO_STYLE}]💡 Tip:[/] Run this command manually:\n [{ACCENT_STYLE}]{manual_cmd}[/]")
288
+ except Exception as exc: # pragma: no cover - defensive guard
289
+ _LOGGER.debug("Failed to render update tip: %s", exc, exc_info=True)
290
+
291
+
211
292
  def _run_update_command(console: Console, ctx: Any) -> None:
212
293
  """Invoke the built-in update command and surface any errors."""
294
+ # Detect uv environment proactively before attempting update
295
+ is_uv = _is_uv_managed_environment()
296
+
297
+ # Provide proactive guidance for uv environments
298
+ # This helps users on older versions (e.g., 0.6.19) that don't have uv detection
299
+ # in their update command
300
+ _show_proactive_uv_guidance(console, is_uv)
301
+
213
302
  try:
214
303
  ctx.invoke(update_command)
215
304
  except click.ClickException as exc:
216
305
  exc.show()
217
306
  console.print(f"[{ERROR_STYLE}]Update command exited with an error.[/]")
307
+ _show_error_guidance(console, is_uv)
218
308
  except click.Abort:
219
309
  console.print(f"[{WARNING_STYLE}]Update aborted by user.[/]")
220
310
  except Exception as exc: # pragma: no cover - defensive guard
221
311
  console.print(f"[{ERROR_STYLE}]Unexpected error while running update: {exc}[/]")
312
+ # Also provide guidance for unexpected errors in uv environments
313
+ if is_uv:
314
+ manual_cmd = _get_manual_upgrade_command(is_uv=True)
315
+ console.print(f"[{INFO_STYLE}]💡 Tip:[/] Try running manually:\n [{ACCENT_STYLE}]{manual_cmd}[/]")
222
316
  else:
223
- _refresh_installed_version(console, ctx)
317
+ new_version = _refresh_installed_version(console, ctx)
318
+ _maybe_retry_update(console, ctx, new_version, is_uv)
224
319
 
225
320
 
226
321
  @contextmanager
@@ -241,9 +336,10 @@ def _suppress_library_logging(
241
336
  logger.setLevel(previous_level)
242
337
 
243
338
 
244
- def _refresh_installed_version(console: Console, ctx: Any) -> None:
339
+ def _refresh_installed_version(console: Console, ctx: Any) -> str | None:
245
340
  """Reload runtime metadata after an in-process upgrade."""
246
341
  new_version: str | None = None
342
+ branding_module: Any | None = None
247
343
 
248
344
  try:
249
345
  version_module = importlib.reload(importlib.import_module("glaip_sdk._version"))
@@ -252,22 +348,25 @@ def _refresh_installed_version(console: Console, ctx: Any) -> None:
252
348
  _LOGGER.debug("Failed to reload glaip_sdk._version: %s", exc, exc_info=True)
253
349
 
254
350
  try:
255
- branding_module = importlib.import_module("glaip_sdk.branding")
351
+ branding_module = importlib.reload(importlib.import_module("glaip_sdk.branding"))
256
352
  if new_version:
257
353
  branding_module.SDK_VERSION = new_version
258
354
  except Exception as exc: # pragma: no cover - defensive guard
259
355
  _LOGGER.debug("Failed to update branding metadata: %s", exc, exc_info=True)
356
+ branding_module = None
260
357
 
261
358
  session = _get_slash_session(ctx)
262
359
  if session and hasattr(session, "refresh_branding"):
263
360
  try:
264
- session.refresh_branding(new_version)
265
- return
361
+ branding_cls = getattr(branding_module, "AIPBranding", None) if branding_module else None
362
+ session.refresh_branding(new_version, branding_cls=branding_cls)
363
+ return new_version
266
364
  except Exception as exc: # pragma: no cover - defensive guard
267
365
  _LOGGER.debug("Failed to refresh active slash session: %s", exc, exc_info=True)
268
366
 
269
367
  if new_version:
270
368
  console.print(f"[{SUCCESS_STYLE}]CLI now running glaip-sdk {new_version}.[/]")
369
+ return new_version
271
370
 
272
371
 
273
372
  def _get_slash_session(ctx: Any) -> Any | None:
@@ -278,4 +377,122 @@ def _get_slash_session(ctx: Any) -> Any | None:
278
377
  return None
279
378
 
280
379
 
380
+ def _stash_update_versions(ctx: Any | None, current_version: str, latest_version: str) -> None:
381
+ """Persist update versions in the Click context for post-update checks."""
382
+ if ctx is None:
383
+ return
384
+ ctx_obj = getattr(ctx, "obj", None)
385
+ if isinstance(ctx_obj, dict):
386
+ ctx_obj[_UPDATE_VERSIONS_KEY] = {"current": current_version, "latest": latest_version}
387
+
388
+
389
+ def _get_update_versions(ctx: Any | None) -> tuple[str | None, str | None]:
390
+ """Return current/latest versions captured during the update prompt."""
391
+ if ctx is None:
392
+ return None, None
393
+ ctx_obj = getattr(ctx, "obj", None)
394
+ if not isinstance(ctx_obj, dict):
395
+ return None, None
396
+ payload = ctx_obj.get(_UPDATE_VERSIONS_KEY)
397
+ if not isinstance(payload, dict):
398
+ return None, None
399
+ current = payload.get("current")
400
+ latest = payload.get("latest")
401
+ return (
402
+ current if isinstance(current, str) else None,
403
+ latest if isinstance(latest, str) else None,
404
+ )
405
+
406
+
407
+ def _should_retry_update(
408
+ ctx: Any,
409
+ console: Console,
410
+ new_version: str | None,
411
+ ) -> tuple[str, str, Version, Version, Version] | None:
412
+ """Check if update retry is needed and return parsed versions if so."""
413
+ if ctx is None or not hasattr(ctx, "invoke"):
414
+ return None
415
+ if not _should_prompt_for_action(console, ctx):
416
+ return None
417
+
418
+ current_version, latest_version = _get_update_versions(ctx)
419
+ if not current_version or not latest_version or not new_version:
420
+ return None
421
+
422
+ current = _parse_version(current_version)
423
+ latest = _parse_version(latest_version)
424
+ installed = _parse_version(new_version)
425
+ if current is None or latest is None or installed is None:
426
+ return None
427
+
428
+ if installed >= latest:
429
+ return None
430
+
431
+ # Note: installed > current case is handled in _maybe_retry_update()
432
+ # to allow warning message to be printed before returning
433
+
434
+ return current_version, latest_version, current, latest, installed
435
+
436
+
437
+ def _handle_reinstall_error(console: Console, exc: Exception, is_uv: bool) -> None:
438
+ """Handle errors during reinstall attempt."""
439
+ if isinstance(exc, click.ClickException):
440
+ exc.show()
441
+ console.print(f"[{ERROR_STYLE}]Reinstall attempt failed.[/]")
442
+ _show_error_guidance(console, is_uv)
443
+ elif isinstance(exc, click.Abort):
444
+ console.print(f"[{WARNING_STYLE}]Reinstall skipped by user.[/]")
445
+ else:
446
+ console.print(f"[{ERROR_STYLE}]Unexpected error while reinstalling: {exc}[/]")
447
+ if is_uv:
448
+ manual_cmd = _get_manual_upgrade_command(is_uv=True)
449
+ console.print(f"[{INFO_STYLE}]💡 Tip:[/] Try running manually:\n [{ACCENT_STYLE}]{manual_cmd}[/]")
450
+
451
+
452
+ def _check_final_version(
453
+ console: Console, new_version: str | None, latest_version: str, latest: Version, is_uv: bool
454
+ ) -> None:
455
+ """Check and report final version status after reinstall."""
456
+ installed = _parse_version(new_version) if isinstance(new_version, str) else None
457
+ if installed is None or installed < latest:
458
+ console.print(
459
+ f"[{WARNING_STYLE}]Still on {new_version}. Your package index may not have {latest_version} yet.[/]"
460
+ )
461
+ if is_uv:
462
+ console.print(
463
+ f"[{INFO_STYLE}]💡 Tip:[/] If you need PyPI immediately, set "
464
+ f"[{ACCENT_STYLE}]UV_INDEX_URL=https://pypi.org/simple[/]."
465
+ )
466
+
467
+
468
+ def _maybe_retry_update(
469
+ console: Console,
470
+ ctx: Any,
471
+ new_version: str | None,
472
+ is_uv: bool,
473
+ ) -> None:
474
+ """Retry once with reinstall when the update did not advance versions."""
475
+ versions = _should_retry_update(ctx, console, new_version)
476
+ if versions is None:
477
+ return
478
+
479
+ current_version, latest_version, current, latest, installed = versions
480
+ if installed > current:
481
+ console.print(f"[{WARNING_STYLE}]Update installed {new_version}, but {latest_version} is still available.[/]")
482
+ return
483
+
484
+ console.print(
485
+ f"[{WARNING_STYLE}]Update completed but version stayed at {new_version}. Retrying with reinstall...[/]"
486
+ )
487
+
488
+ try:
489
+ ctx.invoke(update_command, force_reinstall=True)
490
+ except Exception as exc:
491
+ _handle_reinstall_error(console, exc, is_uv)
492
+ return
493
+
494
+ new_version = _refresh_installed_version(console, ctx)
495
+ _check_final_version(console, new_version, latest_version, latest, is_uv)
496
+
497
+
281
498
  __all__ = ["maybe_notify_update"]
@@ -13,6 +13,7 @@ from typing import Any
13
13
 
14
14
  import click
15
15
 
16
+ from glaip_sdk.cli.core.context import handle_best_effort_check
16
17
  from glaip_sdk.utils.validation import (
17
18
  coerce_timeout,
18
19
  validate_agent_instruction,
@@ -226,14 +227,12 @@ def validate_name_uniqueness_cli(
226
227
  Raises:
227
228
  click.ClickException: If name is not unique
228
229
  """
229
- try:
230
+
231
+ def _check_duplicate() -> None:
230
232
  existing = finder_func(name=name)
231
233
  if existing:
232
234
  raise click.ClickException(
233
235
  f"A {resource_type.lower()} named '{name}' already exists. Please choose a unique name."
234
236
  )
235
- except click.ClickException:
236
- raise
237
- except Exception:
238
- # Non-fatal: best-effort duplicate check
239
- pass
237
+
238
+ handle_best_effort_check(_check_duplicate)
@@ -5,6 +5,8 @@ Authors:
5
5
  Raymond Christopher (raymond.christopher@gdplabs.id)
6
6
  """
7
7
 
8
+ from glaip_sdk.client.agent_runs import AgentRunsClient
8
9
  from glaip_sdk.client.main import Client
10
+ from glaip_sdk.client.schedules import AgentScheduleManager, ScheduleClient
9
11
 
10
- __all__ = ["Client"]
12
+ __all__ = ["AgentRunsClient", "AgentScheduleManager", "Client", "ScheduleClient"]
@@ -0,0 +1,89 @@
1
+ """Schedule request payload builders for AIP SDK.
2
+
3
+ Authors:
4
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from glaip_sdk.models.schedule import ScheduleConfig
11
+
12
+
13
+ @dataclass
14
+ class ScheduleListParams:
15
+ """Parameters for listing schedules.
16
+
17
+ Args:
18
+ limit: Maximum number of schedules to return (1-100, default 50)
19
+ page: Page number for pagination (default 1)
20
+ agent_id: Filter schedules by agent ID
21
+ """
22
+
23
+ limit: int | None = None
24
+ page: int | None = None
25
+ agent_id: str | None = None
26
+
27
+ def to_query_params(self) -> dict[str, Any]:
28
+ """Convert to query parameters dictionary.
29
+
30
+ Returns:
31
+ Dictionary of non-None parameters for the API request
32
+ """
33
+ params: dict[str, Any] = {}
34
+ if self.limit is not None:
35
+ params["limit"] = self.limit
36
+ if self.page is not None:
37
+ params["page"] = self.page
38
+ if self.agent_id is not None:
39
+ params["agent_id"] = self.agent_id
40
+ return params
41
+
42
+
43
+ def normalize_schedule(
44
+ schedule: ScheduleConfig | dict[str, str] | str | None,
45
+ ) -> dict[str, str] | None:
46
+ """Normalize schedule input to a dictionary for API requests.
47
+
48
+ Accepts multiple input formats for user convenience:
49
+ - ScheduleConfig: Pydantic model with cron fields
50
+ - dict: Dictionary with cron fields (minute, hour, etc.)
51
+ - str: Cron string like "0 9 * * 1-5"
52
+ - None: Returns None
53
+
54
+ Args:
55
+ schedule: Schedule in various formats
56
+
57
+ Returns:
58
+ Dictionary suitable for API request or None
59
+
60
+ Raises:
61
+ ValueError: If cron string format is invalid
62
+ TypeError: If schedule is an unsupported type
63
+
64
+ Examples:
65
+ >>> normalize_schedule(ScheduleConfig(minute="0", hour="9"))
66
+ {'minute': '0', 'hour': '9', 'day_of_month': '*', 'month': '*', 'day_of_week': '*'}
67
+
68
+ >>> normalize_schedule({"minute": "0", "hour": "9"})
69
+ {'minute': '0', 'hour': '9', 'day_of_month': '*', 'month': '*', 'day_of_week': '*'}
70
+
71
+ >>> normalize_schedule("0 9 * * 1-5")
72
+ {'minute': '0', 'hour': '9', 'day_of_month': '*', 'month': '*', 'day_of_week': '1-5'}
73
+ """
74
+ if schedule is None:
75
+ return None
76
+
77
+ if isinstance(schedule, ScheduleConfig):
78
+ return schedule.model_dump()
79
+
80
+ if isinstance(schedule, dict):
81
+ # Validate and merge with defaults
82
+ return ScheduleConfig(**schedule).model_dump()
83
+
84
+ if isinstance(schedule, str):
85
+ # Parse cron string
86
+ config = ScheduleConfig.from_cron_string(schedule)
87
+ return config.model_dump()
88
+
89
+ raise TypeError(f"schedule must be ScheduleConfig, dict, or str, got {type(schedule).__name__}")