glaip-sdk 0.0.14__py3-none-any.whl → 0.0.16__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 (44) hide show
  1. glaip_sdk/branding.py +27 -1
  2. glaip_sdk/cli/commands/agents.py +27 -20
  3. glaip_sdk/cli/commands/configure.py +39 -50
  4. glaip_sdk/cli/commands/mcps.py +2 -6
  5. glaip_sdk/cli/commands/models.py +1 -1
  6. glaip_sdk/cli/commands/tools.py +1 -3
  7. glaip_sdk/cli/config.py +42 -0
  8. glaip_sdk/cli/context.py +142 -0
  9. glaip_sdk/cli/display.py +92 -26
  10. glaip_sdk/cli/main.py +141 -124
  11. glaip_sdk/cli/masking.py +148 -0
  12. glaip_sdk/cli/mcp_validators.py +2 -2
  13. glaip_sdk/cli/pager.py +272 -0
  14. glaip_sdk/cli/parsers/json_input.py +2 -2
  15. glaip_sdk/cli/resolution.py +12 -10
  16. glaip_sdk/cli/slash/agent_session.py +7 -0
  17. glaip_sdk/cli/slash/prompt.py +21 -2
  18. glaip_sdk/cli/slash/session.py +15 -21
  19. glaip_sdk/cli/update_notifier.py +8 -2
  20. glaip_sdk/cli/utils.py +99 -369
  21. glaip_sdk/client/_agent_payloads.py +504 -0
  22. glaip_sdk/client/agents.py +194 -551
  23. glaip_sdk/client/base.py +92 -20
  24. glaip_sdk/client/main.py +6 -0
  25. glaip_sdk/client/run_rendering.py +275 -0
  26. glaip_sdk/config/constants.py +3 -0
  27. glaip_sdk/exceptions.py +15 -0
  28. glaip_sdk/models.py +5 -0
  29. glaip_sdk/payload_schemas/__init__.py +19 -0
  30. glaip_sdk/payload_schemas/agent.py +87 -0
  31. glaip_sdk/rich_components.py +12 -0
  32. glaip_sdk/utils/client_utils.py +12 -0
  33. glaip_sdk/utils/import_export.py +2 -2
  34. glaip_sdk/utils/rendering/formatting.py +5 -0
  35. glaip_sdk/utils/rendering/models.py +22 -0
  36. glaip_sdk/utils/rendering/renderer/base.py +9 -1
  37. glaip_sdk/utils/rendering/renderer/panels.py +0 -1
  38. glaip_sdk/utils/rendering/steps.py +59 -0
  39. glaip_sdk/utils/serialization.py +24 -3
  40. {glaip_sdk-0.0.14.dist-info → glaip_sdk-0.0.16.dist-info}/METADATA +1 -1
  41. glaip_sdk-0.0.16.dist-info/RECORD +72 -0
  42. glaip_sdk-0.0.14.dist-info/RECORD +0 -64
  43. {glaip_sdk-0.0.14.dist-info → glaip_sdk-0.0.16.dist-info}/WHEEL +0 -0
  44. {glaip_sdk-0.0.14.dist-info → glaip_sdk-0.0.16.dist-info}/entry_points.txt +0 -0
glaip_sdk/cli/pager.py ADDED
@@ -0,0 +1,272 @@
1
+ """Pager-related helpers for CLI output.
2
+
3
+ Authors:
4
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import os
11
+ import platform
12
+ import shlex
13
+ import shutil
14
+ import subprocess
15
+ import tempfile
16
+ from collections.abc import Callable
17
+ from typing import Any
18
+
19
+ from rich.console import Console
20
+
21
+ __all__ = [
22
+ "console",
23
+ "_prepare_pager_env",
24
+ "_render_ansi",
25
+ "_pager_header",
26
+ "_should_use_pager",
27
+ "_resolve_pager_command",
28
+ "_run_less_pager",
29
+ "_run_more_pager",
30
+ "_run_pager_with_temp_file",
31
+ "_page_with_system_pager",
32
+ "_should_page_output",
33
+ ]
34
+
35
+ console: Console | None = None
36
+
37
+
38
+ def _get_console() -> Console:
39
+ """Return the active console instance.
40
+
41
+ Returns:
42
+ Console: The active Rich console instance
43
+ """
44
+ global console
45
+ try:
46
+ from glaip_sdk.cli import utils as cli_utils
47
+ except Exception: # pragma: no cover - fallback during import cycles
48
+ cli_utils = None
49
+
50
+ current_console = getattr(cli_utils, "console", None) if cli_utils else None
51
+ if current_console is not None and current_console is not console:
52
+ console = current_console
53
+
54
+ if console is None:
55
+ console = Console()
56
+ return console
57
+
58
+
59
+ def _prepare_pager_env(clear_on_exit: bool = True) -> None:
60
+ """Configure LESS flags for a predictable, high-quality UX.
61
+
62
+ Sets sensible defaults for the system pager:
63
+ -R : pass ANSI color escapes
64
+ -S : chop long lines (horizontal scroll with ←/→)
65
+ (No -F, no -X) so we open a full-screen pager and clear on exit.
66
+ Toggle wrapping with AIP_PAGER_WRAP=1 to drop -S.
67
+ Power users can override via AIP_LESS_FLAGS.
68
+
69
+ Args:
70
+ clear_on_exit: Whether to clear the pager on exit (default: True)
71
+
72
+ Returns:
73
+ None
74
+ """
75
+ os.environ.pop("LESSSECURE", None)
76
+ if os.getenv("LESS") is None:
77
+ want_wrap = os.getenv("AIP_PAGER_WRAP", "0") == "1"
78
+ base = "-R" if want_wrap else "-RS"
79
+ default_flags = base if clear_on_exit else (base + "FX")
80
+ os.environ["LESS"] = os.getenv("AIP_LESS_FLAGS", default_flags)
81
+
82
+
83
+ def _render_ansi(renderable: Any) -> str:
84
+ """Render a Rich renderable to an ANSI string suitable for piping to 'less'.
85
+
86
+ Args:
87
+ renderable: Any Rich-compatible renderable object
88
+
89
+ Returns:
90
+ str: ANSI string representation of the renderable
91
+ """
92
+ active_console = _get_console()
93
+ buf = io.StringIO()
94
+ tmp_console = Console(
95
+ file=buf,
96
+ force_terminal=True,
97
+ color_system=active_console.color_system or "auto",
98
+ width=active_console.size.width or 100,
99
+ legacy_windows=False,
100
+ soft_wrap=False,
101
+ record=False,
102
+ )
103
+ tmp_console.print(renderable)
104
+ return buf.getvalue()
105
+
106
+
107
+ def _pager_header() -> str:
108
+ """Generate pager header with navigation instructions.
109
+
110
+ Returns:
111
+ str: Header text containing navigation help, or empty string if disabled
112
+ """
113
+ v = (os.getenv("AIP_PAGER_HEADER", "1") or "1").strip().lower()
114
+ if v in {"0", "false", "off"}:
115
+ return ""
116
+ return "\n".join(
117
+ [
118
+ "TABLE VIEW — ↑/↓ PgUp/PgDn, ←/→ horiz scroll (with -S), /search, n/N next/prev, h help, q quit",
119
+ "───────────────────────────────────────────────────────────────────────────────────────────────",
120
+ "",
121
+ ]
122
+ )
123
+
124
+
125
+ def _should_use_pager() -> bool:
126
+ """Check if we should attempt to use a system pager.
127
+
128
+ Returns:
129
+ bool: True if we should use a pager, False otherwise
130
+ """
131
+ active_console = _get_console()
132
+ if not (active_console.is_terminal and os.isatty(1)):
133
+ return False
134
+ if (os.getenv("TERM") or "").lower() == "dumb":
135
+ return False
136
+ return True
137
+
138
+
139
+ def _resolve_pager_command() -> tuple[list[str] | None, str | None]:
140
+ """Resolve the pager command and path to use.
141
+
142
+ Returns:
143
+ tuple[list[str] | None, str | None]: A tuple containing:
144
+ - list[str] | None: The pager command parts if PAGER is set to less, None otherwise
145
+ - str | None: The path to the less executable if found, None otherwise
146
+ """
147
+ pager_cmd = None
148
+ pager_env = os.getenv("PAGER")
149
+ if pager_env:
150
+ parts = shlex.split(pager_env)
151
+ if parts and os.path.basename(parts[0]).lower() == "less":
152
+ pager_cmd = parts
153
+
154
+ less_path = shutil.which("less")
155
+ return pager_cmd, less_path
156
+
157
+
158
+ def _run_less_pager(
159
+ pager_cmd: list[str] | None, less_path: str | None, tmp_path: str
160
+ ) -> None:
161
+ """Run less pager with appropriate command and flags.
162
+
163
+ Args:
164
+ pager_cmd: Custom pager command parts if PAGER is set to less, None otherwise
165
+ less_path: Path to the less executable, None if not found
166
+ tmp_path: Path to temporary file containing content to display
167
+
168
+ Returns:
169
+ None
170
+ """
171
+ if pager_cmd:
172
+ subprocess.run([*pager_cmd, tmp_path], check=False)
173
+ else:
174
+ flags = os.getenv("LESS", "-RS").split()
175
+ subprocess.run([less_path, *flags, tmp_path], check=False)
176
+
177
+
178
+ def _run_more_pager(tmp_path: str) -> None:
179
+ """Run more pager as fallback.
180
+
181
+ Args:
182
+ tmp_path: Path to temporary file containing content to display
183
+
184
+ Returns:
185
+ None
186
+
187
+ Raises:
188
+ FileNotFoundError: If more command is not found
189
+ """
190
+ more_path = shutil.which("more")
191
+ if more_path:
192
+ subprocess.run([more_path, tmp_path], check=False)
193
+ else:
194
+ raise FileNotFoundError("more command not found")
195
+
196
+
197
+ def _run_pager_with_temp_file(
198
+ pager_runner: Callable[[str], None], ansi_text: str
199
+ ) -> bool:
200
+ """Run a pager using a temporary file containing the content.
201
+
202
+ Args:
203
+ pager_runner: Function that takes a temp file path and runs the pager
204
+ ansi_text: ANSI-formatted text content to display
205
+
206
+ Returns:
207
+ bool: True if pager executed successfully, False if there was an exception
208
+ """
209
+ _prepare_pager_env(clear_on_exit=True)
210
+ with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tmp:
211
+ tmp.write(_pager_header())
212
+ tmp.write(ansi_text)
213
+ tmp_path = tmp.name
214
+ try:
215
+ pager_runner(tmp_path)
216
+ return True
217
+ except Exception:
218
+ return False
219
+ finally:
220
+ try:
221
+ os.unlink(tmp_path)
222
+ except Exception:
223
+ pass
224
+
225
+
226
+ def _page_with_system_pager(ansi_text: str) -> bool:
227
+ """Prefer 'less' with a temp file so stdin remains the TTY.
228
+
229
+ Args:
230
+ ansi_text: ANSI-formatted text content to display in the pager
231
+
232
+ Returns:
233
+ bool: True if pager was executed successfully, False otherwise
234
+ """
235
+ if not _should_use_pager():
236
+ return False
237
+
238
+ pager_cmd, less_path = _resolve_pager_command()
239
+
240
+ if pager_cmd or less_path:
241
+ return _run_pager_with_temp_file(
242
+ lambda tmp_path: _run_less_pager(pager_cmd, less_path, tmp_path), ansi_text
243
+ )
244
+
245
+ if platform.system().lower().startswith("win"):
246
+ return False
247
+
248
+ return _run_pager_with_temp_file(_run_more_pager, ansi_text)
249
+
250
+
251
+ def _should_page_output(row_count: int, is_tty: bool) -> bool:
252
+ """Determine if output should be paginated based on content size and terminal.
253
+
254
+ Args:
255
+ row_count: Number of rows in the content to display
256
+ is_tty: Whether the output is going to a terminal
257
+
258
+ Returns:
259
+ bool: True if output should be paginated, False otherwise
260
+ """
261
+ active_console = _get_console()
262
+ pager_env = (os.getenv("AIP_PAGER", "auto") or "auto").lower()
263
+ if pager_env in ("0", "off", "false"):
264
+ return False
265
+ if pager_env in ("1", "on", "true"):
266
+ return is_tty
267
+ try:
268
+ term_h = active_console.size.height or 24
269
+ approx_lines = 5 + row_count
270
+ return is_tty and (approx_lines >= term_h * 0.5)
271
+ except Exception:
272
+ return is_tty
@@ -17,7 +17,7 @@ import click
17
17
  def _format_file_error(
18
18
  prefix: str, file_path_str: str, resolved_path: Path, *, detail: str | None = None
19
19
  ) -> str:
20
- """Format a file-related error message with path context.
20
+ r"""Format a file-related error message with path context.
21
21
 
22
22
  Args:
23
23
  prefix: Main error message
@@ -31,7 +31,7 @@ def _format_file_error(
31
31
  Examples:
32
32
  >>> from pathlib import Path
33
33
  >>> _format_file_error("File not found", "config.json", Path("/abs/config.json"))
34
- 'File not found: config.json\\nResolved path: /abs/config.json'
34
+ 'File not found: config.json\nResolved path: /abs/config.json'
35
35
  """
36
36
  parts = [f"{prefix}: {file_path_str}", f"Resolved path: {resolved_path}"]
37
37
  if detail:
@@ -32,20 +32,22 @@ def resolve_resource_reference(
32
32
  This is a common pattern used across all resource types.
33
33
 
34
34
  Args:
35
- ctx: Click context
36
- client: API client
37
- reference: Resource ID or name
38
- resource_type: Type of resource
39
- get_by_id_func: Function to get resource by ID
40
- find_by_name_func: Function to find resources by name
41
- label: Label for error messages
42
- select: Selection index for ambiguous matches
35
+ ctx: Click context for CLI operations.
36
+ _client: API client instance for backend operations.
37
+ reference: Resource ID or name to resolve.
38
+ resource_type: Type of resource being resolved.
39
+ get_by_id_func: Function to get resource by ID.
40
+ find_by_name_func: Function to find resources by name.
41
+ label: Label for error messages and user feedback.
42
+ select: Selection index for ambiguous matches in non-interactive mode.
43
+ interface_preference: Interface preference for user interaction ("fuzzy" or "questionary").
44
+ spinner_message: Custom message to show during resolution process.
43
45
 
44
46
  Returns:
45
- Resolved resource object
47
+ Resolved resource object or None if not found.
46
48
 
47
49
  Raises:
48
- click.ClickException: If resolution fails
50
+ click.ClickException: If resolution fails.
49
51
  """
50
52
  try:
51
53
  message = (
@@ -22,6 +22,12 @@ class AgentRunSession:
22
22
  """Per-agent execution context for the command palette."""
23
23
 
24
24
  def __init__(self, session: SlashSession, agent: Any) -> None:
25
+ """Initialize the agent run session.
26
+
27
+ Args:
28
+ session: The slash session context
29
+ agent: The agent to interact with
30
+ """
25
31
  self.session = session
26
32
  self.agent = agent
27
33
  self.console = session.console
@@ -39,6 +45,7 @@ class AgentRunSession:
39
45
  }
40
46
 
41
47
  def run(self) -> None:
48
+ """Run the interactive agent session loop."""
42
49
  self.session.set_contextual_commands(
43
50
  self._contextual_completion_help, include_global=False
44
51
  )
@@ -46,6 +46,11 @@ if _HAS_PROMPT_TOOLKIT:
46
46
  """Provide slash command completions inside the prompt."""
47
47
 
48
48
  def __init__(self, session: SlashSession) -> None:
49
+ """Initialize the slash completer.
50
+
51
+ Args:
52
+ session: The slash session context
53
+ """
49
54
  self._session = session
50
55
 
51
56
  def get_completions(
@@ -53,6 +58,15 @@ if _HAS_PROMPT_TOOLKIT:
53
58
  document: Any,
54
59
  _complete_event: Any, # type: ignore[no-any-return]
55
60
  ) -> Iterable[Completion]: # pragma: no cover - UI
61
+ """Get completions for slash commands.
62
+
63
+ Args:
64
+ document: The document being edited
65
+ _complete_event: The completion event
66
+
67
+ Yields:
68
+ Completion objects for matching commands
69
+ """
56
70
  if Completion is None:
57
71
  return
58
72
 
@@ -66,7 +80,14 @@ if _HAS_PROMPT_TOOLKIT:
66
80
  else: # pragma: no cover - fallback when prompt_toolkit is missing
67
81
 
68
82
  class SlashCompleter: # type: ignore[too-many-ancestors]
83
+ """Fallback slash completer when prompt_toolkit is not available."""
84
+
69
85
  def __init__(self, session: SlashSession) -> None: # noqa: D401 - stub
86
+ """Initialize the fallback slash completer.
87
+
88
+ Args:
89
+ session: The slash session context
90
+ """
70
91
  self._session = session
71
92
 
72
93
 
@@ -76,7 +97,6 @@ def setup_prompt_toolkit(
76
97
  interactive: bool,
77
98
  ) -> tuple[Any | None, Any | None]:
78
99
  """Configure prompt_toolkit session and style for interactive mode."""
79
-
80
100
  if not (interactive and _HAS_PROMPT_TOOLKIT):
81
101
  return None, None
82
102
 
@@ -105,7 +125,6 @@ def setup_prompt_toolkit(
105
125
 
106
126
  def _create_key_bindings(session: SlashSession) -> Any:
107
127
  """Create prompt_toolkit key bindings for the command palette."""
108
-
109
128
  if KeyBindings is None:
110
129
  return None
111
130
 
@@ -19,7 +19,7 @@ from rich.table import Table
19
19
 
20
20
  from glaip_sdk.branding import AIPBranding
21
21
  from glaip_sdk.cli.commands.configure import configure_command, load_config
22
- from glaip_sdk.cli.utils import _fuzzy_pick_for_resources, get_client
22
+ from glaip_sdk.cli.utils import _fuzzy_pick_for_resources, command_hint, get_client
23
23
  from glaip_sdk.rich_components import AIPPanel
24
24
 
25
25
  from .agent_session import AgentRunSession
@@ -49,6 +49,12 @@ class SlashSession:
49
49
  """Interactive command palette controller."""
50
50
 
51
51
  def __init__(self, ctx: click.Context, *, console: Console | None = None) -> None:
52
+ """Initialize the slash session.
53
+
54
+ Args:
55
+ ctx: The Click context
56
+ console: Optional console instance, creates default if None
57
+ """
52
58
  self.ctx = ctx
53
59
  self.console = console or Console()
54
60
  self._commands: dict[str, SlashCommand] = {}
@@ -90,7 +96,6 @@ class SlashSession:
90
96
 
91
97
  def run(self, initial_commands: Iterable[str] | None = None) -> None:
92
98
  """Start the command palette session loop."""
93
-
94
99
  if not self._interactive:
95
100
  self._run_non_interactive(initial_commands)
96
101
  return
@@ -153,7 +158,6 @@ class SlashSession:
153
158
 
154
159
  def _ensure_configuration(self) -> bool:
155
160
  """Ensure the CLI has both API URL and credentials before continuing."""
156
-
157
161
  while not self._configuration_ready():
158
162
  self.console.print(
159
163
  "[yellow]Configuration required.[/] Launching `/login` wizard..."
@@ -173,7 +177,6 @@ class SlashSession:
173
177
 
174
178
  def _configuration_ready(self) -> bool:
175
179
  """Check whether API URL and credentials are available."""
176
-
177
180
  config = self._load_config()
178
181
  api_url = self._get_api_url(config)
179
182
  if not api_url:
@@ -188,7 +191,6 @@ class SlashSession:
188
191
 
189
192
  def handle_command(self, raw: str, *, invoked_from_agent: bool = False) -> bool:
190
193
  """Parse and execute a single slash command string."""
191
-
192
194
  verb, args = self._parse(raw)
193
195
  if not verb:
194
196
  self.console.print("[red]Unrecognised command[/red]")
@@ -308,9 +310,13 @@ class SlashSession:
308
310
  return True
309
311
 
310
312
  if not agents:
311
- self.console.print(
312
- "[yellow]No agents available. Use `aip agents create` to add one.[/yellow]"
313
- )
313
+ hint = command_hint("agents create", slash_command=None, ctx=self.ctx)
314
+ if hint:
315
+ self.console.print(
316
+ f"[yellow]No agents available. Use `{hint}` to add one.[/yellow]"
317
+ )
318
+ else:
319
+ self.console.print("[yellow]No agents available.[/yellow]")
314
320
  return True
315
321
 
316
322
  if args:
@@ -373,7 +379,7 @@ class SlashSession:
373
379
  self._register(
374
380
  SlashCommand(
375
381
  name="login",
376
- help="Run `aip configure` to set credentials.",
382
+ help="Run `/login` (alias `/configure`) to set credentials.",
377
383
  handler=SlashSession._cmd_login,
378
384
  aliases=("configure",),
379
385
  )
@@ -419,12 +425,10 @@ class SlashSession:
419
425
  @property
420
426
  def verbose_enabled(self) -> bool:
421
427
  """Return whether verbose agent runs are enabled."""
422
-
423
428
  return self._verbose_enabled
424
429
 
425
430
  def set_verbose(self, enabled: bool, *, announce: bool = True) -> None:
426
431
  """Enable or disable verbose mode with optional announcement."""
427
-
428
432
  if self._verbose_enabled == enabled:
429
433
  if announce:
430
434
  self._print_verbose_status(context="already")
@@ -437,12 +441,10 @@ class SlashSession:
437
441
 
438
442
  def toggle_verbose(self, *, announce: bool = True) -> None:
439
443
  """Flip verbose mode state."""
440
-
441
444
  self.set_verbose(not self._verbose_enabled, announce=announce)
442
445
 
443
446
  def _cmd_verbose(self, args: list[str], _invoked_from_agent: bool) -> bool:
444
447
  """Slash handler for `/verbose` command."""
445
-
446
448
  if args:
447
449
  self.console.print(
448
450
  "Usage: `/verbose` toggles verbose streaming output. Press Ctrl+T as a shortcut."
@@ -470,30 +472,25 @@ class SlashSession:
470
472
  # ------------------------------------------------------------------
471
473
  def register_active_renderer(self, renderer: Any) -> None:
472
474
  """Register the renderer currently streaming an agent run."""
473
-
474
475
  self._active_renderer = renderer
475
476
  self._sync_active_renderer()
476
477
 
477
478
  def clear_active_renderer(self, renderer: Any | None = None) -> None:
478
479
  """Clear the active renderer if it matches the provided instance."""
479
-
480
480
  if renderer is not None and renderer is not self._active_renderer:
481
481
  return
482
482
  self._active_renderer = None
483
483
 
484
484
  def notify_agent_run_started(self) -> None:
485
485
  """Mark that an agent run is in progress."""
486
-
487
486
  self.clear_active_renderer()
488
487
 
489
488
  def notify_agent_run_finished(self) -> None:
490
489
  """Mark that the active agent run has completed."""
491
-
492
490
  self.clear_active_renderer()
493
491
 
494
492
  def _sync_active_renderer(self) -> None:
495
493
  """Ensure the active renderer reflects the current verbose state."""
496
-
497
494
  renderer = self._active_renderer
498
495
  if renderer is None:
499
496
  return
@@ -622,18 +619,15 @@ class SlashSession:
622
619
  self, commands: dict[str, str] | None, *, include_global: bool = True
623
620
  ) -> None:
624
621
  """Set context-specific commands that should appear in completions."""
625
-
626
622
  self._contextual_commands = dict(commands or {})
627
623
  self._contextual_include_global = include_global if commands else True
628
624
 
629
625
  def get_contextual_commands(self) -> dict[str, str]: # type: ignore[no-any-return]
630
626
  """Return a copy of the currently active contextual commands."""
631
-
632
627
  return dict(self._contextual_commands)
633
628
 
634
629
  def should_include_global_commands(self) -> bool:
635
630
  """Return whether global slash commands should appear in completions."""
636
-
637
631
  return self._contextual_include_global
638
632
 
639
633
  def _remember_agent(self, agent: Any) -> None: # type: ignore[no-any-return]
@@ -13,6 +13,7 @@ import httpx
13
13
  from packaging.version import InvalidVersion, Version
14
14
  from rich.console import Console
15
15
 
16
+ from glaip_sdk.cli.utils import command_hint
16
17
  from glaip_sdk.rich_components import AIPPanel
17
18
 
18
19
  FetchLatestVersion = Callable[[], str | None]
@@ -59,6 +60,7 @@ def _should_check_for_updates() -> bool:
59
60
  def _build_update_panel(
60
61
  current_version: str,
61
62
  latest_version: str,
63
+ command_text: str,
62
64
  ) -> AIPPanel:
63
65
  """Create a Rich panel that prompts the user to update."""
64
66
  message = (
@@ -66,7 +68,7 @@ def _build_update_panel(
66
68
  f"{current_version} → {latest_version}\n\n"
67
69
  "See the latest release notes:\n"
68
70
  f"https://pypi.org/project/glaip-sdk/{latest_version}/\n\n"
69
- "[cyan]Run[/cyan] [bold]aip update[/bold] to install."
71
+ f"[cyan]Run[/cyan] [bold]{command_text}[/bold] to install."
70
72
  )
71
73
  return AIPPanel(
72
74
  message,
@@ -99,8 +101,12 @@ def maybe_notify_update(
99
101
  if current is None or latest is None or latest <= current:
100
102
  return
101
103
 
104
+ command_text = command_hint("update")
105
+ if command_text is None:
106
+ return
107
+
102
108
  active_console = console or Console()
103
- panel = _build_update_panel(current_version, latest_version)
109
+ panel = _build_update_panel(current_version, latest_version, command_text)
104
110
  active_console.print(panel)
105
111
 
106
112