glaip-sdk 0.7.9__py3-none-any.whl → 0.7.10__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/base.py +61 -10
- glaip_sdk/cli/slash/remote_runs_controller.py +2 -0
- glaip_sdk/cli/slash/tui/accounts.tcss +12 -0
- glaip_sdk/cli/slash/tui/accounts_app.py +64 -29
- glaip_sdk/cli/slash/tui/clipboard.py +56 -8
- glaip_sdk/cli/slash/tui/remote_runs_app.py +119 -12
- glaip_sdk/cli/slash/tui/toast.py +270 -19
- glaip_sdk/client/run_rendering.py +76 -29
- glaip_sdk/guardrails/__init__.py +80 -0
- glaip_sdk/guardrails/serializer.py +89 -0
- glaip_sdk/payload_schemas/agent.py +1 -0
- glaip_sdk/payload_schemas/guardrails.py +34 -0
- glaip_sdk/runner/langgraph.py +1 -0
- {glaip_sdk-0.7.9.dist-info → glaip_sdk-0.7.10.dist-info}/METADATA +3 -1
- {glaip_sdk-0.7.9.dist-info → glaip_sdk-0.7.10.dist-info}/RECORD +18 -15
- {glaip_sdk-0.7.9.dist-info → glaip_sdk-0.7.10.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.7.9.dist-info → glaip_sdk-0.7.10.dist-info}/entry_points.txt +0 -0
- {glaip_sdk-0.7.9.dist-info → glaip_sdk-0.7.10.dist-info}/top_level.txt +0 -0
glaip_sdk/agents/base.py
CHANGED
|
@@ -54,6 +54,7 @@ from glaip_sdk.utils.resource_refs import is_uuid
|
|
|
54
54
|
|
|
55
55
|
if TYPE_CHECKING:
|
|
56
56
|
from glaip_sdk.client.schedules import AgentScheduleManager
|
|
57
|
+
from glaip_sdk.guardrails import GuardrailManager
|
|
57
58
|
from glaip_sdk.models import AgentResponse
|
|
58
59
|
from glaip_sdk.registry import AgentRegistry, MCPRegistry, ToolRegistry
|
|
59
60
|
|
|
@@ -130,6 +131,7 @@ class Agent:
|
|
|
130
131
|
agents: list | None = None,
|
|
131
132
|
mcps: list | None = None,
|
|
132
133
|
model: str | None = _UNSET, # type: ignore[assignment]
|
|
134
|
+
guardrail: GuardrailManager | None = None,
|
|
133
135
|
_client: Any = None,
|
|
134
136
|
**kwargs: Any,
|
|
135
137
|
) -> None:
|
|
@@ -147,7 +149,9 @@ class Agent:
|
|
|
147
149
|
agents: List of sub-agents (Agent classes, instances, or strings).
|
|
148
150
|
mcps: List of MCPs.
|
|
149
151
|
model: Model identifier.
|
|
152
|
+
guardrail: The guardrail manager for content safety.
|
|
150
153
|
_client: Internal client reference (set automatically).
|
|
154
|
+
|
|
151
155
|
**kwargs: Additional configuration parameters:
|
|
152
156
|
- timeout: Execution timeout in seconds.
|
|
153
157
|
- metadata: Optional metadata dictionary.
|
|
@@ -173,6 +177,7 @@ class Agent:
|
|
|
173
177
|
self._agents = agents
|
|
174
178
|
self._mcps = mcps
|
|
175
179
|
self._model = model
|
|
180
|
+
self._guardrail = guardrail
|
|
176
181
|
self._language_model_id: str | None = None
|
|
177
182
|
# Extract parameters from kwargs with _UNSET defaults
|
|
178
183
|
self._timeout = kwargs.pop("timeout", Agent._UNSET) # type: ignore[assignment]
|
|
@@ -452,6 +457,11 @@ class Agent:
|
|
|
452
457
|
return self._mcp_configs
|
|
453
458
|
return None
|
|
454
459
|
|
|
460
|
+
@property
|
|
461
|
+
def guardrail(self) -> GuardrailManager | None:
|
|
462
|
+
"""The guardrail manager for content safety."""
|
|
463
|
+
return self._guardrail
|
|
464
|
+
|
|
455
465
|
@property
|
|
456
466
|
def a2a_profile(self) -> dict[str, Any] | None:
|
|
457
467
|
"""A2A (Agent-to-Agent) profile configuration.
|
|
@@ -547,11 +557,20 @@ class Agent:
|
|
|
547
557
|
|
|
548
558
|
# Handle agent_config with timeout
|
|
549
559
|
# The timeout property is a convenience that maps to agent_config.execution_timeout
|
|
550
|
-
|
|
560
|
+
raw_config = self.agent_config if self.agent_config is not self._UNSET else {}
|
|
561
|
+
agent_config = dict(raw_config) if raw_config else {}
|
|
562
|
+
|
|
551
563
|
if self.timeout and "execution_timeout" not in agent_config:
|
|
552
564
|
agent_config["execution_timeout"] = self.timeout
|
|
553
|
-
|
|
554
|
-
|
|
565
|
+
|
|
566
|
+
if self.guardrail:
|
|
567
|
+
from glaip_sdk.guardrails.serializer import ( # noqa: PLC0415
|
|
568
|
+
serialize_guardrail_manager,
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
agent_config["guardrails"] = serialize_guardrail_manager(self.guardrail)
|
|
572
|
+
|
|
573
|
+
config["agent_config"] = agent_config
|
|
555
574
|
|
|
556
575
|
# Handle tool_configs - resolve tool names/classes to IDs
|
|
557
576
|
if self.tool_configs:
|
|
@@ -583,11 +602,20 @@ class Agent:
|
|
|
583
602
|
|
|
584
603
|
Returns:
|
|
585
604
|
List of resolved MCP IDs for the API payload.
|
|
605
|
+
|
|
606
|
+
Raises:
|
|
607
|
+
ValueError: If an MCP fails to resolve to a valid ID.
|
|
586
608
|
"""
|
|
587
609
|
if not self.mcps:
|
|
588
610
|
return []
|
|
589
611
|
|
|
590
|
-
|
|
612
|
+
resolved_ids: list[str] = []
|
|
613
|
+
for mcp_ref in self.mcps:
|
|
614
|
+
mcp = registry.resolve(mcp_ref)
|
|
615
|
+
if not mcp.id:
|
|
616
|
+
raise ValueError(f"Failed to resolve ID for MCP: {mcp_ref}")
|
|
617
|
+
resolved_ids.append(mcp.id)
|
|
618
|
+
return resolved_ids
|
|
591
619
|
|
|
592
620
|
def _resolve_tools(self, registry: ToolRegistry) -> list[str]:
|
|
593
621
|
"""Resolve tool references to IDs using ToolRegistry.
|
|
@@ -601,12 +629,20 @@ class Agent:
|
|
|
601
629
|
|
|
602
630
|
Returns:
|
|
603
631
|
List of resolved tool IDs for the API payload.
|
|
632
|
+
|
|
633
|
+
Raises:
|
|
634
|
+
ValueError: If a tool fails to resolve to a valid ID.
|
|
604
635
|
"""
|
|
605
636
|
if not self.tools:
|
|
606
637
|
return []
|
|
607
638
|
|
|
608
|
-
|
|
609
|
-
|
|
639
|
+
resolved_ids: list[str] = []
|
|
640
|
+
for tool_ref in self.tools:
|
|
641
|
+
tool = registry.resolve(tool_ref)
|
|
642
|
+
if not tool.id:
|
|
643
|
+
raise ValueError(f"Failed to resolve ID for tool: {tool_ref}")
|
|
644
|
+
resolved_ids.append(tool.id)
|
|
645
|
+
return resolved_ids
|
|
610
646
|
|
|
611
647
|
def _resolve_tool_configs(self, registry: ToolRegistry) -> dict[str, Any]:
|
|
612
648
|
"""Resolve tool_configs keys from tool names/classes to tool IDs.
|
|
@@ -649,6 +685,8 @@ class Agent:
|
|
|
649
685
|
try:
|
|
650
686
|
# Resolve key (tool name/class) to Tool object, get ID
|
|
651
687
|
tool = registry.resolve(key)
|
|
688
|
+
if not tool.id:
|
|
689
|
+
raise ValueError(f"Resolved tool has no ID: {key}")
|
|
652
690
|
resolved[tool.id] = config
|
|
653
691
|
except (ValueError, KeyError) as e:
|
|
654
692
|
raise ValueError(f"Failed to resolve tool config key: {key}") from e
|
|
@@ -684,6 +722,8 @@ class Agent:
|
|
|
684
722
|
resolved_id = key
|
|
685
723
|
else:
|
|
686
724
|
mcp = registry.resolve(key)
|
|
725
|
+
if not mcp.id:
|
|
726
|
+
raise ValueError(f"Resolved MCP has no ID: {key}")
|
|
687
727
|
resolved_id = mcp.id
|
|
688
728
|
|
|
689
729
|
if resolved_id in resolved:
|
|
@@ -699,7 +739,7 @@ class Agent:
|
|
|
699
739
|
|
|
700
740
|
return resolved
|
|
701
741
|
|
|
702
|
-
def _resolve_agents(self, registry: AgentRegistry) -> list:
|
|
742
|
+
def _resolve_agents(self, registry: AgentRegistry) -> list[str]:
|
|
703
743
|
"""Resolve sub-agent references using AgentRegistry.
|
|
704
744
|
|
|
705
745
|
Uses the global AgentRegistry to cache Agent objects across deployments.
|
|
@@ -711,12 +751,20 @@ class Agent:
|
|
|
711
751
|
|
|
712
752
|
Returns:
|
|
713
753
|
List of resolved agent IDs for the API payload.
|
|
754
|
+
|
|
755
|
+
Raises:
|
|
756
|
+
ValueError: If an agent fails to resolve to a valid ID.
|
|
714
757
|
"""
|
|
715
758
|
if not self.agents:
|
|
716
759
|
return []
|
|
717
760
|
|
|
718
|
-
|
|
719
|
-
|
|
761
|
+
resolved_ids: list[str] = []
|
|
762
|
+
for agent_ref in self.agents:
|
|
763
|
+
agent = registry.resolve(agent_ref)
|
|
764
|
+
if not agent.id:
|
|
765
|
+
raise ValueError(f"Failed to resolve ID for agent: {agent_ref}")
|
|
766
|
+
resolved_ids.append(agent.id)
|
|
767
|
+
return resolved_ids
|
|
720
768
|
|
|
721
769
|
def _create_or_update_agent(
|
|
722
770
|
self,
|
|
@@ -807,6 +855,8 @@ class Agent:
|
|
|
807
855
|
Returns:
|
|
808
856
|
Dictionary containing Agent attributes.
|
|
809
857
|
"""
|
|
858
|
+
config = self._build_config(get_tool_registry(), get_mcp_registry())
|
|
859
|
+
|
|
810
860
|
data = {
|
|
811
861
|
"id": self._id,
|
|
812
862
|
"name": self.name,
|
|
@@ -820,10 +870,11 @@ class Agent:
|
|
|
820
870
|
"mcps": self.mcps,
|
|
821
871
|
"timeout": self.timeout,
|
|
822
872
|
"metadata": self.metadata,
|
|
823
|
-
"agent_config":
|
|
873
|
+
"agent_config": config.get("agent_config"),
|
|
824
874
|
"tool_configs": self.tool_configs,
|
|
825
875
|
"mcp_configs": self.mcp_configs,
|
|
826
876
|
"a2a_profile": self.a2a_profile,
|
|
877
|
+
"guardrail": self.guardrail,
|
|
827
878
|
"created_at": self._created_at,
|
|
828
879
|
"updated_at": self._updated_at,
|
|
829
880
|
}
|
|
@@ -294,12 +294,14 @@ class RemoteRunsController:
|
|
|
294
294
|
fetch_detail=fetch_detail,
|
|
295
295
|
export_run=export_run,
|
|
296
296
|
)
|
|
297
|
+
tui_ctx = getattr(self.session, "tui_ctx", None)
|
|
297
298
|
page, limit, cursor = run_remote_runs_textual(
|
|
298
299
|
runs_page,
|
|
299
300
|
state.get("cursor", 0),
|
|
300
301
|
callbacks,
|
|
301
302
|
agent_name=agent_name,
|
|
302
303
|
agent_id=agent_id,
|
|
304
|
+
ctx=tui_ctx,
|
|
303
305
|
)
|
|
304
306
|
state["page"] = page
|
|
305
307
|
state["limit"] = limit
|
|
@@ -3,6 +3,18 @@
|
|
|
3
3
|
* Keep layout compact: filter sits tight above the table; header shows active account.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
Screen {
|
|
7
|
+
layers: base toasts;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
#toast-container {
|
|
11
|
+
width: 100%;
|
|
12
|
+
height: auto;
|
|
13
|
+
dock: top;
|
|
14
|
+
align: right top;
|
|
15
|
+
layer: toasts;
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
#header-info {
|
|
7
19
|
padding: 0 1 0 1;
|
|
8
20
|
margin: 0;
|
|
@@ -18,7 +18,7 @@ import asyncio
|
|
|
18
18
|
import logging
|
|
19
19
|
from collections.abc import Callable
|
|
20
20
|
from dataclasses import dataclass
|
|
21
|
-
from typing import Any
|
|
21
|
+
from typing import Any, cast
|
|
22
22
|
|
|
23
23
|
from glaip_sdk.cli.account_store import AccountStore, AccountStoreError, get_account_store
|
|
24
24
|
from glaip_sdk.cli.commands.common_config import check_connection_with_reason
|
|
@@ -34,7 +34,15 @@ from glaip_sdk.cli.slash.tui.keybind_registry import KeybindRegistry
|
|
|
34
34
|
from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
|
|
35
35
|
from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities
|
|
36
36
|
from glaip_sdk.cli.slash.tui.theme.catalog import _BUILTIN_THEMES
|
|
37
|
-
|
|
37
|
+
|
|
38
|
+
try: # pragma: no cover - optional dependency
|
|
39
|
+
from glaip_sdk.cli.slash.tui.toast import ClipboardToastMixin, Toast, ToastBus, ToastHandlerMixin, ToastVariant
|
|
40
|
+
except Exception: # pragma: no cover - optional dependency
|
|
41
|
+
ClipboardToastMixin = object # type: ignore[assignment, misc]
|
|
42
|
+
Toast = None # type: ignore[assignment]
|
|
43
|
+
ToastBus = None # type: ignore[assignment]
|
|
44
|
+
ToastHandlerMixin = object # type: ignore[assignment, misc]
|
|
45
|
+
ToastVariant = None # type: ignore[assignment]
|
|
38
46
|
from glaip_sdk.cli.validators import validate_api_key
|
|
39
47
|
from glaip_sdk.utils.validation import validate_url
|
|
40
48
|
|
|
@@ -450,7 +458,9 @@ class ConfirmDeleteModal(_ConfirmDeleteBase): # pragma: no cover - interactive
|
|
|
450
458
|
self.dismiss(self._name)
|
|
451
459
|
|
|
452
460
|
|
|
453
|
-
class AccountsTextualApp(
|
|
461
|
+
class AccountsTextualApp( # pragma: no cover - interactive
|
|
462
|
+
ToastHandlerMixin, ClipboardToastMixin, BackgroundTaskMixin, _AppBase
|
|
463
|
+
):
|
|
454
464
|
"""Textual application for browsing accounts."""
|
|
455
465
|
|
|
456
466
|
CSS_PATH = CSS_FILE_NAME
|
|
@@ -494,7 +504,6 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
494
504
|
self._ctx = ctx
|
|
495
505
|
self._keybinds: KeybindRegistry | None = None
|
|
496
506
|
self._toast_bus: ToastBus | None = None
|
|
497
|
-
self._toast_ready = False
|
|
498
507
|
self._clipboard: ClipboardAdapter | None = None
|
|
499
508
|
self._filter_text: str = ""
|
|
500
509
|
self._is_switching = False
|
|
@@ -526,6 +535,8 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
526
535
|
main.styles.height = "1fr"
|
|
527
536
|
main.styles.padding = (0, 0)
|
|
528
537
|
yield main
|
|
538
|
+
if Toast is not None:
|
|
539
|
+
yield Container(Toast(), id="toast-container")
|
|
529
540
|
yield Horizontal(
|
|
530
541
|
LoadingIndicator(id=ACCOUNTS_LOADING_ID.lstrip("#")),
|
|
531
542
|
Static("", id=STATUS_ID.lstrip("#")),
|
|
@@ -555,11 +566,14 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
555
566
|
self._register_keybinds()
|
|
556
567
|
|
|
557
568
|
def _initialize_context_services(self) -> None:
|
|
569
|
+
def _notify(message: ToastBus.Changed) -> None:
|
|
570
|
+
self.post_message(message)
|
|
571
|
+
|
|
558
572
|
if self._ctx:
|
|
559
573
|
if self._ctx.keybinds is None:
|
|
560
574
|
self._ctx.keybinds = KeybindRegistry()
|
|
561
|
-
if self._ctx.toasts is None:
|
|
562
|
-
self._ctx.toasts = ToastBus()
|
|
575
|
+
if self._ctx.toasts is None and ToastBus is not None:
|
|
576
|
+
self._ctx.toasts = ToastBus(on_change=_notify)
|
|
563
577
|
if self._ctx.clipboard is None:
|
|
564
578
|
self._ctx.clipboard = ClipboardAdapter(terminal=self._ctx.terminal)
|
|
565
579
|
self._keybinds = self._ctx.keybinds
|
|
@@ -571,10 +585,11 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
571
585
|
tty=True, ansi=True, osc52=False, osc11_bg=None, mouse=False, truecolor=False
|
|
572
586
|
)
|
|
573
587
|
self._clipboard = ClipboardAdapter(terminal=terminal)
|
|
588
|
+
if ToastBus is not None:
|
|
589
|
+
self._toast_bus = ToastBus(on_change=_notify)
|
|
574
590
|
|
|
575
591
|
def _prepare_toasts(self) -> None:
|
|
576
|
-
"""Prepare toast system by
|
|
577
|
-
self._toast_ready = True
|
|
592
|
+
"""Prepare toast system by clearing any existing toasts."""
|
|
578
593
|
if self._toast_bus:
|
|
579
594
|
self._toast_bus.clear()
|
|
580
595
|
|
|
@@ -768,7 +783,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
768
783
|
self._hide_loading()
|
|
769
784
|
self._is_switching = False
|
|
770
785
|
error_msg = f"Switch failed to start: {exc}"
|
|
771
|
-
if self.
|
|
786
|
+
if self._toast_bus:
|
|
772
787
|
self._toast_bus.show(message=error_msg, variant="error")
|
|
773
788
|
try:
|
|
774
789
|
self._set_status(error_msg, "red")
|
|
@@ -800,7 +815,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
800
815
|
if switched:
|
|
801
816
|
self._active_account = name
|
|
802
817
|
status_msg = message or f"Switched to '{name}'."
|
|
803
|
-
if self.
|
|
818
|
+
if self._toast_bus:
|
|
804
819
|
self._toast_bus.show(message=status_msg, variant="success")
|
|
805
820
|
self._update_header()
|
|
806
821
|
self._reload_rows()
|
|
@@ -913,34 +928,54 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
913
928
|
return
|
|
914
929
|
|
|
915
930
|
text = f"Account: {name}\nURL: {account.get('api_url', '')}"
|
|
916
|
-
adapter = self.
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
931
|
+
adapter = self._clipboard_adapter()
|
|
932
|
+
writer = self._osc52_writer()
|
|
933
|
+
if writer:
|
|
934
|
+
result = adapter.copy(text, writer=writer)
|
|
935
|
+
else:
|
|
921
936
|
result = adapter.copy(text)
|
|
922
|
-
|
|
923
|
-
return
|
|
924
|
-
|
|
925
|
-
async def perform() -> None:
|
|
926
|
-
result = await asyncio.to_thread(adapter.copy, text)
|
|
927
|
-
self._handle_copy_result(name, result)
|
|
928
|
-
|
|
929
|
-
self.track_task(perform(), logger=logging.getLogger(__name__))
|
|
937
|
+
self._handle_copy_result(name, result)
|
|
930
938
|
|
|
931
939
|
def _handle_copy_result(self, name: str, result: ClipboardResult) -> None:
|
|
932
940
|
"""Update UI state after a copy attempt."""
|
|
933
941
|
if result.success:
|
|
934
|
-
if self.
|
|
935
|
-
self._toast_bus.copy_success(
|
|
936
|
-
# Status fallback until toast widget is implemented (see specs/workflow/tui-toast-system/spec.md Phase 2)
|
|
942
|
+
if self._toast_bus:
|
|
943
|
+
self._toast_bus.copy_success(f"Account '{name}'")
|
|
937
944
|
self._set_status(f"Copied '{name}' to clipboard.", "green")
|
|
938
945
|
else:
|
|
939
|
-
if self.
|
|
940
|
-
self._toast_bus.show(message=f"Copy failed: {result.message}", variant=
|
|
941
|
-
# Status fallback until toast widget is implemented (see specs/workflow/tui-toast-system/spec.md Phase 2)
|
|
946
|
+
if self._toast_bus and ToastVariant is not None:
|
|
947
|
+
self._toast_bus.show(message=f"Copy failed: {result.message}", variant=ToastVariant.WARNING)
|
|
942
948
|
self._set_status(f"Copy failed: {result.message}", "red")
|
|
943
949
|
|
|
950
|
+
def _clipboard_adapter(self) -> ClipboardAdapter:
|
|
951
|
+
if self._ctx is not None and self._ctx.clipboard is not None:
|
|
952
|
+
return cast(ClipboardAdapter, self._ctx.clipboard)
|
|
953
|
+
if self._clipboard is not None:
|
|
954
|
+
return self._clipboard
|
|
955
|
+
adapter = ClipboardAdapter(terminal=self._ctx.terminal if self._ctx else None)
|
|
956
|
+
if self._ctx is not None:
|
|
957
|
+
self._ctx.clipboard = adapter
|
|
958
|
+
else:
|
|
959
|
+
self._clipboard = adapter
|
|
960
|
+
return adapter
|
|
961
|
+
|
|
962
|
+
def _osc52_writer(self) -> Callable[[str], Any] | None:
|
|
963
|
+
try:
|
|
964
|
+
console = getattr(self, "console", None)
|
|
965
|
+
except Exception:
|
|
966
|
+
return None
|
|
967
|
+
if console is None:
|
|
968
|
+
return None
|
|
969
|
+
output = getattr(console, "file", None)
|
|
970
|
+
if output is None:
|
|
971
|
+
return None
|
|
972
|
+
|
|
973
|
+
def _write(sequence: str, _output=output) -> None:
|
|
974
|
+
_output.write(sequence)
|
|
975
|
+
_output.flush()
|
|
976
|
+
|
|
977
|
+
return _write
|
|
978
|
+
|
|
944
979
|
def _check_env_lock_hotkey(self) -> bool:
|
|
945
980
|
"""Prevent mutations when env credentials are present."""
|
|
946
981
|
if not self._is_env_locked():
|
|
@@ -45,6 +45,36 @@ _SUBPROCESS_COMMANDS: dict[ClipboardMethod, list[str]] = {
|
|
|
45
45
|
ClipboardMethod.CLIP: ["clip"],
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
_ENV_CLIPBOARD_METHOD = "AIP_TUI_CLIPBOARD_METHOD"
|
|
49
|
+
_ENV_CLIPBOARD_FORCE = "AIP_TUI_CLIPBOARD_FORCE"
|
|
50
|
+
_ENV_METHOD_MAP = {
|
|
51
|
+
"osc52": ClipboardMethod.OSC52,
|
|
52
|
+
"pbcopy": ClipboardMethod.PBCOPY,
|
|
53
|
+
"xclip": ClipboardMethod.XCLIP,
|
|
54
|
+
"xsel": ClipboardMethod.XSEL,
|
|
55
|
+
"wl-copy": ClipboardMethod.WL_COPY,
|
|
56
|
+
"wl_copy": ClipboardMethod.WL_COPY,
|
|
57
|
+
"clip": ClipboardMethod.CLIP,
|
|
58
|
+
"none": ClipboardMethod.NONE,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve_env_method() -> ClipboardMethod | None:
|
|
63
|
+
raw = os.getenv(_ENV_CLIPBOARD_METHOD)
|
|
64
|
+
if not raw:
|
|
65
|
+
return None
|
|
66
|
+
value = raw.strip().lower()
|
|
67
|
+
if value in ("auto", "default"):
|
|
68
|
+
return None
|
|
69
|
+
return _ENV_METHOD_MAP.get(value)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_env_force_enabled() -> bool:
|
|
73
|
+
raw = os.getenv(_ENV_CLIPBOARD_FORCE)
|
|
74
|
+
if not raw:
|
|
75
|
+
return False
|
|
76
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
77
|
+
|
|
48
78
|
|
|
49
79
|
class ClipboardAdapter:
|
|
50
80
|
"""Cross-platform clipboard access with OSC 52 fallback."""
|
|
@@ -57,7 +87,16 @@ class ClipboardAdapter:
|
|
|
57
87
|
) -> None:
|
|
58
88
|
"""Initialize the adapter."""
|
|
59
89
|
self._terminal = terminal
|
|
60
|
-
self.
|
|
90
|
+
self._force_method = False
|
|
91
|
+
if method is not None:
|
|
92
|
+
self._method = method
|
|
93
|
+
else:
|
|
94
|
+
env_method = _resolve_env_method()
|
|
95
|
+
if env_method is not None:
|
|
96
|
+
self._method = env_method
|
|
97
|
+
self._force_method = _is_env_force_enabled()
|
|
98
|
+
else:
|
|
99
|
+
self._method = self._detect_method()
|
|
61
100
|
|
|
62
101
|
@property
|
|
63
102
|
def method(self) -> ClipboardMethod:
|
|
@@ -77,25 +116,34 @@ class ClipboardAdapter:
|
|
|
77
116
|
|
|
78
117
|
command = _SUBPROCESS_COMMANDS.get(self._method)
|
|
79
118
|
if command is None:
|
|
119
|
+
if self._force_method:
|
|
120
|
+
return ClipboardResult(False, self._method, "Forced clipboard method unavailable.")
|
|
80
121
|
return self._copy_osc52(text, writer=writer)
|
|
81
122
|
|
|
82
123
|
result = self._copy_subprocess(command, text)
|
|
83
124
|
if not result.success:
|
|
125
|
+
if self._force_method:
|
|
126
|
+
return result
|
|
84
127
|
return self._copy_osc52(text, writer=writer)
|
|
85
128
|
|
|
86
129
|
return result
|
|
87
130
|
|
|
88
131
|
def _detect_method(self) -> ClipboardMethod:
|
|
132
|
+
system = platform.system()
|
|
133
|
+
method = ClipboardMethod.NONE
|
|
134
|
+
if system == "Darwin":
|
|
135
|
+
method = self._detect_darwin_method()
|
|
136
|
+
elif system == "Linux":
|
|
137
|
+
method = self._detect_linux_method()
|
|
138
|
+
elif system == "Windows":
|
|
139
|
+
method = self._detect_windows_method()
|
|
140
|
+
|
|
141
|
+
if method is not ClipboardMethod.NONE:
|
|
142
|
+
return method
|
|
143
|
+
|
|
89
144
|
if self._terminal.osc52 if self._terminal else detect_osc52_support():
|
|
90
145
|
return ClipboardMethod.OSC52
|
|
91
146
|
|
|
92
|
-
system = platform.system()
|
|
93
|
-
if system == "Darwin":
|
|
94
|
-
return self._detect_darwin_method()
|
|
95
|
-
if system == "Linux":
|
|
96
|
-
return self._detect_linux_method()
|
|
97
|
-
if system == "Windows":
|
|
98
|
-
return self._detect_windows_method()
|
|
99
147
|
return ClipboardMethod.NONE
|
|
100
148
|
|
|
101
149
|
def _detect_darwin_method(self) -> ClipboardMethod:
|