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.
- glaip_sdk/cli/auth.py +414 -0
- glaip_sdk/cli/commands/agents.py +25 -3
- glaip_sdk/cli/commands/mcps.py +356 -113
- glaip_sdk/cli/main.py +8 -0
- glaip_sdk/cli/mcp_validators.py +297 -0
- glaip_sdk/cli/parsers/__init__.py +9 -0
- glaip_sdk/cli/parsers/json_input.py +140 -0
- glaip_sdk/cli/slash/session.py +24 -9
- glaip_sdk/cli/update_notifier.py +107 -0
- glaip_sdk/cli/utils.py +23 -8
- glaip_sdk/client/mcps.py +3 -3
- glaip_sdk/models.py +0 -2
- glaip_sdk/utils/import_export.py +3 -7
- glaip_sdk/utils/serialization.py +93 -2
- {glaip_sdk-0.0.10.dist-info → glaip_sdk-0.0.12.dist-info}/METADATA +2 -1
- {glaip_sdk-0.0.10.dist-info → glaip_sdk-0.0.12.dist-info}/RECORD +18 -13
- {glaip_sdk-0.0.10.dist-info → glaip_sdk-0.0.12.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.0.10.dist-info → glaip_sdk-0.0.12.dist-info}/entry_points.txt +0 -0
|
@@ -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,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
|
+
)
|
glaip_sdk/cli/slash/session.py
CHANGED
|
@@ -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=
|
|
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.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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"]
|