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.
- glaip_sdk/agents/__init__.py +27 -0
- glaip_sdk/agents/base.py +1196 -0
- glaip_sdk/cli/__init__.py +9 -0
- glaip_sdk/cli/account_store.py +540 -0
- glaip_sdk/cli/agent_config.py +78 -0
- glaip_sdk/cli/auth.py +699 -0
- glaip_sdk/cli/commands/__init__.py +5 -0
- glaip_sdk/cli/commands/accounts.py +746 -0
- glaip_sdk/cli/commands/agents.py +1509 -0
- glaip_sdk/cli/commands/common_config.py +104 -0
- glaip_sdk/cli/commands/configure.py +896 -0
- glaip_sdk/cli/commands/mcps.py +1356 -0
- glaip_sdk/cli/commands/models.py +69 -0
- glaip_sdk/cli/commands/tools.py +576 -0
- glaip_sdk/cli/commands/transcripts.py +755 -0
- glaip_sdk/cli/commands/update.py +61 -0
- glaip_sdk/cli/config.py +95 -0
- glaip_sdk/cli/constants.py +38 -0
- glaip_sdk/cli/context.py +150 -0
- glaip_sdk/cli/core/__init__.py +79 -0
- glaip_sdk/cli/core/context.py +124 -0
- glaip_sdk/cli/core/output.py +851 -0
- glaip_sdk/cli/core/prompting.py +649 -0
- glaip_sdk/cli/core/rendering.py +187 -0
- glaip_sdk/cli/display.py +355 -0
- glaip_sdk/cli/hints.py +57 -0
- glaip_sdk/cli/io.py +112 -0
- glaip_sdk/cli/main.py +615 -0
- glaip_sdk/cli/masking.py +136 -0
- glaip_sdk/cli/mcp_validators.py +287 -0
- glaip_sdk/cli/pager.py +266 -0
- glaip_sdk/cli/parsers/__init__.py +7 -0
- glaip_sdk/cli/parsers/json_input.py +177 -0
- glaip_sdk/cli/resolution.py +67 -0
- glaip_sdk/cli/rich_helpers.py +27 -0
- glaip_sdk/cli/slash/__init__.py +15 -0
- glaip_sdk/cli/slash/accounts_controller.py +578 -0
- glaip_sdk/cli/slash/accounts_shared.py +75 -0
- glaip_sdk/cli/slash/agent_session.py +285 -0
- glaip_sdk/cli/slash/prompt.py +256 -0
- glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
- glaip_sdk/cli/slash/session.py +1708 -0
- glaip_sdk/cli/slash/tui/__init__.py +9 -0
- glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
- glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
- glaip_sdk/cli/slash/tui/loading.py +58 -0
- glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
- glaip_sdk/cli/transcript/__init__.py +31 -0
- glaip_sdk/cli/transcript/cache.py +536 -0
- glaip_sdk/cli/transcript/capture.py +329 -0
- glaip_sdk/cli/transcript/export.py +38 -0
- glaip_sdk/cli/transcript/history.py +815 -0
- glaip_sdk/cli/transcript/launcher.py +77 -0
- glaip_sdk/cli/transcript/viewer.py +374 -0
- glaip_sdk/cli/update_notifier.py +290 -0
- glaip_sdk/cli/utils.py +263 -0
- glaip_sdk/cli/validators.py +238 -0
- glaip_sdk/client/__init__.py +11 -0
- glaip_sdk/client/_agent_payloads.py +520 -0
- glaip_sdk/client/agent_runs.py +147 -0
- glaip_sdk/client/agents.py +1335 -0
- glaip_sdk/client/base.py +502 -0
- glaip_sdk/client/main.py +249 -0
- glaip_sdk/client/mcps.py +370 -0
- glaip_sdk/client/run_rendering.py +700 -0
- glaip_sdk/client/shared.py +21 -0
- glaip_sdk/client/tools.py +661 -0
- glaip_sdk/client/validators.py +198 -0
- glaip_sdk/config/constants.py +52 -0
- glaip_sdk/mcps/__init__.py +21 -0
- glaip_sdk/mcps/base.py +345 -0
- glaip_sdk/models/__init__.py +90 -0
- glaip_sdk/models/agent.py +47 -0
- glaip_sdk/models/agent_runs.py +116 -0
- glaip_sdk/models/common.py +42 -0
- glaip_sdk/models/mcp.py +33 -0
- glaip_sdk/models/tool.py +33 -0
- glaip_sdk/payload_schemas/__init__.py +7 -0
- glaip_sdk/payload_schemas/agent.py +85 -0
- glaip_sdk/registry/__init__.py +55 -0
- glaip_sdk/registry/agent.py +164 -0
- glaip_sdk/registry/base.py +139 -0
- glaip_sdk/registry/mcp.py +253 -0
- glaip_sdk/registry/tool.py +232 -0
- glaip_sdk/runner/__init__.py +59 -0
- glaip_sdk/runner/base.py +84 -0
- glaip_sdk/runner/deps.py +112 -0
- glaip_sdk/runner/langgraph.py +782 -0
- glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
- glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
- glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
- glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
- glaip_sdk/runner/tool_adapter/__init__.py +18 -0
- glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
- glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
- glaip_sdk/tools/__init__.py +22 -0
- glaip_sdk/tools/base.py +435 -0
- glaip_sdk/utils/__init__.py +86 -0
- glaip_sdk/utils/a2a/__init__.py +34 -0
- glaip_sdk/utils/a2a/event_processor.py +188 -0
- glaip_sdk/utils/agent_config.py +194 -0
- glaip_sdk/utils/bundler.py +267 -0
- glaip_sdk/utils/client.py +111 -0
- glaip_sdk/utils/client_utils.py +486 -0
- glaip_sdk/utils/datetime_helpers.py +58 -0
- glaip_sdk/utils/discovery.py +78 -0
- glaip_sdk/utils/display.py +135 -0
- glaip_sdk/utils/export.py +143 -0
- glaip_sdk/utils/general.py +61 -0
- glaip_sdk/utils/import_export.py +168 -0
- glaip_sdk/utils/import_resolver.py +492 -0
- glaip_sdk/utils/instructions.py +101 -0
- glaip_sdk/utils/rendering/__init__.py +115 -0
- glaip_sdk/utils/rendering/formatting.py +264 -0
- glaip_sdk/utils/rendering/layout/__init__.py +64 -0
- glaip_sdk/utils/rendering/layout/panels.py +156 -0
- glaip_sdk/utils/rendering/layout/progress.py +202 -0
- glaip_sdk/utils/rendering/layout/summary.py +74 -0
- glaip_sdk/utils/rendering/layout/transcript.py +606 -0
- glaip_sdk/utils/rendering/models.py +85 -0
- glaip_sdk/utils/rendering/renderer/__init__.py +55 -0
- glaip_sdk/utils/rendering/renderer/base.py +1024 -0
- glaip_sdk/utils/rendering/renderer/config.py +27 -0
- glaip_sdk/utils/rendering/renderer/console.py +55 -0
- glaip_sdk/utils/rendering/renderer/debug.py +178 -0
- glaip_sdk/utils/rendering/renderer/factory.py +138 -0
- glaip_sdk/utils/rendering/renderer/stream.py +202 -0
- glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
- glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
- glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
- glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
- glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
- glaip_sdk/utils/rendering/state.py +204 -0
- glaip_sdk/utils/rendering/step_tree_state.py +100 -0
- glaip_sdk/utils/rendering/steps/__init__.py +34 -0
- glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
- glaip_sdk/utils/rendering/steps/format.py +176 -0
- glaip_sdk/utils/rendering/steps/manager.py +387 -0
- glaip_sdk/utils/rendering/timing.py +36 -0
- glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
- glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
- glaip_sdk/utils/resource_refs.py +195 -0
- glaip_sdk/utils/run_renderer.py +41 -0
- glaip_sdk/utils/runtime_config.py +425 -0
- glaip_sdk/utils/serialization.py +424 -0
- glaip_sdk/utils/sync.py +142 -0
- glaip_sdk/utils/tool_detection.py +33 -0
- glaip_sdk/utils/validation.py +264 -0
- {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.15b3.dist-info}/METADATA +1 -1
- glaip_sdk-0.6.15b3.dist-info/RECORD +160 -0
- glaip_sdk-0.6.15b2.dist-info/RECORD +0 -12
- {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.15b3.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.15b3.dist-info}/entry_points.txt +0 -0
- {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.15b3.dist-info}/top_level.txt +0 -0
glaip_sdk/cli/masking.py
ADDED
|
@@ -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
|