glaip-sdk 0.0.13__py3-none-any.whl → 0.0.15__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.
@@ -23,6 +23,7 @@ from glaip_sdk.cli.agent_config import (
23
23
  from glaip_sdk.cli.agent_config import (
24
24
  sanitize_agent_config_for_cli as sanitize_agent_config,
25
25
  )
26
+ from glaip_sdk.cli.context import detect_export_format, get_ctx_value, output_flags
26
27
  from glaip_sdk.cli.display import (
27
28
  build_resource_result_data,
28
29
  display_agent_run_suggestions,
@@ -48,10 +49,7 @@ from glaip_sdk.cli.utils import (
48
49
  _fuzzy_pick_for_resources,
49
50
  build_renderer,
50
51
  coerce_to_row,
51
- detect_export_format,
52
52
  get_client,
53
- get_ctx_value,
54
- output_flags,
55
53
  output_list,
56
54
  output_result,
57
55
  spinner_context,
@@ -13,6 +13,7 @@ import click
13
13
  from rich.console import Console
14
14
  from rich.text import Text
15
15
 
16
+ from glaip_sdk.cli.context import detect_export_format, get_ctx_value, output_flags
16
17
  from glaip_sdk.cli.display import (
17
18
  display_api_error,
18
19
  display_confirmation_prompt,
@@ -34,10 +35,7 @@ from glaip_sdk.cli.parsers.json_input import parse_json_input
34
35
  from glaip_sdk.cli.resolution import resolve_resource_reference
35
36
  from glaip_sdk.cli.utils import (
36
37
  coerce_to_row,
37
- detect_export_format,
38
38
  get_client,
39
- get_ctx_value,
40
- output_flags,
41
39
  output_list,
42
40
  output_result,
43
41
  spinner_context,
@@ -9,9 +9,9 @@ from typing import Any
9
9
  import click
10
10
  from rich.console import Console
11
11
 
12
+ from glaip_sdk.cli.context import output_flags
12
13
  from glaip_sdk.cli.utils import (
13
14
  get_client,
14
- output_flags,
15
15
  output_list,
16
16
  spinner_context,
17
17
  )
@@ -13,6 +13,7 @@ import click
13
13
  from rich.console import Console
14
14
  from rich.text import Text
15
15
 
16
+ from glaip_sdk.cli.context import detect_export_format, get_ctx_value, output_flags
16
17
  from glaip_sdk.cli.display import (
17
18
  display_api_error,
18
19
  display_confirmation_prompt,
@@ -34,10 +35,7 @@ from glaip_sdk.cli.io import (
34
35
  from glaip_sdk.cli.resolution import resolve_resource_reference
35
36
  from glaip_sdk.cli.utils import (
36
37
  coerce_to_row,
37
- detect_export_format,
38
38
  get_client,
39
- get_ctx_value,
40
- output_flags,
41
39
  output_list,
42
40
  output_result,
43
41
  spinner_context,
@@ -0,0 +1,142 @@
1
+ """Context-related helpers for the glaip CLI.
2
+
3
+ Authors:
4
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import click
14
+
15
+ __all__ = [
16
+ "get_ctx_value",
17
+ "_get_view",
18
+ "_set_view",
19
+ "_set_json",
20
+ "output_flags",
21
+ "detect_export_format",
22
+ ]
23
+
24
+
25
+ def get_ctx_value(ctx: Any, key: str, default: Any = None) -> Any:
26
+ """Safely resolve a value from a click context object.
27
+
28
+ Args:
29
+ ctx: Click context object to extract value from
30
+ key: Key to retrieve from the context
31
+ default: Default value if key is not found
32
+
33
+ Returns:
34
+ The value associated with the key, or the default if not found
35
+ """
36
+ if ctx is None:
37
+ return default
38
+
39
+ obj = getattr(ctx, "obj", None)
40
+ if obj is None:
41
+ return default
42
+
43
+ if isinstance(obj, dict):
44
+ return obj.get(key, default)
45
+
46
+ getter = getattr(obj, "get", None)
47
+ if callable(getter):
48
+ try:
49
+ return getter(key, default)
50
+ except TypeError:
51
+ return default
52
+
53
+ return getattr(obj, key, default) if hasattr(obj, key) else default
54
+
55
+
56
+ def _get_view(ctx: Any) -> str:
57
+ """Resolve the active view preference from context.
58
+
59
+ Args:
60
+ ctx: Click context object containing view preferences
61
+
62
+ Returns:
63
+ The view format string (rich, plain, json, md), defaults to 'rich'
64
+ """
65
+ view = get_ctx_value(ctx, "view")
66
+ if view:
67
+ return view
68
+
69
+ fallback = get_ctx_value(ctx, "format")
70
+ return fallback or "rich"
71
+
72
+
73
+ def _set_view(ctx: Any, _param: Any, value: str) -> None:
74
+ """Click callback to persist the `--view/--output` option.
75
+
76
+ Args:
77
+ ctx: Click context object to store the view preference
78
+ _param: Click parameter object (unused)
79
+ value: The view format string to store
80
+ """
81
+ if not value:
82
+ return
83
+ ctx.ensure_object(dict)
84
+ ctx.obj["view"] = value
85
+
86
+
87
+ def _set_json(ctx: Any, _param: Any, value: bool) -> None:
88
+ """Click callback for the `--json` shorthand flag.
89
+
90
+ Args:
91
+ ctx: Click context object to store the view preference
92
+ _param: Click parameter object (unused)
93
+ value: Boolean flag indicating json mode
94
+ """
95
+ if not value:
96
+ return
97
+ ctx.ensure_object(dict)
98
+ ctx.obj["view"] = "json"
99
+
100
+
101
+ def output_flags() -> Callable[[Callable[..., Any]], Callable[..., Any]]:
102
+ """Decorator to add shared output flags (`--view`, `--json`) to commands.
103
+
104
+ Returns:
105
+ A decorator function that adds output format options to click commands
106
+ """
107
+
108
+ def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
109
+ f = click.option(
110
+ "--json",
111
+ "json_mode",
112
+ is_flag=True,
113
+ expose_value=False,
114
+ help="Shortcut for --view json",
115
+ callback=_set_json,
116
+ )(f)
117
+ f = click.option(
118
+ "-o",
119
+ "--output",
120
+ "--view",
121
+ "view_opt",
122
+ type=click.Choice(["rich", "plain", "json", "md"]),
123
+ expose_value=False,
124
+ help="Output format",
125
+ callback=_set_view,
126
+ )(f)
127
+ return f
128
+
129
+ return decorator
130
+
131
+
132
+ def detect_export_format(file_path: str | Path) -> str:
133
+ """Detect the export format from the file extension.
134
+
135
+ Args:
136
+ file_path: Path to the file to analyze
137
+
138
+ Returns:
139
+ The format string ('yaml' or 'json') based on file extension
140
+ """
141
+ path = Path(file_path)
142
+ return "yaml" if path.suffix.lower() in {".yaml", ".yml"} else "json"
@@ -0,0 +1,148 @@
1
+ """Masking 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 os
10
+ from typing import Any
11
+
12
+ __all__ = [
13
+ "mask_payload",
14
+ "mask_rows",
15
+ "_mask_value",
16
+ "_mask_any",
17
+ "_maybe_mask_row",
18
+ "_resolve_mask_fields",
19
+ ]
20
+
21
+ _DEFAULT_MASK_FIELDS = {
22
+ "api_key",
23
+ "apikey",
24
+ "token",
25
+ "access_token",
26
+ "secret",
27
+ "client_secret",
28
+ "password",
29
+ "private_key",
30
+ "bearer",
31
+ }
32
+
33
+
34
+ def _mask_value(raw: Any) -> str:
35
+ """Return a masked representation of the provided value.
36
+
37
+ Args:
38
+ raw: The raw value to mask, converted to string.
39
+
40
+ Returns:
41
+ str: A masked representation showing first 4 and last 4 characters
42
+ separated by dots, or "••••" for strings ≤ 8 characters.
43
+ """
44
+ text = str(raw)
45
+ if len(text) <= 8:
46
+ return "••••"
47
+ return f"{text[:4]}••••••••{text[-4:]}"
48
+
49
+
50
+ def _mask_any(value: Any, mask_fields: set[str]) -> Any:
51
+ """Recursively mask sensitive fields in mappings and iterables.
52
+
53
+ Args:
54
+ value: The value to process - can be dict, list, or any other type.
55
+ mask_fields: Set of field names (lowercase) that should be masked.
56
+
57
+ Returns:
58
+ Any: The processed value with sensitive fields masked. Dicts and lists
59
+ are processed recursively, other values are returned unchanged.
60
+ """
61
+ if isinstance(value, dict):
62
+ masked: dict[Any, Any] = {}
63
+ for key, raw in value.items():
64
+ if isinstance(key, str) and key.lower() in mask_fields and raw is not None:
65
+ masked[key] = _mask_value(raw)
66
+ else:
67
+ masked[key] = _mask_any(raw, mask_fields)
68
+ return masked
69
+
70
+ if isinstance(value, list):
71
+ return [_mask_any(item, mask_fields) for item in value]
72
+
73
+ return value
74
+
75
+
76
+ def _maybe_mask_row(row: dict[str, Any], mask_fields: set[str]) -> dict[str, Any]:
77
+ """Mask a single row when masking is enabled.
78
+
79
+ Args:
80
+ row: A dictionary representing a single row of data.
81
+ mask_fields: Set of field names to mask. If empty, returns row unchanged.
82
+
83
+ Returns:
84
+ dict[str, Any]: The row with sensitive fields masked, or the original
85
+ row if no mask_fields are provided.
86
+ """
87
+ if not mask_fields:
88
+ return row
89
+ return _mask_any(row, mask_fields)
90
+
91
+
92
+ def _resolve_mask_fields() -> set[str]:
93
+ """Resolve the set of sensitive fields to mask based on environment.
94
+
95
+ Returns:
96
+ set[str]: Set of field names to mask. Empty set if masking is disabled
97
+ via AIP_MASK_OFF environment variable, custom fields from
98
+ AIP_MASK_FIELDS, or default fields if neither is set.
99
+ """
100
+ if os.getenv("AIP_MASK_OFF", "0") in {"1", "true", "on", "yes"}:
101
+ return set()
102
+
103
+ env_fields = (os.getenv("AIP_MASK_FIELDS") or "").strip()
104
+ if env_fields:
105
+ parts = [part.strip().lower() for part in env_fields.split(",") if part.strip()]
106
+ return set(parts)
107
+
108
+ return set(_DEFAULT_MASK_FIELDS)
109
+
110
+
111
+ def mask_payload(payload: Any) -> Any:
112
+ """Mask sensitive values in an arbitrary payload when masking is enabled.
113
+
114
+ Args:
115
+ payload: Any data structure (dict, list, or primitive) to mask.
116
+
117
+ Returns:
118
+ Any: The payload with sensitive fields masked based on environment
119
+ configuration. Returns original payload if masking is disabled
120
+ or if an error occurs during masking.
121
+ """
122
+ mask_fields = _resolve_mask_fields()
123
+ if not mask_fields:
124
+ return payload
125
+ try:
126
+ return _mask_any(payload, mask_fields)
127
+ except Exception:
128
+ return payload
129
+
130
+
131
+ def mask_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
132
+ """Mask sensitive values in row-oriented data when masking is enabled.
133
+
134
+ Args:
135
+ rows: List of dictionaries representing rows of tabular data.
136
+
137
+ Returns:
138
+ list[dict[str, Any]]: List of rows with sensitive fields masked based
139
+ on environment configuration. Returns original
140
+ rows if masking is disabled or if an error occurs.
141
+ """
142
+ mask_fields = _resolve_mask_fields()
143
+ if not mask_fields:
144
+ return rows
145
+ try:
146
+ return [_maybe_mask_row(row, mask_fields) for row in rows]
147
+ except Exception:
148
+ return rows
glaip_sdk/cli/pager.py ADDED
@@ -0,0 +1,271 @@
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
+ """
61
+ Configure LESS flags for a predictable, high-quality UX:
62
+ -R : pass ANSI color escapes
63
+ -S : chop long lines (horizontal scroll with ←/→)
64
+ (No -F, no -X) so we open a full-screen pager and clear on exit.
65
+ Toggle wrapping with AIP_PAGER_WRAP=1 to drop -S.
66
+ Power users can override via AIP_LESS_FLAGS.
67
+
68
+ Args:
69
+ clear_on_exit: Whether to clear the pager on exit (default: True)
70
+
71
+ Returns:
72
+ None
73
+ """
74
+ os.environ.pop("LESSSECURE", None)
75
+ if os.getenv("LESS") is None:
76
+ want_wrap = os.getenv("AIP_PAGER_WRAP", "0") == "1"
77
+ base = "-R" if want_wrap else "-RS"
78
+ default_flags = base if clear_on_exit else (base + "FX")
79
+ os.environ["LESS"] = os.getenv("AIP_LESS_FLAGS", default_flags)
80
+
81
+
82
+ def _render_ansi(renderable: Any) -> str:
83
+ """Render a Rich renderable to an ANSI string suitable for piping to 'less'.
84
+
85
+ Args:
86
+ renderable: Any Rich-compatible renderable object
87
+
88
+ Returns:
89
+ str: ANSI string representation of the renderable
90
+ """
91
+ active_console = _get_console()
92
+ buf = io.StringIO()
93
+ tmp_console = Console(
94
+ file=buf,
95
+ force_terminal=True,
96
+ color_system=active_console.color_system or "auto",
97
+ width=active_console.size.width or 100,
98
+ legacy_windows=False,
99
+ soft_wrap=False,
100
+ record=False,
101
+ )
102
+ tmp_console.print(renderable)
103
+ return buf.getvalue()
104
+
105
+
106
+ def _pager_header() -> str:
107
+ """Generate pager header with navigation instructions.
108
+
109
+ Returns:
110
+ str: Header text containing navigation help, or empty string if disabled
111
+ """
112
+ v = (os.getenv("AIP_PAGER_HEADER", "1") or "1").strip().lower()
113
+ if v in {"0", "false", "off"}:
114
+ return ""
115
+ return "\n".join(
116
+ [
117
+ "TABLE VIEW — ↑/↓ PgUp/PgDn, ←/→ horiz scroll (with -S), /search, n/N next/prev, h help, q quit",
118
+ "───────────────────────────────────────────────────────────────────────────────────────────────",
119
+ "",
120
+ ]
121
+ )
122
+
123
+
124
+ def _should_use_pager() -> bool:
125
+ """Check if we should attempt to use a system pager.
126
+
127
+ Returns:
128
+ bool: True if we should use a pager, False otherwise
129
+ """
130
+ active_console = _get_console()
131
+ if not (active_console.is_terminal and os.isatty(1)):
132
+ return False
133
+ if (os.getenv("TERM") or "").lower() == "dumb":
134
+ return False
135
+ return True
136
+
137
+
138
+ def _resolve_pager_command() -> tuple[list[str] | None, str | None]:
139
+ """Resolve the pager command and path to use.
140
+
141
+ Returns:
142
+ tuple[list[str] | None, str | None]: A tuple containing:
143
+ - list[str] | None: The pager command parts if PAGER is set to less, None otherwise
144
+ - str | None: The path to the less executable if found, None otherwise
145
+ """
146
+ pager_cmd = None
147
+ pager_env = os.getenv("PAGER")
148
+ if pager_env:
149
+ parts = shlex.split(pager_env)
150
+ if parts and os.path.basename(parts[0]).lower() == "less":
151
+ pager_cmd = parts
152
+
153
+ less_path = shutil.which("less")
154
+ return pager_cmd, less_path
155
+
156
+
157
+ def _run_less_pager(
158
+ pager_cmd: list[str] | None, less_path: str | None, tmp_path: str
159
+ ) -> None:
160
+ """Run less pager with appropriate command and flags.
161
+
162
+ Args:
163
+ pager_cmd: Custom pager command parts if PAGER is set to less, None otherwise
164
+ less_path: Path to the less executable, None if not found
165
+ tmp_path: Path to temporary file containing content to display
166
+
167
+ Returns:
168
+ None
169
+ """
170
+ if pager_cmd:
171
+ subprocess.run([*pager_cmd, tmp_path], check=False)
172
+ else:
173
+ flags = os.getenv("LESS", "-RS").split()
174
+ subprocess.run([less_path, *flags, tmp_path], check=False)
175
+
176
+
177
+ def _run_more_pager(tmp_path: str) -> None:
178
+ """Run more pager as fallback.
179
+
180
+ Args:
181
+ tmp_path: Path to temporary file containing content to display
182
+
183
+ Returns:
184
+ None
185
+
186
+ Raises:
187
+ FileNotFoundError: If more command is not found
188
+ """
189
+ more_path = shutil.which("more")
190
+ if more_path:
191
+ subprocess.run([more_path, tmp_path], check=False)
192
+ else:
193
+ raise FileNotFoundError("more command not found")
194
+
195
+
196
+ def _run_pager_with_temp_file(
197
+ pager_runner: Callable[[str], None], ansi_text: str
198
+ ) -> bool:
199
+ """Run a pager using a temporary file containing the content.
200
+
201
+ Args:
202
+ pager_runner: Function that takes a temp file path and runs the pager
203
+ ansi_text: ANSI-formatted text content to display
204
+
205
+ Returns:
206
+ bool: True if pager executed successfully, False if there was an exception
207
+ """
208
+ _prepare_pager_env(clear_on_exit=True)
209
+ with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tmp:
210
+ tmp.write(_pager_header())
211
+ tmp.write(ansi_text)
212
+ tmp_path = tmp.name
213
+ try:
214
+ pager_runner(tmp_path)
215
+ return True
216
+ except Exception:
217
+ return False
218
+ finally:
219
+ try:
220
+ os.unlink(tmp_path)
221
+ except Exception:
222
+ pass
223
+
224
+
225
+ def _page_with_system_pager(ansi_text: str) -> bool:
226
+ """Prefer 'less' with a temp file so stdin remains the TTY.
227
+
228
+ Args:
229
+ ansi_text: ANSI-formatted text content to display in the pager
230
+
231
+ Returns:
232
+ bool: True if pager was executed successfully, False otherwise
233
+ """
234
+ if not _should_use_pager():
235
+ return False
236
+
237
+ pager_cmd, less_path = _resolve_pager_command()
238
+
239
+ if pager_cmd or less_path:
240
+ return _run_pager_with_temp_file(
241
+ lambda tmp_path: _run_less_pager(pager_cmd, less_path, tmp_path), ansi_text
242
+ )
243
+
244
+ if platform.system().lower().startswith("win"):
245
+ return False
246
+
247
+ return _run_pager_with_temp_file(_run_more_pager, ansi_text)
248
+
249
+
250
+ def _should_page_output(row_count: int, is_tty: bool) -> bool:
251
+ """Determine if output should be paginated based on content size and terminal.
252
+
253
+ Args:
254
+ row_count: Number of rows in the content to display
255
+ is_tty: Whether the output is going to a terminal
256
+
257
+ Returns:
258
+ bool: True if output should be paginated, False otherwise
259
+ """
260
+ active_console = _get_console()
261
+ pager_env = (os.getenv("AIP_PAGER", "auto") or "auto").lower()
262
+ if pager_env in ("0", "off", "false"):
263
+ return False
264
+ if pager_env in ("1", "on", "true"):
265
+ return is_tty
266
+ try:
267
+ term_h = active_console.size.height or 24
268
+ approx_lines = 5 + row_count
269
+ return is_tty and (approx_lines >= term_h * 0.5)
270
+ except Exception:
271
+ return is_tty
glaip_sdk/cli/utils.py CHANGED
@@ -2,23 +2,17 @@
2
2
 
3
3
  Authors:
4
4
  Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
5
6
  """
6
7
 
7
8
  from __future__ import annotations
8
9
 
9
- import io
10
10
  import json
11
11
  import logging
12
12
  import os
13
- import platform
14
- import shlex
15
- import shutil
16
- import subprocess
17
13
  import sys
18
- import tempfile
19
14
  from collections.abc import Callable
20
15
  from contextlib import AbstractContextManager, nullcontext
21
- from pathlib import Path
22
16
  from typing import TYPE_CHECKING, Any
23
17
 
24
18
  import click
@@ -43,7 +37,9 @@ except Exception: # pragma: no cover - optional dependency
43
37
 
44
38
  if TYPE_CHECKING: # pragma: no cover - import-only during type checking
45
39
  from glaip_sdk import Client
40
+ from glaip_sdk.cli import masking, pager
46
41
  from glaip_sdk.cli.commands.configure import load_config
42
+ from glaip_sdk.cli.context import _get_view, get_ctx_value
47
43
  from glaip_sdk.rich_components import AIPPanel, AIPTable
48
44
  from glaip_sdk.utils import is_uuid
49
45
  from glaip_sdk.utils.rendering.renderer import (
@@ -53,182 +49,10 @@ from glaip_sdk.utils.rendering.renderer import (
53
49
  )
54
50
 
55
51
  console = Console()
52
+ pager.console = console
56
53
  logger = logging.getLogger("glaip_sdk.cli.utils")
57
54
 
58
55
 
59
- # ----------------------------- Context helpers ---------------------------- #
60
-
61
-
62
- def get_ctx_value(ctx: Any, key: str, default: Any = None) -> Any:
63
- """Safely resolve a value from click's context object."""
64
- if ctx is None:
65
- return default
66
-
67
- obj = getattr(ctx, "obj", None)
68
- if obj is None:
69
- return default
70
-
71
- if isinstance(obj, dict):
72
- return obj.get(key, default)
73
-
74
- getter = getattr(obj, "get", None)
75
- if callable(getter):
76
- try:
77
- return getter(key, default)
78
- except TypeError:
79
- return default
80
-
81
- return getattr(obj, key, default) if hasattr(obj, key) else default
82
-
83
-
84
- # ----------------------------- Pager helpers ----------------------------- #
85
-
86
-
87
- def _prepare_pager_env(
88
- clear_on_exit: bool = True,
89
- ) -> None: # pragma: no cover - terminal UI setup
90
- """
91
- Configure LESS flags for a predictable, high-quality UX:
92
- -R : pass ANSI color escapes
93
- -S : chop long lines (horizontal scroll with ←/→)
94
- (No -F, no -X) so we open a full-screen pager and clear on exit.
95
- Toggle wrapping with AIP_PAGER_WRAP=1 to drop -S.
96
- Power users can override via AIP_LESS_FLAGS.
97
- """
98
- os.environ.pop("LESSSECURE", None)
99
- if os.getenv("LESS") is None:
100
- want_wrap = os.getenv("AIP_PAGER_WRAP", "0") == "1"
101
- base = "-R" if want_wrap else "-RS"
102
- default_flags = base if clear_on_exit else (base + "FX")
103
- os.environ["LESS"] = os.getenv("AIP_LESS_FLAGS", default_flags)
104
-
105
-
106
- def _render_ansi(
107
- renderable: Any,
108
- ) -> str:
109
- """Render a Rich renderable to an ANSI string suitable for piping to 'less'."""
110
- buf = io.StringIO()
111
- tmp_console = Console(
112
- file=buf,
113
- force_terminal=True,
114
- color_system=console.color_system or "auto",
115
- width=console.size.width or 100,
116
- legacy_windows=False,
117
- soft_wrap=False,
118
- record=False,
119
- )
120
- tmp_console.print(renderable)
121
- return buf.getvalue()
122
-
123
-
124
- def _pager_header() -> str:
125
- v = (os.getenv("AIP_PAGER_HEADER", "1") or "1").strip().lower()
126
- if v in {"0", "false", "off"}:
127
- return ""
128
- return "\n".join(
129
- [
130
- "TABLE VIEW — ↑/↓ PgUp/PgDn, ←/→ horiz scroll (with -S), /search, n/N next/prev, h help, q quit",
131
- "───────────────────────────────────────────────────────────────────────────────────────────────",
132
- "",
133
- ]
134
- )
135
-
136
-
137
- def _should_use_pager() -> bool:
138
- """Check if we should attempt to use a system pager."""
139
- if not (console.is_terminal and os.isatty(1)):
140
- return False
141
- if (os.getenv("TERM") or "").lower() == "dumb":
142
- return False
143
- return True
144
-
145
-
146
- def _resolve_pager_command() -> tuple[list[str] | None, str | None]:
147
- """Resolve the pager command and path to use."""
148
- pager_cmd = None
149
- pager_env = os.getenv("PAGER")
150
- if pager_env:
151
- parts = shlex.split(pager_env)
152
- if parts and os.path.basename(parts[0]).lower() == "less":
153
- pager_cmd = parts
154
-
155
- less_path = shutil.which("less")
156
- return pager_cmd, less_path
157
-
158
-
159
- def _run_less_pager(
160
- pager_cmd: list[str] | None, less_path: str | None, tmp_path: str
161
- ) -> None:
162
- """Run less pager with appropriate command and flags."""
163
- if pager_cmd:
164
- subprocess.run([*pager_cmd, tmp_path], check=False)
165
- else:
166
- flags = os.getenv("LESS", "-RS").split()
167
- subprocess.run([less_path, *flags, tmp_path], check=False)
168
-
169
-
170
- def _run_more_pager(tmp_path: str) -> None:
171
- """Run more pager as fallback."""
172
- more_path = shutil.which("more")
173
- if more_path:
174
- subprocess.run([more_path, tmp_path], check=False)
175
- else:
176
- raise FileNotFoundError("more command not found")
177
-
178
-
179
- def _run_pager_with_temp_file(
180
- pager_runner: Callable[[str], None], ansi_text: str
181
- ) -> bool:
182
- """Run a pager using a temporary file containing the content."""
183
- _prepare_pager_env(clear_on_exit=True)
184
- with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tmp:
185
- tmp.write(_pager_header())
186
- tmp.write(ansi_text)
187
- tmp_path = tmp.name
188
- try:
189
- pager_runner(tmp_path)
190
- return True
191
- except Exception:
192
- # If pager fails, return False to indicate paging was not successful
193
- return False
194
- finally:
195
- try:
196
- os.unlink(tmp_path)
197
- except Exception:
198
- pass
199
-
200
-
201
- def _page_with_system_pager(
202
- ansi_text: str,
203
- ) -> bool: # pragma: no cover - spawns real pager
204
- """Prefer 'less' with a temp file so stdin remains the TTY."""
205
- if not _should_use_pager():
206
- return False
207
-
208
- pager_cmd, less_path = _resolve_pager_command()
209
-
210
- if pager_cmd or less_path:
211
- return _run_pager_with_temp_file(
212
- lambda tmp_path: _run_less_pager(pager_cmd, less_path, tmp_path), ansi_text
213
- )
214
-
215
- # Windows 'more' is poor with ANSI; let Rich fallback handle it
216
- if platform.system().lower().startswith("win"):
217
- return False
218
-
219
- # POSIX 'more' fallback (may or may not honor ANSI)
220
- return _run_pager_with_temp_file(_run_more_pager, ansi_text)
221
-
222
-
223
- def _get_view(ctx: Any) -> str:
224
- view = get_ctx_value(ctx, "view")
225
- if view:
226
- return view
227
-
228
- fallback = get_ctx_value(ctx, "format")
229
- return fallback or "rich"
230
-
231
-
232
56
  def spinner_context(
233
57
  ctx: Any | None,
234
58
  message: str,
@@ -347,63 +171,6 @@ def get_client(ctx: Any) -> Client: # pragma: no cover
347
171
  )
348
172
 
349
173
 
350
- # ----------------------------- Secret masking ---------------------------- #
351
-
352
- _DEFAULT_MASK_FIELDS = {
353
- "api_key",
354
- "apikey",
355
- "token",
356
- "access_token",
357
- "secret",
358
- "client_secret",
359
- "password",
360
- "private_key",
361
- "bearer",
362
- }
363
-
364
-
365
- def _mask_value(v: Any) -> str:
366
- s = str(v)
367
- if len(s) <= 8:
368
- return "••••"
369
- return f"{s[:4]}••••••••{s[-4:]}"
370
-
371
-
372
- def _mask_any(value: Any, mask_fields: set[str]) -> Any:
373
- """Recursively mask sensitive fields in mappings / lists."""
374
-
375
- if isinstance(value, dict):
376
- masked: dict[Any, Any] = {}
377
- for key, raw in value.items():
378
- if isinstance(key, str) and key.lower() in mask_fields and raw is not None:
379
- masked[key] = _mask_value(raw)
380
- else:
381
- masked[key] = _mask_any(raw, mask_fields)
382
- return masked
383
-
384
- if isinstance(value, list):
385
- return [_mask_any(item, mask_fields) for item in value]
386
-
387
- return value
388
-
389
-
390
- def _maybe_mask_row(row: dict[str, Any], mask_fields: set[str]) -> dict[str, Any]:
391
- """Mask a single row (legacy function, now uses _mask_any)."""
392
- if not mask_fields:
393
- return row
394
- return _mask_any(row, mask_fields)
395
-
396
-
397
- def _resolve_mask_fields() -> set[str]:
398
- if os.getenv("AIP_MASK_OFF", "0") in ("1", "true", "on", "yes"):
399
- return set()
400
- env_fields = (os.getenv("AIP_MASK_FIELDS") or "").strip()
401
- if env_fields:
402
- parts = [p.strip().lower() for p in env_fields.split(",") if p.strip()]
403
- return set(parts)
404
- return set(_DEFAULT_MASK_FIELDS)
405
-
406
-
407
174
  # ----------------------------- Fuzzy palette ----------------------------- #
408
175
 
409
176
 
@@ -667,16 +434,6 @@ def _coerce_result_payload(result: Any) -> Any:
667
434
  return result
668
435
 
669
436
 
670
- def _apply_mask_if_configured(payload: Any) -> Any:
671
- mask_fields = _resolve_mask_fields()
672
- if not mask_fields:
673
- return payload
674
- try:
675
- return _mask_any(payload, mask_fields)
676
- except Exception:
677
- return payload
678
-
679
-
680
437
  def _ensure_displayable(payload: Any) -> Any:
681
438
  if isinstance(payload, dict | list | str | int | float | bool) or payload is None:
682
439
  return payload
@@ -713,7 +470,7 @@ def output_result(
713
470
  fmt = _get_view(ctx)
714
471
 
715
472
  data = _coerce_result_payload(result)
716
- data = _apply_mask_if_configured(data)
473
+ data = masking.mask_payload(data)
717
474
  data = _ensure_displayable(data)
718
475
 
719
476
  if fmt == "json":
@@ -771,16 +528,6 @@ def _normalise_rows(
771
528
  return []
772
529
 
773
530
 
774
- def _mask_rows_if_configured(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
775
- mask_fields = _resolve_mask_fields()
776
- if not mask_fields:
777
- return rows
778
- try:
779
- return [_maybe_mask_row(row, mask_fields) for row in rows]
780
- except Exception:
781
- return rows
782
-
783
-
784
531
  def _render_plain_list(
785
532
  rows: list[dict[str, Any]], title: str, columns: list[tuple]
786
533
  ) -> None:
@@ -832,20 +579,6 @@ def _build_table_group(
832
579
  return Group(table, footer)
833
580
 
834
581
 
835
- def _should_page_output(row_count: int, is_tty: bool) -> bool:
836
- pager_env = (os.getenv("AIP_PAGER", "auto") or "auto").lower()
837
- if pager_env in ("0", "off", "false"):
838
- return False
839
- if pager_env in ("1", "on", "true"):
840
- return is_tty
841
- try:
842
- term_h = console.size.height or 24
843
- approx_lines = 5 + row_count
844
- return is_tty and (approx_lines >= term_h * 0.5)
845
- except Exception:
846
- return is_tty
847
-
848
-
849
582
  def _handle_json_output(items: list[Any], rows: list[dict[str, Any]]) -> None:
850
583
  """Handle JSON output format."""
851
584
  data = (
@@ -947,14 +680,14 @@ def _handle_table_output(
947
680
  """Handle table output with paging."""
948
681
  content = _build_table_group(rows, columns, title)
949
682
  should_page = (
950
- _should_page_output(len(rows), console.is_terminal and os.isatty(1))
683
+ pager._should_page_output(len(rows), console.is_terminal and os.isatty(1))
951
684
  if use_pager is None
952
685
  else use_pager
953
686
  )
954
687
 
955
688
  if should_page:
956
- ansi = _render_ansi(content)
957
- if not _page_with_system_pager(ansi):
689
+ ansi = pager._render_ansi(content)
690
+ if not pager._page_with_system_pager(ansi):
958
691
  with console.pager(styles=True):
959
692
  console.print(content)
960
693
  else:
@@ -974,7 +707,7 @@ def output_list(
974
707
  """Display a list with optional fuzzy palette for quick selection."""
975
708
  fmt = _get_view(ctx)
976
709
  rows = _normalise_rows(items, transform_func)
977
- rows = _mask_rows_if_configured(rows)
710
+ rows = masking.mask_rows(rows)
978
711
 
979
712
  if fmt == "json":
980
713
  _handle_json_output(items, rows)
@@ -1004,50 +737,6 @@ def output_list(
1004
737
  _handle_table_output(rows, columns, title, use_pager=use_pager)
1005
738
 
1006
739
 
1007
- # ------------------------- Output flags decorator ------------------------ #
1008
-
1009
-
1010
- def _set_view(ctx: Any, _param: Any, value: str) -> None:
1011
- if not value:
1012
- return
1013
- ctx.ensure_object(dict)
1014
- ctx.obj["view"] = value
1015
-
1016
-
1017
- def _set_json(ctx: Any, _param: Any, value: bool) -> None:
1018
- if not value:
1019
- return
1020
- ctx.ensure_object(dict)
1021
- ctx.obj["view"] = "json"
1022
-
1023
-
1024
- def output_flags() -> Callable[[Callable[..., Any]], Callable[..., Any]]:
1025
- """Decorator to allow output format flags on any subcommand."""
1026
-
1027
- def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
1028
- f = click.option(
1029
- "--json",
1030
- "json_mode",
1031
- is_flag=True,
1032
- expose_value=False,
1033
- help="Shortcut for --view json",
1034
- callback=_set_json,
1035
- )(f)
1036
- f = click.option(
1037
- "-o",
1038
- "--output",
1039
- "--view",
1040
- "view_opt",
1041
- type=click.Choice(["rich", "plain", "json", "md"]),
1042
- expose_value=False,
1043
- help="Output format",
1044
- callback=_set_view,
1045
- )(f)
1046
- return f
1047
-
1048
- return decorator
1049
-
1050
-
1051
740
  # ------------------------- Ambiguity handling --------------------------- #
1052
741
 
1053
742
 
@@ -1400,19 +1089,3 @@ def handle_ambiguous_resource(
1400
1089
  else:
1401
1090
  # Re-raise cancellation exceptions
1402
1091
  raise
1403
-
1404
-
1405
- def detect_export_format(file_path: str | Path) -> str:
1406
- """Detect export format from file extension.
1407
-
1408
- Args:
1409
- file_path: Path to the export file
1410
-
1411
- Returns:
1412
- "yaml" if file extension is .yaml or .yml, "json" otherwise
1413
- """
1414
- path = Path(file_path)
1415
- if path.suffix.lower() in [".yaml", ".yml"]:
1416
- return "yaml"
1417
- else:
1418
- return "json"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: glaip-sdk
3
- Version: 0.0.13
3
+ Version: 0.0.15
4
4
  Summary: Python SDK for GL AIP (GDP Labs AI Agent Package) - Simplified CLI Design
5
5
  License: MIT
6
6
  Author: Raymond Christopher
@@ -172,13 +172,13 @@ aip agents run <AGENT_ID> --input "Hello world, what's the weather like?"
172
172
 
173
173
  ## 📚 Documentation
174
174
 
175
- 📖 **[Complete Documentation](https://gdplabs.gitbook.io/ai-agents-package)** - Visit our GitBook for comprehensive guides, tutorials, and API reference.
175
+ 📖 **[Complete Documentation](https://gdplabs.gitbook.io/gl-aip/gl-aip-sdk/overview)** - Visit our GitBook for comprehensive guides, tutorials, and API reference.
176
176
 
177
177
  Quick links:
178
178
 
179
- - **[Quick Start Guide](./docs/get-started/quick-start-guide.md)**: Get your first agent running in 5 minutes
180
- - **[Agent Management](./docs/guides/agents-guide.md)**: Complete agent lifecycle management
181
- - **[Custom Tools](./docs/guides/tools-guide.md)**: Build and integrate custom tools
182
- - **[MCP Integration](./docs/guides/mcps-guide.md)**: Connect external services
183
- - **[API Reference](./docs/reference/python-sdk-reference.md)**: Complete SDK reference
179
+ - **[Quick Start Guide](https://gdplabs.gitbook.io/gl-aip/gl-aip-sdk/get-started/quick-start-guide)**: Get your first agent running in 5 minutes
180
+ - **[Agent Management](https://gdplabs.gitbook.io/gl-aip/gl-aip-sdk/guides/agents-guide)**: Complete agent lifecycle management
181
+ - **[Custom Tools](https://gdplabs.gitbook.io/gl-aip/gl-aip-sdk/guides/tools-guide)**: Build and integrate custom tools
182
+ - **[MCP Integration](https://gdplabs.gitbook.io/gl-aip/gl-aip-sdk/guides/mcps-guide)**: Connect external services
183
+ - **[API Reference](https://gdplabs.gitbook.io/gl-aip/gl-aip-sdk/reference/python-sdk-reference)**: Complete SDK reference
184
184
 
@@ -5,15 +5,18 @@ glaip_sdk/cli/__init__.py,sha256=xCCfuF1Yc7mpCDcfhHZTX0vizvtrDSLeT8MJ3V7m5A0,156
5
5
  glaip_sdk/cli/agent_config.py,sha256=VHjebw68wAdhGUzYdPH8qz10oADZPRgUQcPW6F7iHIU,2421
6
6
  glaip_sdk/cli/auth.py,sha256=eYdtGmJ3XgiO96hq_69GF6b3W-aRWZrDQ-6bHuaRX4M,13517
7
7
  glaip_sdk/cli/commands/__init__.py,sha256=x0CZlZbZHoHvuzfoTWIyEch6WmNnbPzxajrox6riYp0,173
8
- glaip_sdk/cli/commands/agents.py,sha256=97dzowjHgk5knyHuI-0z2ojvqNlkebNN1-ikGEoS5sc,40623
8
+ glaip_sdk/cli/commands/agents.py,sha256=Ejg_IFXfvajW2q3IrmotiLjljEYknKEcMejdvVCWoDs,40644
9
9
  glaip_sdk/cli/commands/configure.py,sha256=eRDzsaKV4fl2lJt8ieS4g2-xRnaa02eAAPW8xBf-tqA,7507
10
- glaip_sdk/cli/commands/mcps.py,sha256=ENhasfSupmCSKs-Ycg-M9Gy-58Y55SMIQzeg3fBJj48,28186
11
- glaip_sdk/cli/commands/models.py,sha256=Ra3-50BPScNs0Q-j4b7U4iK0hNooucEyVgHpQ11-pt8,1700
12
- glaip_sdk/cli/commands/tools.py,sha256=MOM9Db3HGL1stF-WvL5cZXjw-iZo2qc-oyKQHy6VwIM,18690
10
+ glaip_sdk/cli/commands/mcps.py,sha256=I-cqVqAGqSiUDgWOEB6eUeKg_wMtteoPyyXwu96qq6Q,28207
11
+ glaip_sdk/cli/commands/models.py,sha256=G1ce-wZOfvMP6SMnIVuSQ89CF444Kz8Ja6nrNOQXCqU,1729
12
+ glaip_sdk/cli/commands/tools.py,sha256=P3zuKVapoC3yV4rnHGdFPO_snjLGWo5IpfuYHIUfMeU,18711
13
+ glaip_sdk/cli/context.py,sha256=M4weRf8dmp5bMtPLRF3w1StnRB7Lo8FPFq2GQMv3Rv8,3617
13
14
  glaip_sdk/cli/display.py,sha256=jE20swoRKzpYUmc0jgbeonaXKeE9x95hfjWAEdnBYRc,8727
14
15
  glaip_sdk/cli/io.py,sha256=GPkw3pQMLBGoD5GH-KlbKpNRlVWFZOXHE17F7V3kQsI,3343
15
16
  glaip_sdk/cli/main.py,sha256=3Bl8u9t1MekzaNrAZqsx4TukbzzFdi6Wss6jvTDos00,12930
17
+ glaip_sdk/cli/masking.py,sha256=BOZjwUqxQf3LQlYgUMwq7UYgve8x4_1Qk04ixiJJPZ8,4399
16
18
  glaip_sdk/cli/mcp_validators.py,sha256=PEJRzb7ogRkwNJwJK9k5Xmb8hvoQ58L2Qywqd_3Wayo,10125
19
+ glaip_sdk/cli/pager.py,sha256=KmOBhY66JHg2vRpiNJ69RnZiF8sFer7yR8NIdlxnALk,8007
17
20
  glaip_sdk/cli/parsers/__init__.py,sha256=Ycd4HDfYmA7GUGFt0ndBPBo5uTbv15XsXnYUj-a89ug,183
18
21
  glaip_sdk/cli/parsers/json_input.py,sha256=iISa31ZsDNYWfCVRy0cifRIg2gjnhI-XtdDLB-UOshg,4039
19
22
  glaip_sdk/cli/resolution.py,sha256=BOw2NchReLKewAwBAZLWw_3_bI7u3tfzQEO7kQbIiGE,2067
@@ -22,7 +25,7 @@ glaip_sdk/cli/slash/agent_session.py,sha256=pDOwGXNHuyJIulrGYu1pacyF3oxHWeDQY-Uv
22
25
  glaip_sdk/cli/slash/prompt.py,sha256=Pr5SSTOKFssRsi-AujOm5_BCW_f5MxgLwJ3CCji1ogM,7356
23
26
  glaip_sdk/cli/slash/session.py,sha256=U5UEL6eIvNkIJcSz04Uf8Ql0EptmLJukqHxDCAJ-nOQ,31097
24
27
  glaip_sdk/cli/update_notifier.py,sha256=uVbjZJnW4znTzx4AkqsDO4NfXiF-mtQiypTkJByAVuM,3236
25
- glaip_sdk/cli/utils.py,sha256=98n1tovTUSqS5BIUl4cz6zGoRSSJiXFGJW8oD0xIm2g,42537
28
+ glaip_sdk/cli/utils.py,sha256=IG1h0wY6rIYvDRcTZuOmSgAHiYlx6yD9SKHrTbDFHmc,33077
26
29
  glaip_sdk/cli/validators.py,sha256=USbBgY86AwuDHO-Q_g8g7hu-ot4NgITBsWjTWIl62ms,5569
27
30
  glaip_sdk/client/__init__.py,sha256=nYLXfBVTTWwKjP0e63iumPYO4k5FifwWaELQPaPIKIg,188
28
31
  glaip_sdk/client/agents.py,sha256=FSKubF40wptMNIheC3_iawiX2CRbhTcNLFiz4qkPC6k,34659
@@ -58,7 +61,7 @@ glaip_sdk/utils/rich_utils.py,sha256=-Ij-1bIJvnVAi6DrfftchIlMcvOTjVmSE0Qqax0EY_s
58
61
  glaip_sdk/utils/run_renderer.py,sha256=d_VMI6LbvHPUUeRmGqh5wK_lHqDEIAcym2iqpbtDad0,1365
59
62
  glaip_sdk/utils/serialization.py,sha256=T1yt_8G2DCFpcxx7XnqFl5slksRXfBCUuLQJTreGYEQ,11806
60
63
  glaip_sdk/utils/validation.py,sha256=QNORcdyvuliEs4EH2_mkDgmoyT9utgl7YNhaf45SEf8,6992
61
- glaip_sdk-0.0.13.dist-info/METADATA,sha256=Kv1zs8YO3gQigogChDeITrmnA7UggqkM_qBFnkAAi-k,4984
62
- glaip_sdk-0.0.13.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
63
- glaip_sdk-0.0.13.dist-info/entry_points.txt,sha256=EGs8NO8J1fdFMWA3CsF7sKBEvtHb_fujdCoNPhfMouE,47
64
- glaip_sdk-0.0.13.dist-info/RECORD,,
64
+ glaip_sdk-0.0.15.dist-info/METADATA,sha256=Hp6gkY7Ci5eTGCScvyovsLl2j0g7Dlw1kQ3chFWYwrI,5168
65
+ glaip_sdk-0.0.15.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
66
+ glaip_sdk-0.0.15.dist-info/entry_points.txt,sha256=EGs8NO8J1fdFMWA3CsF7sKBEvtHb_fujdCoNPhfMouE,47
67
+ glaip_sdk-0.0.15.dist-info/RECORD,,