glaip-sdk 0.0.10__py3-none-any.whl → 0.0.12__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.
@@ -0,0 +1,297 @@
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
+ """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(
83
+ format_validation_error(f"Invalid {source} value", requirement)
84
+ )
85
+
86
+ parsed_url = urlparse(url_value)
87
+ if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
88
+ raise click.ClickException(
89
+ format_validation_error(
90
+ f"Invalid {source} value",
91
+ "'url' must be an absolute HTTP or HTTPS URL.",
92
+ )
93
+ )
94
+
95
+ return config
96
+
97
+
98
+ def _validate_headers_mapping(
99
+ headers: Any, *, source: str, context: str
100
+ ) -> dict[str, str]:
101
+ """Validate headers mapping for authentication.
102
+
103
+ Args:
104
+ headers: Headers value to validate (expected to be a non-empty dict)
105
+ source: Source parameter name for error messages
106
+ context: Context description for error messages (e.g., "bearer-token authentication")
107
+
108
+ Returns:
109
+ Validated headers dictionary with string keys and values
110
+
111
+ Raises:
112
+ click.ClickException: If headers is not a dict, empty, or contains invalid entries
113
+ """
114
+ if not isinstance(headers, dict) or not headers:
115
+ raise click.ClickException(
116
+ format_validation_error(
117
+ f"Invalid {source} value",
118
+ f"{context} must provide a non-empty 'headers' object with string keys and values.",
119
+ )
120
+ )
121
+
122
+ normalized: dict[str, str] = {}
123
+ for key, value in headers.items():
124
+ if not isinstance(key, str) or not key.strip():
125
+ raise click.ClickException(
126
+ format_validation_error(
127
+ f"Invalid {source} value",
128
+ "Header names must be non-empty strings.",
129
+ )
130
+ )
131
+ if not isinstance(value, str) or not value.strip():
132
+ raise click.ClickException(
133
+ format_validation_error(
134
+ f"Invalid {source} value",
135
+ f"Header '{key}' must have a non-empty string value.",
136
+ )
137
+ )
138
+ normalized[key] = value
139
+ return normalized
140
+
141
+
142
+ def _validate_bearer_token_auth(auth: dict[str, Any], source: str) -> dict[str, Any]:
143
+ """Validate bearer-token authentication.
144
+
145
+ Args:
146
+ auth: Authentication dictionary
147
+ source: Source parameter name for error messages
148
+
149
+ Returns:
150
+ Validated bearer-token authentication dictionary
151
+
152
+ Raises:
153
+ click.ClickException: If bearer-token structure is invalid
154
+ """
155
+ token = auth.get("token")
156
+ if isinstance(token, str) and token.strip():
157
+ return {"type": "bearer-token", "token": token}
158
+ headers = auth.get("headers")
159
+ normalized_headers = _validate_headers_mapping(
160
+ headers, source=source, context="bearer-token authentication"
161
+ )
162
+ return {"type": "bearer-token", "headers": normalized_headers}
163
+
164
+
165
+ def _validate_api_key_auth(auth: dict[str, Any], source: str) -> dict[str, Any]:
166
+ """Validate api-key authentication.
167
+
168
+ Args:
169
+ auth: Authentication dictionary
170
+ source: Source parameter name for error messages
171
+
172
+ Returns:
173
+ Validated api-key authentication dictionary
174
+
175
+ Raises:
176
+ click.ClickException: If api-key structure is invalid
177
+ """
178
+ headers = auth.get("headers")
179
+ if headers is not None:
180
+ normalized_headers = _validate_headers_mapping(
181
+ headers, source=source, context="api-key authentication"
182
+ )
183
+ return {"type": "api-key", "headers": normalized_headers}
184
+
185
+ key = auth.get("key")
186
+ value = auth.get("value")
187
+ if not isinstance(key, str) or not key.strip():
188
+ raise click.ClickException(
189
+ format_validation_error(
190
+ f"Invalid {source} value",
191
+ "api-key authentication requires a non-empty 'key'.",
192
+ )
193
+ )
194
+ if not isinstance(value, str) or not value.strip():
195
+ raise click.ClickException(
196
+ format_validation_error(
197
+ f"Invalid {source} value",
198
+ "api-key authentication requires a non-empty 'value'.",
199
+ )
200
+ )
201
+ return {"type": "api-key", "key": key, "value": value}
202
+
203
+
204
+ def _validate_custom_header_auth(auth: dict[str, Any], source: str) -> dict[str, Any]:
205
+ """Validate custom-header authentication.
206
+
207
+ Args:
208
+ auth: Authentication dictionary
209
+ source: Source parameter name for error messages
210
+
211
+ Returns:
212
+ Validated custom-header authentication dictionary
213
+
214
+ Raises:
215
+ click.ClickException: If custom-header structure is invalid
216
+ """
217
+ headers = auth.get("headers")
218
+ normalized_headers = _validate_headers_mapping(
219
+ headers, source=source, context="custom-header authentication"
220
+ )
221
+ return {"type": "custom-header", "headers": normalized_headers}
222
+
223
+
224
+ def validate_mcp_auth_structure(auth: Any, *, source: str = "--auth") -> dict[str, Any]:
225
+ """Validate MCP authentication structure for CLI commands.
226
+
227
+ Validates authentication objects according to the MCP schema, supporting:
228
+ - no-auth: No authentication required
229
+ - bearer-token: Bearer token via 'token' field or 'headers'
230
+ - api-key: API key via 'key'/'value' fields or 'headers'
231
+ - custom-header: Custom headers via 'headers' object
232
+
233
+ Args:
234
+ auth: Authentication value to validate (expected to be a dict or None)
235
+ source: Source parameter name for error messages (default: "--auth")
236
+
237
+ Returns:
238
+ Validated authentication dictionary, or empty dict if auth is None
239
+
240
+ Raises:
241
+ click.ClickException: If auth structure is invalid or type is unsupported
242
+
243
+ Examples:
244
+ >>> validate_mcp_auth_structure(None)
245
+ {}
246
+
247
+ >>> validate_mcp_auth_structure({"type": "no-auth"})
248
+ {'type': 'no-auth'}
249
+
250
+ >>> validate_mcp_auth_structure({"type": "bearer-token", "token": "abc123"})
251
+ {'type': 'bearer-token', 'token': 'abc123'}
252
+
253
+ Schema Reference:
254
+ See docs/reference/schemas/mcps.md - Authentication Types
255
+ - Required field: 'type' (string, one of: no-auth, bearer-token, api-key, custom-header)
256
+ - Additional fields depend on type
257
+ """
258
+ if auth is None:
259
+ return {}
260
+
261
+ if not isinstance(auth, dict):
262
+ raise click.ClickException(
263
+ format_validation_error(
264
+ f"Invalid {source} value",
265
+ "Expected a JSON object representing MCP authentication.",
266
+ )
267
+ )
268
+
269
+ raw_type = auth.get("type")
270
+ if not isinstance(raw_type, str) or not raw_type.strip():
271
+ raise click.ClickException(
272
+ format_validation_error(
273
+ f"Invalid {source} value",
274
+ "Authentication objects must include a non-empty 'type' field.",
275
+ )
276
+ )
277
+
278
+ auth_type = raw_type.strip()
279
+
280
+ # Dispatch to type-specific validators
281
+ if auth_type == "no-auth":
282
+ return {"type": "no-auth"}
283
+ if auth_type == "bearer-token":
284
+ return _validate_bearer_token_auth(auth, source)
285
+ if auth_type == "api-key":
286
+ return _validate_api_key_auth(auth, source)
287
+ if auth_type == "custom-header":
288
+ return _validate_custom_header_auth(auth, source)
289
+
290
+ # Unknown type
291
+ raise click.ClickException(
292
+ format_validation_error(
293
+ f"Invalid {source} value",
294
+ f"Unsupported authentication type '{auth_type}'. "
295
+ f"Supported types: no-auth, bearer-token, api-key, custom-header",
296
+ )
297
+ )
@@ -0,0 +1,9 @@
1
+ """CLI input parsers.
2
+
3
+ Authors:
4
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
5
+ """
6
+
7
+ from glaip_sdk.cli.parsers.json_input import parse_json_input
8
+
9
+ __all__ = ["parse_json_input"]
@@ -0,0 +1,140 @@
1
+ """JSON input parser for CLI options.
2
+
3
+ Handles both inline JSON strings and @file references.
4
+
5
+ Authors:
6
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
7
+ """
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import click
15
+
16
+
17
+ def _format_file_error(
18
+ prefix: str, file_path_str: str, resolved_path: Path, *, detail: str | None = None
19
+ ) -> str:
20
+ """Format a file-related error message with path context.
21
+
22
+ Args:
23
+ prefix: Main error message
24
+ file_path_str: Original file path string provided by user
25
+ resolved_path: Resolved absolute path
26
+ detail: Optional additional detail to append
27
+
28
+ Returns:
29
+ Formatted error message string with file path context
30
+
31
+ Examples:
32
+ >>> from pathlib import Path
33
+ >>> _format_file_error("File not found", "config.json", Path("/abs/config.json"))
34
+ 'File not found: config.json\\nResolved path: /abs/config.json'
35
+ """
36
+ parts = [f"{prefix}: {file_path_str}", f"Resolved path: {resolved_path}"]
37
+ if detail:
38
+ parts.append(detail)
39
+ return "\n".join(parts)
40
+
41
+
42
+ def _parse_json_from_file(file_path_str: str) -> Any:
43
+ """Parse JSON from a file path.
44
+
45
+ Args:
46
+ file_path_str: Path to the JSON file (without @ prefix).
47
+
48
+ Returns:
49
+ Parsed dictionary from file.
50
+
51
+ Raises:
52
+ click.ClickException: If file not found, not readable, empty, or invalid JSON.
53
+ """
54
+ # Resolve relative paths against CWD
55
+ file_path = Path(file_path_str)
56
+ if not file_path.is_absolute():
57
+ file_path = Path.cwd() / file_path
58
+
59
+ # Check if file exists and is a regular file
60
+ if not file_path.is_file():
61
+ raise click.ClickException(
62
+ _format_file_error("File not found or not a file", file_path_str, file_path)
63
+ )
64
+
65
+ # Check if file is readable
66
+ if not os.access(file_path, os.R_OK):
67
+ raise click.ClickException(
68
+ _format_file_error(
69
+ "File not readable (permission denied)", file_path_str, file_path
70
+ )
71
+ )
72
+
73
+ # Read file content
74
+ try:
75
+ content = file_path.read_text(encoding="utf-8")
76
+ except Exception as e:
77
+ raise click.ClickException(
78
+ _format_file_error(
79
+ "Error reading file", file_path_str, file_path, detail=f"Error: {e}"
80
+ )
81
+ )
82
+
83
+ # Check for empty content
84
+ if not content.strip():
85
+ raise click.ClickException(
86
+ _format_file_error("File is empty", file_path_str, file_path)
87
+ )
88
+
89
+ # Parse JSON from file content
90
+ try:
91
+ return json.loads(content)
92
+ except json.JSONDecodeError as e:
93
+ raise click.ClickException(
94
+ _format_file_error(
95
+ "Invalid JSON in file",
96
+ file_path_str,
97
+ file_path,
98
+ detail=f"Error: {e.msg} at line {e.lineno}, column {e.colno}",
99
+ )
100
+ )
101
+
102
+
103
+ def parse_json_input(value: str | None) -> Any:
104
+ """Parse JSON input from inline string or file reference.
105
+
106
+ Args:
107
+ value: JSON string or @file reference. If None, returns None.
108
+
109
+ Returns:
110
+ Parsed JSON value (dict, list, str, int, float, bool, None) or None if value is None.
111
+
112
+ Raises:
113
+ click.ClickException: If file not found, not readable, empty, or invalid JSON.
114
+
115
+ Examples:
116
+ >>> parse_json_input('{"key": "value"}')
117
+ {'key': 'value'}
118
+
119
+ >>> parse_json_input('@/path/to/config.json')
120
+ # Returns content of config.json parsed as JSON
121
+
122
+ >>> parse_json_input(None)
123
+ None
124
+ """
125
+ if value is None:
126
+ return None
127
+
128
+ # Check if value is a file reference (strip whitespace first)
129
+ trimmed = value.strip()
130
+ if trimmed.startswith("@"):
131
+ return _parse_json_from_file(trimmed[1:])
132
+
133
+ # Parse inline JSON
134
+ try:
135
+ return json.loads(value)
136
+ except json.JSONDecodeError as e:
137
+ raise click.ClickException(
138
+ f"Invalid JSON in inline value\n"
139
+ f"Error: {e.msg} at line {e.lineno}, column {e.colno}"
140
+ )
@@ -76,6 +76,8 @@ class SlashSession:
76
76
  self._setup_prompt_toolkit()
77
77
  self._register_defaults()
78
78
  self._branding = AIPBranding.create_from_sdk()
79
+ self._suppress_login_layout = False
80
+ self._default_actions_shown = False
79
81
 
80
82
  # ------------------------------------------------------------------
81
83
  # Session orchestration
@@ -96,7 +98,9 @@ class SlashSession:
96
98
  if not self._ensure_configuration():
97
99
  return
98
100
 
99
- self._render_header(initial=True)
101
+ self._render_header(initial=not self._welcome_rendered)
102
+ if not self._default_actions_shown:
103
+ self._show_default_quick_actions()
100
104
  self._render_home_hint()
101
105
  self._run_interactive_loop()
102
106
 
@@ -154,6 +158,7 @@ class SlashSession:
154
158
  self.console.print(
155
159
  "[yellow]Configuration required.[/] Launching `/login` wizard..."
156
160
  )
161
+ self._suppress_login_layout = True
157
162
  try:
158
163
  self._cmd_login([], False)
159
164
  except KeyboardInterrupt:
@@ -161,6 +166,8 @@ class SlashSession:
161
166
  "[red]Configuration aborted. Closing the command palette.[/red]"
162
167
  )
163
168
  return False
169
+ finally:
170
+ self._suppress_login_layout = False
164
171
 
165
172
  return True
166
173
 
@@ -260,13 +267,12 @@ class SlashSession:
260
267
  try:
261
268
  self.ctx.invoke(configure_command)
262
269
  self._config_cache = None
263
- self._render_header(initial=True)
264
- self._show_quick_actions(
265
- [
266
- (self.STATUS_COMMAND, "Verify the connection"),
267
- (self.AGENTS_COMMAND, "Pick an agent to inspect or run"),
268
- ]
269
- )
270
+ if self._suppress_login_layout:
271
+ self._welcome_rendered = False
272
+ self._default_actions_shown = False
273
+ else:
274
+ self._render_header(initial=True)
275
+ self._show_default_quick_actions()
270
276
  except click.ClickException as exc:
271
277
  self.console.print(f"[red]{exc}[/red]")
272
278
  return True
@@ -349,7 +355,7 @@ class SlashSession:
349
355
  # running.
350
356
  return True
351
357
 
352
- self.console.print("[cyan]Closing the command palette.")
358
+ self.console.print("[cyan]Closing the command palette.[/cyan]")
353
359
  return False
354
360
 
355
361
  # ------------------------------------------------------------------
@@ -772,6 +778,15 @@ class SlashSession:
772
778
  label = recent.get("name") or recent.get("id") or "-"
773
779
  lines.append(f"[dim]Recent agent[/dim]: {label} [{recent.get('id', '-')}]")
774
780
 
781
+ def _show_default_quick_actions(self) -> None:
782
+ self._show_quick_actions(
783
+ [
784
+ (self.STATUS_COMMAND, "Verify the connection"),
785
+ (self.AGENTS_COMMAND, "Pick an agent to inspect or run"),
786
+ ]
787
+ )
788
+ self._default_actions_shown = True
789
+
775
790
  def _render_home_hint(self) -> None:
776
791
  self.console.print(
777
792
  AIPPanel(
@@ -0,0 +1,107 @@
1
+ """Utility helpers for checking and displaying SDK update notifications.
2
+
3
+ Author:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from collections.abc import Callable
11
+
12
+ import httpx
13
+ from packaging.version import InvalidVersion, Version
14
+ from rich.console import Console
15
+
16
+ from glaip_sdk.rich_components import AIPPanel
17
+
18
+ FetchLatestVersion = Callable[[], str | None]
19
+
20
+ PYPI_JSON_URL = "https://pypi.org/pypi/{package}/json"
21
+ DEFAULT_TIMEOUT = 1.5 # seconds
22
+
23
+
24
+ def _parse_version(value: str) -> Version | None:
25
+ """Parse a version string into a `Version`, returning None on failure."""
26
+ try:
27
+ return Version(value)
28
+ except InvalidVersion:
29
+ return None
30
+
31
+
32
+ def _fetch_latest_version(package_name: str) -> str | None:
33
+ """Fetch the latest published version from PyPI."""
34
+ url = PYPI_JSON_URL.format(package=package_name)
35
+ timeout = httpx.Timeout(DEFAULT_TIMEOUT)
36
+
37
+ try:
38
+ with httpx.Client(timeout=timeout) as client:
39
+ response = client.get(url, headers={"Accept": "application/json"})
40
+ response.raise_for_status()
41
+ payload = response.json()
42
+ except httpx.HTTPError:
43
+ return None
44
+ except ValueError:
45
+ return None
46
+
47
+ info = payload.get("info") if isinstance(payload, dict) else None
48
+ latest_version = info.get("version") if isinstance(info, dict) else None
49
+ if isinstance(latest_version, str) and latest_version.strip():
50
+ return latest_version.strip()
51
+ return None
52
+
53
+
54
+ def _should_check_for_updates() -> bool:
55
+ """Return False when update checks are explicitly disabled."""
56
+ return os.getenv("AIP_NO_UPDATE_CHECK") is None
57
+
58
+
59
+ def _build_update_panel(
60
+ current_version: str,
61
+ latest_version: str,
62
+ ) -> AIPPanel:
63
+ """Create a Rich panel that prompts the user to update."""
64
+ message = (
65
+ f"[bold yellow]✨ Update available![/bold yellow] "
66
+ f"{current_version} → {latest_version}\n\n"
67
+ "See the latest release notes:\n"
68
+ f"https://pypi.org/project/glaip-sdk/{latest_version}/\n\n"
69
+ "[cyan]Run[/cyan] [bold]aip update[/bold] to install."
70
+ )
71
+ return AIPPanel(
72
+ message,
73
+ title="[bold green]AIP SDK Update[/bold green]",
74
+ )
75
+
76
+
77
+ def maybe_notify_update(
78
+ current_version: str,
79
+ *,
80
+ package_name: str = "glaip-sdk",
81
+ console: Console | None = None,
82
+ fetch_latest_version: FetchLatestVersion | None = None,
83
+ ) -> None:
84
+ """Check PyPI for a newer version and display a prompt if one exists.
85
+
86
+ This function deliberately swallows network errors to avoid impacting CLI
87
+ startup time when offline or when PyPI is unavailable.
88
+ """
89
+ if not _should_check_for_updates():
90
+ return
91
+
92
+ fetcher = fetch_latest_version or (lambda: _fetch_latest_version(package_name))
93
+ latest_version = fetcher()
94
+ if not latest_version:
95
+ return
96
+
97
+ current = _parse_version(current_version)
98
+ latest = _parse_version(latest_version)
99
+ if current is None or latest is None or latest <= current:
100
+ return
101
+
102
+ active_console = console or Console()
103
+ panel = _build_update_panel(current_version, latest_version)
104
+ active_console.print(panel)
105
+
106
+
107
+ __all__ = ["maybe_notify_update"]