glaip-sdk 0.6.15b2__py3-none-any.whl → 0.6.15b3__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 (154) hide show
  1. glaip_sdk/agents/__init__.py +27 -0
  2. glaip_sdk/agents/base.py +1196 -0
  3. glaip_sdk/cli/__init__.py +9 -0
  4. glaip_sdk/cli/account_store.py +540 -0
  5. glaip_sdk/cli/agent_config.py +78 -0
  6. glaip_sdk/cli/auth.py +699 -0
  7. glaip_sdk/cli/commands/__init__.py +5 -0
  8. glaip_sdk/cli/commands/accounts.py +746 -0
  9. glaip_sdk/cli/commands/agents.py +1509 -0
  10. glaip_sdk/cli/commands/common_config.py +104 -0
  11. glaip_sdk/cli/commands/configure.py +896 -0
  12. glaip_sdk/cli/commands/mcps.py +1356 -0
  13. glaip_sdk/cli/commands/models.py +69 -0
  14. glaip_sdk/cli/commands/tools.py +576 -0
  15. glaip_sdk/cli/commands/transcripts.py +755 -0
  16. glaip_sdk/cli/commands/update.py +61 -0
  17. glaip_sdk/cli/config.py +95 -0
  18. glaip_sdk/cli/constants.py +38 -0
  19. glaip_sdk/cli/context.py +150 -0
  20. glaip_sdk/cli/core/__init__.py +79 -0
  21. glaip_sdk/cli/core/context.py +124 -0
  22. glaip_sdk/cli/core/output.py +851 -0
  23. glaip_sdk/cli/core/prompting.py +649 -0
  24. glaip_sdk/cli/core/rendering.py +187 -0
  25. glaip_sdk/cli/display.py +355 -0
  26. glaip_sdk/cli/hints.py +57 -0
  27. glaip_sdk/cli/io.py +112 -0
  28. glaip_sdk/cli/main.py +615 -0
  29. glaip_sdk/cli/masking.py +136 -0
  30. glaip_sdk/cli/mcp_validators.py +287 -0
  31. glaip_sdk/cli/pager.py +266 -0
  32. glaip_sdk/cli/parsers/__init__.py +7 -0
  33. glaip_sdk/cli/parsers/json_input.py +177 -0
  34. glaip_sdk/cli/resolution.py +67 -0
  35. glaip_sdk/cli/rich_helpers.py +27 -0
  36. glaip_sdk/cli/slash/__init__.py +15 -0
  37. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  38. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  39. glaip_sdk/cli/slash/agent_session.py +285 -0
  40. glaip_sdk/cli/slash/prompt.py +256 -0
  41. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  42. glaip_sdk/cli/slash/session.py +1708 -0
  43. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  44. glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
  45. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  46. glaip_sdk/cli/slash/tui/loading.py +58 -0
  47. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  48. glaip_sdk/cli/transcript/__init__.py +31 -0
  49. glaip_sdk/cli/transcript/cache.py +536 -0
  50. glaip_sdk/cli/transcript/capture.py +329 -0
  51. glaip_sdk/cli/transcript/export.py +38 -0
  52. glaip_sdk/cli/transcript/history.py +815 -0
  53. glaip_sdk/cli/transcript/launcher.py +77 -0
  54. glaip_sdk/cli/transcript/viewer.py +374 -0
  55. glaip_sdk/cli/update_notifier.py +290 -0
  56. glaip_sdk/cli/utils.py +263 -0
  57. glaip_sdk/cli/validators.py +238 -0
  58. glaip_sdk/client/__init__.py +11 -0
  59. glaip_sdk/client/_agent_payloads.py +520 -0
  60. glaip_sdk/client/agent_runs.py +147 -0
  61. glaip_sdk/client/agents.py +1335 -0
  62. glaip_sdk/client/base.py +502 -0
  63. glaip_sdk/client/main.py +249 -0
  64. glaip_sdk/client/mcps.py +370 -0
  65. glaip_sdk/client/run_rendering.py +700 -0
  66. glaip_sdk/client/shared.py +21 -0
  67. glaip_sdk/client/tools.py +661 -0
  68. glaip_sdk/client/validators.py +198 -0
  69. glaip_sdk/config/constants.py +52 -0
  70. glaip_sdk/mcps/__init__.py +21 -0
  71. glaip_sdk/mcps/base.py +345 -0
  72. glaip_sdk/models/__init__.py +90 -0
  73. glaip_sdk/models/agent.py +47 -0
  74. glaip_sdk/models/agent_runs.py +116 -0
  75. glaip_sdk/models/common.py +42 -0
  76. glaip_sdk/models/mcp.py +33 -0
  77. glaip_sdk/models/tool.py +33 -0
  78. glaip_sdk/payload_schemas/__init__.py +7 -0
  79. glaip_sdk/payload_schemas/agent.py +85 -0
  80. glaip_sdk/registry/__init__.py +55 -0
  81. glaip_sdk/registry/agent.py +164 -0
  82. glaip_sdk/registry/base.py +139 -0
  83. glaip_sdk/registry/mcp.py +253 -0
  84. glaip_sdk/registry/tool.py +232 -0
  85. glaip_sdk/runner/__init__.py +59 -0
  86. glaip_sdk/runner/base.py +84 -0
  87. glaip_sdk/runner/deps.py +112 -0
  88. glaip_sdk/runner/langgraph.py +782 -0
  89. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  90. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  91. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  92. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  93. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  94. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  95. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  96. glaip_sdk/tools/__init__.py +22 -0
  97. glaip_sdk/tools/base.py +435 -0
  98. glaip_sdk/utils/__init__.py +86 -0
  99. glaip_sdk/utils/a2a/__init__.py +34 -0
  100. glaip_sdk/utils/a2a/event_processor.py +188 -0
  101. glaip_sdk/utils/agent_config.py +194 -0
  102. glaip_sdk/utils/bundler.py +267 -0
  103. glaip_sdk/utils/client.py +111 -0
  104. glaip_sdk/utils/client_utils.py +486 -0
  105. glaip_sdk/utils/datetime_helpers.py +58 -0
  106. glaip_sdk/utils/discovery.py +78 -0
  107. glaip_sdk/utils/display.py +135 -0
  108. glaip_sdk/utils/export.py +143 -0
  109. glaip_sdk/utils/general.py +61 -0
  110. glaip_sdk/utils/import_export.py +168 -0
  111. glaip_sdk/utils/import_resolver.py +492 -0
  112. glaip_sdk/utils/instructions.py +101 -0
  113. glaip_sdk/utils/rendering/__init__.py +115 -0
  114. glaip_sdk/utils/rendering/formatting.py +264 -0
  115. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  116. glaip_sdk/utils/rendering/layout/panels.py +156 -0
  117. glaip_sdk/utils/rendering/layout/progress.py +202 -0
  118. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  119. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  120. glaip_sdk/utils/rendering/models.py +85 -0
  121. glaip_sdk/utils/rendering/renderer/__init__.py +55 -0
  122. glaip_sdk/utils/rendering/renderer/base.py +1024 -0
  123. glaip_sdk/utils/rendering/renderer/config.py +27 -0
  124. glaip_sdk/utils/rendering/renderer/console.py +55 -0
  125. glaip_sdk/utils/rendering/renderer/debug.py +178 -0
  126. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  127. glaip_sdk/utils/rendering/renderer/stream.py +202 -0
  128. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  129. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  130. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  131. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  132. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  133. glaip_sdk/utils/rendering/state.py +204 -0
  134. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  135. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  136. glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
  137. glaip_sdk/utils/rendering/steps/format.py +176 -0
  138. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  139. glaip_sdk/utils/rendering/timing.py +36 -0
  140. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  141. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  142. glaip_sdk/utils/resource_refs.py +195 -0
  143. glaip_sdk/utils/run_renderer.py +41 -0
  144. glaip_sdk/utils/runtime_config.py +425 -0
  145. glaip_sdk/utils/serialization.py +424 -0
  146. glaip_sdk/utils/sync.py +142 -0
  147. glaip_sdk/utils/tool_detection.py +33 -0
  148. glaip_sdk/utils/validation.py +264 -0
  149. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.15b3.dist-info}/METADATA +1 -1
  150. glaip_sdk-0.6.15b3.dist-info/RECORD +160 -0
  151. glaip_sdk-0.6.15b2.dist-info/RECORD +0 -12
  152. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.15b3.dist-info}/WHEEL +0 -0
  153. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.15b3.dist-info}/entry_points.txt +0 -0
  154. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.15b3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,136 @@
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
+ from typing import Any
10
+
11
+ from glaip_sdk.cli.constants import MASK_SENSITIVE_FIELDS, MASKING_ENABLED
12
+
13
+ __all__ = [
14
+ "mask_payload",
15
+ "mask_rows",
16
+ "_mask_value",
17
+ "_mask_any",
18
+ "_maybe_mask_row",
19
+ "_resolve_mask_fields",
20
+ "mask_api_key_display",
21
+ ]
22
+
23
+
24
+ def _mask_value(raw: Any) -> str:
25
+ """Return a masked representation of the provided value.
26
+
27
+ Args:
28
+ raw: The raw value to mask, converted to string.
29
+
30
+ Returns:
31
+ str: A masked representation showing first 4 and last 4 characters
32
+ separated by dots, or "••••" for strings ≤ 8 characters.
33
+ """
34
+ text = str(raw)
35
+ if len(text) <= 8:
36
+ return "••••"
37
+ return f"{text[:4]}••••••••{text[-4:]}"
38
+
39
+
40
+ def _mask_any(value: Any, mask_fields: set[str]) -> Any:
41
+ """Recursively mask sensitive fields in mappings and iterables.
42
+
43
+ Args:
44
+ value: The value to process - can be dict, list, or any other type.
45
+ mask_fields: Set of field names (lowercase) that should be masked.
46
+
47
+ Returns:
48
+ Any: The processed value with sensitive fields masked. Dicts and lists
49
+ are processed recursively, other values are returned unchanged.
50
+ """
51
+ if isinstance(value, dict):
52
+ masked: dict[Any, Any] = {}
53
+ for key, raw in value.items():
54
+ if isinstance(key, str) and key.lower() in mask_fields and raw is not None:
55
+ masked[key] = _mask_value(raw)
56
+ else:
57
+ masked[key] = _mask_any(raw, mask_fields)
58
+ return masked
59
+
60
+ if isinstance(value, list):
61
+ return [_mask_any(item, mask_fields) for item in value]
62
+
63
+ return value
64
+
65
+
66
+ def _maybe_mask_row(row: dict[str, Any], mask_fields: set[str]) -> dict[str, Any]:
67
+ """Mask a single row when masking is enabled.
68
+
69
+ Args:
70
+ row: A dictionary representing a single row of data.
71
+ mask_fields: Set of field names to mask. If empty, returns row unchanged.
72
+
73
+ Returns:
74
+ dict[str, Any]: The row with sensitive fields masked, or the original
75
+ row if no mask_fields are provided.
76
+ """
77
+ if not mask_fields:
78
+ return row
79
+ return _mask_any(row, mask_fields)
80
+
81
+
82
+ def _resolve_mask_fields() -> set[str]:
83
+ """Return the configured set of fields that should be masked."""
84
+ if not MASKING_ENABLED:
85
+ return set()
86
+ return set(MASK_SENSITIVE_FIELDS)
87
+
88
+
89
+ def mask_payload(payload: Any) -> Any:
90
+ """Mask sensitive values in an arbitrary payload when masking is enabled.
91
+
92
+ Args:
93
+ payload: Any data structure (dict, list, or primitive) to mask.
94
+
95
+ Returns:
96
+ Any: The payload with sensitive fields masked based on configuration.
97
+ """
98
+ mask_fields = _resolve_mask_fields()
99
+ if not mask_fields:
100
+ return payload
101
+ try:
102
+ return _mask_any(payload, mask_fields)
103
+ except Exception:
104
+ return payload
105
+
106
+
107
+ def mask_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
108
+ """Mask sensitive values in row-oriented data when masking is enabled.
109
+
110
+ Args:
111
+ rows: List of dictionaries representing rows of tabular data.
112
+
113
+ Returns:
114
+ list[dict[str, Any]]: List of rows with sensitive fields masked based
115
+ on configuration. Returns original rows if
116
+ masking is disabled or if an error occurs.
117
+ """
118
+ mask_fields = _resolve_mask_fields()
119
+ if not mask_fields:
120
+ return rows
121
+ try:
122
+ return [_maybe_mask_row(row, mask_fields) for row in rows]
123
+ except Exception:
124
+ return rows
125
+
126
+
127
+ def mask_api_key_display(value: str | None) -> str:
128
+ """Mask API keys for CLI display while preserving readability for short keys."""
129
+ if not value:
130
+ return ""
131
+ length = len(value)
132
+ if length <= 4:
133
+ return "***"
134
+ if length <= 8:
135
+ return value[:1] + "••••" + value[-1:]
136
+ return value[:4] + "••••" + value[-4:]
@@ -0,0 +1,287 @@
1
+ """MCP configuration and authentication validation for CLI.
2
+
3
+ This module provides validation functions for MCP config and auth structures
4
+ that are used in CLI commands. It ensures data conforms to the MCP schema
5
+ documented in docs/reference/schemas/mcps.md.
6
+
7
+ Authors:
8
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
9
+ """
10
+
11
+ from typing import Any
12
+ from urllib.parse import urlparse
13
+
14
+ import click
15
+
16
+
17
+ def format_validation_error(prefix: str, detail: str | None = None) -> str:
18
+ r"""Format a validation error message with optional detail.
19
+
20
+ Args:
21
+ prefix: Main error message
22
+ detail: Optional additional detail to append
23
+
24
+ Returns:
25
+ Formatted error message string
26
+
27
+ Examples:
28
+ >>> format_validation_error("Invalid config", "Missing 'url' field")
29
+ "Invalid config\nMissing 'url' field"
30
+ """
31
+ parts = [prefix]
32
+ if detail:
33
+ parts.append(detail)
34
+ return "\n".join(parts)
35
+
36
+
37
+ def validate_mcp_config_structure(
38
+ config: Any, *, transport: str | None = None, source: str = "--config"
39
+ ) -> dict[str, Any]:
40
+ """Validate MCP configuration structure for CLI commands.
41
+
42
+ Validates that the config is a dictionary with a valid 'url' field.
43
+ The 'url' must be an absolute HTTP/HTTPS URL as required by the MCP schema.
44
+
45
+ Args:
46
+ config: Configuration value to validate (expected to be a dict)
47
+ transport: Optional transport type ('http' or 'sse') for context in errors
48
+ source: Source parameter name for error messages (default: "--config")
49
+
50
+ Returns:
51
+ Validated configuration dictionary
52
+
53
+ Raises:
54
+ click.ClickException: If config is not a dict, missing 'url', or URL is invalid
55
+
56
+ Examples:
57
+ >>> validate_mcp_config_structure({"url": "https://api.example.com"})
58
+ {'url': 'https://api.example.com'}
59
+
60
+ >>> validate_mcp_config_structure([1, 2, 3]) # doctest: +SKIP
61
+ ClickException: Invalid --config value
62
+ Expected a JSON object representing MCP configuration.
63
+
64
+ Schema Reference:
65
+ See docs/reference/schemas/mcps.md - Config Object Structure
66
+ - Required field: 'url' (string, must be valid HTTP/HTTPS URL)
67
+ - Additional fields allowed and passed through
68
+ """
69
+ if not isinstance(config, dict):
70
+ raise click.ClickException(
71
+ format_validation_error(
72
+ f"Invalid {source} value",
73
+ "Expected a JSON object representing MCP configuration.",
74
+ )
75
+ )
76
+
77
+ url_value = config.get("url")
78
+ if not isinstance(url_value, str) or not url_value.strip():
79
+ requirement = "Missing required 'url' field with a non-empty string value."
80
+ if transport:
81
+ requirement += f" Required for transport '{transport}'."
82
+ raise click.ClickException(format_validation_error(f"Invalid {source} value", requirement))
83
+
84
+ parsed_url = urlparse(url_value)
85
+ if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
86
+ raise click.ClickException(
87
+ format_validation_error(
88
+ f"Invalid {source} value",
89
+ "'url' must be an absolute HTTP or HTTPS URL.",
90
+ )
91
+ )
92
+
93
+ return config
94
+
95
+
96
+ def _validate_headers_mapping(headers: Any, *, source: str, context: str) -> dict[str, str]:
97
+ """Validate headers mapping for authentication.
98
+
99
+ Args:
100
+ headers: Headers value to validate (expected to be a non-empty dict)
101
+ source: Source parameter name for error messages
102
+ context: Context description for error messages (e.g., "bearer-token authentication")
103
+
104
+ Returns:
105
+ Validated headers dictionary with string keys and values
106
+
107
+ Raises:
108
+ click.ClickException: If headers is not a dict, empty, or contains invalid entries
109
+ """
110
+ if not isinstance(headers, dict) or not headers:
111
+ raise click.ClickException(
112
+ format_validation_error(
113
+ f"Invalid {source} value",
114
+ f"{context} must provide a non-empty 'headers' object with string keys and values.",
115
+ )
116
+ )
117
+
118
+ normalized: dict[str, str] = {}
119
+ for key, value in headers.items():
120
+ if not isinstance(key, str) or not key.strip():
121
+ raise click.ClickException(
122
+ format_validation_error(
123
+ f"Invalid {source} value",
124
+ "Header names must be non-empty strings.",
125
+ )
126
+ )
127
+ if not isinstance(value, str) or not value.strip():
128
+ raise click.ClickException(
129
+ format_validation_error(
130
+ f"Invalid {source} value",
131
+ f"Header '{key}' must have a non-empty string value.",
132
+ )
133
+ )
134
+ normalized[key] = value
135
+ return normalized
136
+
137
+
138
+ def _validate_bearer_token_auth(auth: dict[str, Any], source: str) -> dict[str, Any]:
139
+ """Validate bearer-token authentication.
140
+
141
+ Args:
142
+ auth: Authentication dictionary
143
+ source: Source parameter name for error messages
144
+
145
+ Returns:
146
+ Validated bearer-token authentication dictionary
147
+
148
+ Raises:
149
+ click.ClickException: If bearer-token structure is invalid
150
+ """
151
+ token = auth.get("token")
152
+ if isinstance(token, str) and token.strip():
153
+ return {"type": "bearer-token", "token": token}
154
+ headers = auth.get("headers")
155
+ normalized_headers = _validate_headers_mapping(headers, source=source, context="bearer-token authentication")
156
+ return {"type": "bearer-token", "headers": normalized_headers}
157
+
158
+
159
+ def _validate_api_key_auth(auth: dict[str, Any], source: str) -> dict[str, Any]:
160
+ """Validate api-key authentication.
161
+
162
+ Args:
163
+ auth: Authentication dictionary
164
+ source: Source parameter name for error messages
165
+
166
+ Returns:
167
+ Validated api-key authentication dictionary
168
+
169
+ Raises:
170
+ click.ClickException: If api-key structure is invalid
171
+ """
172
+ headers = auth.get("headers")
173
+ if headers is not None:
174
+ normalized_headers = _validate_headers_mapping(headers, source=source, context="api-key authentication")
175
+ return {"type": "api-key", "headers": normalized_headers}
176
+
177
+ key = auth.get("key")
178
+ value = auth.get("value")
179
+ if not isinstance(key, str) or not key.strip():
180
+ raise click.ClickException(
181
+ format_validation_error(
182
+ f"Invalid {source} value",
183
+ "api-key authentication requires a non-empty 'key'.",
184
+ )
185
+ )
186
+ if not isinstance(value, str) or not value.strip():
187
+ raise click.ClickException(
188
+ format_validation_error(
189
+ f"Invalid {source} value",
190
+ "api-key authentication requires a non-empty 'value'.",
191
+ )
192
+ )
193
+ return {"type": "api-key", "key": key, "value": value}
194
+
195
+
196
+ def _validate_custom_header_auth(auth: dict[str, Any], source: str) -> dict[str, Any]:
197
+ """Validate custom-header authentication.
198
+
199
+ Args:
200
+ auth: Authentication dictionary
201
+ source: Source parameter name for error messages
202
+
203
+ Returns:
204
+ Validated custom-header authentication dictionary
205
+
206
+ Raises:
207
+ click.ClickException: If custom-header structure is invalid
208
+ """
209
+ headers = auth.get("headers")
210
+ normalized_headers = _validate_headers_mapping(headers, source=source, context="custom-header authentication")
211
+ return {"type": "custom-header", "headers": normalized_headers}
212
+
213
+
214
+ def validate_mcp_auth_structure(auth: Any, *, source: str = "--auth") -> dict[str, Any]:
215
+ """Validate MCP authentication structure for CLI commands.
216
+
217
+ Validates authentication objects according to the MCP schema, supporting:
218
+ - no-auth: No authentication required
219
+ - bearer-token: Bearer token via 'token' field or 'headers'
220
+ - api-key: API key via 'key'/'value' fields or 'headers'
221
+ - custom-header: Custom headers via 'headers' object
222
+
223
+ Args:
224
+ auth: Authentication value to validate (expected to be a dict or None)
225
+ source: Source parameter name for error messages (default: "--auth")
226
+
227
+ Returns:
228
+ Validated authentication dictionary, or empty dict if auth is None
229
+
230
+ Raises:
231
+ click.ClickException: If auth structure is invalid or type is unsupported
232
+
233
+ Examples:
234
+ >>> validate_mcp_auth_structure(None)
235
+ {}
236
+
237
+ >>> validate_mcp_auth_structure({"type": "no-auth"})
238
+ {'type': 'no-auth'}
239
+
240
+ >>> validate_mcp_auth_structure({"type": "bearer-token", "token": "abc123"})
241
+ {'type': 'bearer-token', 'token': 'abc123'}
242
+
243
+ Schema Reference:
244
+ See docs/reference/schemas/mcps.md - Authentication Types
245
+ - Required field: 'type' (string, one of: no-auth, bearer-token, api-key, custom-header)
246
+ - Additional fields depend on type
247
+ """
248
+ if auth is None:
249
+ return {}
250
+
251
+ if not isinstance(auth, dict):
252
+ raise click.ClickException(
253
+ format_validation_error(
254
+ f"Invalid {source} value",
255
+ "Expected a JSON object representing MCP authentication.",
256
+ )
257
+ )
258
+
259
+ raw_type = auth.get("type")
260
+ if not isinstance(raw_type, str) or not raw_type.strip():
261
+ raise click.ClickException(
262
+ format_validation_error(
263
+ f"Invalid {source} value",
264
+ "Authentication objects must include a non-empty 'type' field.",
265
+ )
266
+ )
267
+
268
+ auth_type = raw_type.strip()
269
+
270
+ # Dispatch to type-specific validators
271
+ if auth_type == "no-auth":
272
+ return {"type": "no-auth"}
273
+ if auth_type == "bearer-token":
274
+ return _validate_bearer_token_auth(auth, source)
275
+ if auth_type == "api-key":
276
+ return _validate_api_key_auth(auth, source)
277
+ if auth_type == "custom-header":
278
+ return _validate_custom_header_auth(auth, source)
279
+
280
+ # Unknown type
281
+ raise click.ClickException(
282
+ format_validation_error(
283
+ f"Invalid {source} value",
284
+ f"Unsupported authentication type '{auth_type}'. "
285
+ f"Supported types: no-auth, bearer-token, api-key, custom-header",
286
+ )
287
+ )
glaip_sdk/cli/pager.py ADDED
@@ -0,0 +1,266 @@
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 importlib
10
+ import io
11
+ import os
12
+ import platform
13
+ import shlex
14
+ import shutil
15
+ import subprocess
16
+ import tempfile
17
+ from collections.abc import Callable
18
+ from typing import Any
19
+
20
+ from rich.console import Console
21
+
22
+ from glaip_sdk.cli.constants import PAGER_HEADER_ENABLED, PAGER_MODE, PAGER_WRAP_LINES
23
+
24
+ __all__ = [
25
+ "console",
26
+ "_prepare_pager_env",
27
+ "_render_ansi",
28
+ "_pager_header",
29
+ "_should_use_pager",
30
+ "_resolve_pager_command",
31
+ "_run_less_pager",
32
+ "_run_more_pager",
33
+ "_run_pager_with_temp_file",
34
+ "_page_with_system_pager",
35
+ "_should_page_output",
36
+ ]
37
+
38
+ console: Console | None = None
39
+
40
+
41
+ def _get_console() -> Console:
42
+ """Return the active console instance.
43
+
44
+ Returns:
45
+ Console: The active Rich console instance
46
+ """
47
+ global console
48
+ try:
49
+ cli_utils = importlib.import_module("glaip_sdk.cli.utils")
50
+ except Exception: # pragma: no cover - fallback during import cycles
51
+ cli_utils = None
52
+
53
+ current_console = getattr(cli_utils, "console", None) if cli_utils else None
54
+ if current_console is not None and current_console is not console:
55
+ console = current_console
56
+
57
+ if console is None:
58
+ console = Console()
59
+ return console
60
+
61
+
62
+ def _prepare_pager_env(clear_on_exit: bool = True) -> None:
63
+ """Configure LESS flags for a predictable, high-quality UX.
64
+
65
+ Sets sensible defaults for the system pager:
66
+ -R : pass ANSI color escapes
67
+ -S : chop long lines (horizontal scroll with ←/→)
68
+ (No -F, no -X) so we open a full-screen pager and clear on exit.
69
+ Toggle wrapping via `PAGER_WRAP_LINES` (True drops -S).
70
+
71
+ Args:
72
+ clear_on_exit: Whether to clear the pager on exit (default: True)
73
+
74
+ Returns:
75
+ None
76
+ """
77
+ os.environ.pop("LESSSECURE", None)
78
+ if os.getenv("LESS") is None:
79
+ base = "-R" if PAGER_WRAP_LINES else "-RS"
80
+ default_flags = base if clear_on_exit else (base + "FX")
81
+ os.environ["LESS"] = default_flags
82
+
83
+
84
+ def _render_ansi(renderable: Any) -> str:
85
+ """Render a Rich renderable to an ANSI string suitable for piping to 'less'.
86
+
87
+ Args:
88
+ renderable: Any Rich-compatible renderable object
89
+
90
+ Returns:
91
+ str: ANSI string representation of the renderable
92
+ """
93
+ active_console = _get_console()
94
+ buf = io.StringIO()
95
+ tmp_console = Console(
96
+ file=buf,
97
+ force_terminal=True,
98
+ color_system=active_console.color_system or "auto",
99
+ width=active_console.size.width or 100,
100
+ legacy_windows=False,
101
+ soft_wrap=False,
102
+ record=False,
103
+ )
104
+ tmp_console.print(renderable)
105
+ return buf.getvalue()
106
+
107
+
108
+ def _pager_header() -> str:
109
+ """Generate pager header with navigation instructions.
110
+
111
+ Returns:
112
+ str: Header text containing navigation help, or empty string if disabled
113
+ """
114
+ if not PAGER_HEADER_ENABLED:
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(pager_cmd: list[str] | None, less_path: str | None, tmp_path: str) -> None:
159
+ """Run less pager with appropriate command and flags.
160
+
161
+ Args:
162
+ pager_cmd: Custom pager command parts if PAGER is set to less, None otherwise
163
+ less_path: Path to the less executable, None if not found
164
+ tmp_path: Path to temporary file containing content to display
165
+
166
+ Returns:
167
+ None
168
+ """
169
+ if pager_cmd:
170
+ subprocess.run([*pager_cmd, tmp_path], check=False)
171
+ else:
172
+ flags = os.getenv("LESS", "-RS").split()
173
+ subprocess.run([less_path, *flags, tmp_path], check=False)
174
+
175
+
176
+ def _run_more_pager(tmp_path: str) -> None:
177
+ """Run more pager as fallback.
178
+
179
+ Args:
180
+ tmp_path: Path to temporary file containing content to display
181
+
182
+ Returns:
183
+ None
184
+
185
+ Raises:
186
+ FileNotFoundError: If more command is not found
187
+ """
188
+ more_path = shutil.which("more")
189
+ if more_path:
190
+ subprocess.run([more_path, tmp_path], check=False)
191
+ else:
192
+ raise FileNotFoundError("more command not found")
193
+
194
+
195
+ def _run_pager_with_temp_file(pager_runner: Callable[[str], None], ansi_text: str) -> bool:
196
+ """Run a pager using a temporary file containing the content.
197
+
198
+ Args:
199
+ pager_runner: Function that takes a temp file path and runs the pager
200
+ ansi_text: ANSI-formatted text content to display
201
+
202
+ Returns:
203
+ bool: True if pager executed successfully, False if there was an exception
204
+ """
205
+ _prepare_pager_env(clear_on_exit=True)
206
+ with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tmp:
207
+ tmp.write(_pager_header())
208
+ tmp.write(ansi_text)
209
+ tmp_path = tmp.name
210
+ try:
211
+ pager_runner(tmp_path)
212
+ return True
213
+ except Exception:
214
+ return False
215
+ finally:
216
+ try:
217
+ os.unlink(tmp_path)
218
+ except Exception:
219
+ pass
220
+
221
+
222
+ def _page_with_system_pager(ansi_text: str) -> bool:
223
+ """Prefer 'less' with a temp file so stdin remains the TTY.
224
+
225
+ Args:
226
+ ansi_text: ANSI-formatted text content to display in the pager
227
+
228
+ Returns:
229
+ bool: True if pager was executed successfully, False otherwise
230
+ """
231
+ if not _should_use_pager():
232
+ return False
233
+
234
+ pager_cmd, less_path = _resolve_pager_command()
235
+
236
+ if pager_cmd or less_path:
237
+ return _run_pager_with_temp_file(lambda tmp_path: _run_less_pager(pager_cmd, less_path, tmp_path), ansi_text)
238
+
239
+ if platform.system().lower().startswith("win"):
240
+ return False
241
+
242
+ return _run_pager_with_temp_file(_run_more_pager, ansi_text)
243
+
244
+
245
+ def _should_page_output(row_count: int, is_tty: bool) -> bool:
246
+ """Determine if output should be paginated based on content size and terminal.
247
+
248
+ Args:
249
+ row_count: Number of rows in the content to display
250
+ is_tty: Whether the output is going to a terminal
251
+
252
+ Returns:
253
+ bool: True if output should be paginated, False otherwise
254
+ """
255
+ active_console = _get_console()
256
+ pager_mode = (PAGER_MODE or "auto").lower()
257
+ if pager_mode in ("0", "off", "false"):
258
+ return False
259
+ if pager_mode in ("1", "on", "true"):
260
+ return is_tty
261
+ try:
262
+ term_h = active_console.size.height or 24
263
+ approx_lines = 5 + row_count
264
+ return is_tty and (approx_lines >= term_h * 0.5)
265
+ except Exception:
266
+ return is_tty
@@ -0,0 +1,7 @@
1
+ """CLI input parsers.
2
+
3
+ Authors:
4
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
5
+ """
6
+
7
+ __all__: list[str] = []