glaip-sdk 0.0.2__py3-none-any.whl → 0.0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. glaip_sdk/__init__.py +2 -2
  2. glaip_sdk/_version.py +51 -0
  3. glaip_sdk/branding.py +145 -0
  4. glaip_sdk/cli/commands/agents.py +876 -166
  5. glaip_sdk/cli/commands/configure.py +46 -104
  6. glaip_sdk/cli/commands/init.py +43 -118
  7. glaip_sdk/cli/commands/mcps.py +86 -161
  8. glaip_sdk/cli/commands/tools.py +196 -57
  9. glaip_sdk/cli/main.py +43 -29
  10. glaip_sdk/cli/utils.py +258 -27
  11. glaip_sdk/client/__init__.py +54 -2
  12. glaip_sdk/client/agents.py +196 -237
  13. glaip_sdk/client/base.py +62 -2
  14. glaip_sdk/client/mcps.py +63 -20
  15. glaip_sdk/client/tools.py +236 -81
  16. glaip_sdk/config/constants.py +10 -3
  17. glaip_sdk/exceptions.py +13 -0
  18. glaip_sdk/models.py +21 -5
  19. glaip_sdk/utils/__init__.py +116 -18
  20. glaip_sdk/utils/client_utils.py +284 -0
  21. glaip_sdk/utils/rendering/__init__.py +1 -0
  22. glaip_sdk/utils/rendering/formatting.py +211 -0
  23. glaip_sdk/utils/rendering/models.py +53 -0
  24. glaip_sdk/utils/rendering/renderer/__init__.py +38 -0
  25. glaip_sdk/utils/rendering/renderer/base.py +827 -0
  26. glaip_sdk/utils/rendering/renderer/config.py +33 -0
  27. glaip_sdk/utils/rendering/renderer/console.py +54 -0
  28. glaip_sdk/utils/rendering/renderer/debug.py +82 -0
  29. glaip_sdk/utils/rendering/renderer/panels.py +123 -0
  30. glaip_sdk/utils/rendering/renderer/progress.py +118 -0
  31. glaip_sdk/utils/rendering/renderer/stream.py +198 -0
  32. glaip_sdk/utils/rendering/steps.py +168 -0
  33. glaip_sdk/utils/run_renderer.py +22 -1086
  34. {glaip_sdk-0.0.2.dist-info → glaip_sdk-0.0.4.dist-info}/METADATA +8 -36
  35. glaip_sdk-0.0.4.dist-info/RECORD +41 -0
  36. glaip_sdk/cli/config.py +0 -592
  37. glaip_sdk/utils.py +0 -167
  38. glaip_sdk-0.0.2.dist-info/RECORD +0 -28
  39. {glaip_sdk-0.0.2.dist-info → glaip_sdk-0.0.4.dist-info}/WHEEL +0 -0
  40. {glaip_sdk-0.0.2.dist-info → glaip_sdk-0.0.4.dist-info}/entry_points.txt +0 -0
glaip_sdk/cli/utils.py CHANGED
@@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Any
19
19
  import click
20
20
  from rich import box
21
21
  from rich.console import Console, Group
22
+ from rich.markdown import Markdown
22
23
  from rich.panel import Panel
23
24
  from rich.pretty import Pretty
24
25
  from rich.table import Table
@@ -41,6 +42,15 @@ except Exception:
41
42
  if TYPE_CHECKING:
42
43
  from glaip_sdk import Client
43
44
 
45
+ from glaip_sdk import Client
46
+ from glaip_sdk.cli.commands.configure import load_config
47
+ from glaip_sdk.utils import is_uuid
48
+ from glaip_sdk.utils.rendering.renderer import (
49
+ CapturingConsole,
50
+ RendererConfig,
51
+ RichStreamRenderer,
52
+ )
53
+
44
54
  console = Console()
45
55
 
46
56
 
@@ -160,9 +170,6 @@ def _get_view(ctx) -> str:
160
170
 
161
171
  def get_client(ctx) -> Client:
162
172
  """Get configured client from context, env, and config file (ctx > env > file)."""
163
- from glaip_sdk import Client
164
- from glaip_sdk.cli.commands.configure import load_config
165
-
166
173
  file_config = load_config() or {}
167
174
  context_config = (ctx.obj or {}) if ctx else {}
168
175
 
@@ -191,16 +198,6 @@ def get_client(ctx) -> Client:
191
198
  )
192
199
 
193
200
 
194
- # ----------------------------- Small helpers ----------------------------- #
195
-
196
-
197
- def safe_getattr(obj: Any, attr: str, default: Any = None) -> Any:
198
- try:
199
- return getattr(obj, attr)
200
- except Exception:
201
- return default
202
-
203
-
204
201
  # ----------------------------- Secret masking ---------------------------- #
205
202
 
206
203
  _DEFAULT_MASK_FIELDS = {
@@ -481,8 +478,6 @@ def output_result(
481
478
 
482
479
  if fmt == "md":
483
480
  try:
484
- from rich.markdown import Markdown
485
-
486
481
  console.print(Markdown(str(data)))
487
482
  except ImportError:
488
483
  # Fallback to plain if markdown not available
@@ -490,12 +485,12 @@ def output_result(
490
485
  return
491
486
 
492
487
  if success_message:
493
- console.print(f"[green]✅ {success_message}[/green]")
488
+ console.print(Text(f"[green]✅ {success_message}[/green]"))
494
489
 
495
490
  if panel_title:
496
491
  console.print(Panel(Pretty(data), title=panel_title, border_style="blue"))
497
492
  else:
498
- console.print(f"[cyan]{title}:[/cyan]")
493
+ console.print(Text(f"[cyan]{title}:[/cyan]"))
499
494
  console.print(Pretty(data))
500
495
 
501
496
 
@@ -532,6 +527,14 @@ def output_list(
532
527
  except Exception:
533
528
  rows = []
534
529
 
530
+ # Mask secrets (apply before any view)
531
+ mask_fields = _resolve_mask_fields()
532
+ if mask_fields:
533
+ try:
534
+ rows = [_maybe_mask_row(r, mask_fields) for r in rows]
535
+ except Exception:
536
+ pass
537
+
535
538
  # JSON view bypasses any UI
536
539
  if fmt == "json":
537
540
  data = rows or [it.to_dict() if hasattr(it, "to_dict") else it for it in items]
@@ -563,7 +566,7 @@ def output_list(
563
566
  return
564
567
 
565
568
  if not items:
566
- console.print(f"[yellow]No {title.lower()} found.[/yellow]")
569
+ console.print(Text(f"[yellow]No {title.lower()} found.[/yellow]"))
567
570
  return
568
571
 
569
572
  # Sort by name by default (unless disabled)
@@ -578,14 +581,6 @@ def output_list(
578
581
  except Exception:
579
582
  pass
580
583
 
581
- # Mask secrets
582
- mask_fields = _resolve_mask_fields()
583
- if mask_fields:
584
- try:
585
- rows = [_maybe_mask_row(r, mask_fields) for r in rows]
586
- except Exception:
587
- pass
588
-
589
584
  # === Fuzzy palette is the default for TTY lists ===
590
585
  picked: dict[str, Any] | None = None
591
586
  if console.is_terminal and os.isatty(1):
@@ -685,6 +680,240 @@ def output_flags():
685
680
  # ------------------------- Ambiguity handling --------------------------- #
686
681
 
687
682
 
683
+ def coerce_to_row(item, keys: list[str]) -> dict[str, Any]:
684
+ """Coerce an item (dict or object) to a row dict with specified keys.
685
+
686
+ Args:
687
+ item: The item to coerce (dict or object with attributes)
688
+ keys: List of keys/attribute names to extract
689
+
690
+ Returns:
691
+ Dict with the extracted values, "N/A" for missing values
692
+ """
693
+ result = {}
694
+ for key in keys:
695
+ if isinstance(item, dict):
696
+ value = item.get(key, "N/A")
697
+ else:
698
+ value = getattr(item, key, "N/A")
699
+ result[key] = str(value) if value is not None else "N/A"
700
+ return result
701
+
702
+
703
+ def build_renderer(
704
+ ctx,
705
+ *,
706
+ save_path,
707
+ theme="dark",
708
+ verbose=False,
709
+ tty_enabled=True,
710
+ live=None,
711
+ snapshots=None,
712
+ ):
713
+ """Build renderer and capturing console for CLI commands.
714
+
715
+ Args:
716
+ ctx: Click context
717
+ save_path: Path to save output to (enables capturing)
718
+ theme: Color theme ("dark" or "light")
719
+ verbose: Whether to enable verbose mode
720
+ tty_enabled: Whether TTY is available
721
+
722
+ Returns:
723
+ Tuple of (renderer, capturing_console)
724
+ """
725
+ # Use capturing console if saving output
726
+ working_console = console
727
+ if save_path:
728
+ working_console = CapturingConsole(console, capture=True)
729
+
730
+ # Configure renderer based on verbose mode and explicit overrides
731
+ if live is None:
732
+ live_enabled = not verbose # Disable live mode in verbose (unless overridden)
733
+ else:
734
+ live_enabled = bool(live)
735
+
736
+ renderer_cfg = RendererConfig(
737
+ theme=theme,
738
+ style="debug" if verbose else "pretty",
739
+ live=live_enabled,
740
+ show_delegate_tool_panels=True,
741
+ append_finished_snapshots=bool(snapshots)
742
+ if snapshots is not None
743
+ else RendererConfig.append_finished_snapshots,
744
+ )
745
+
746
+ # Create the renderer instance
747
+ renderer = RichStreamRenderer(
748
+ working_console.original_console
749
+ if isinstance(working_console, CapturingConsole)
750
+ else working_console,
751
+ cfg=renderer_cfg,
752
+ verbose=verbose,
753
+ )
754
+
755
+ return renderer, working_console
756
+
757
+
758
+ def _fuzzy_pick_for_resources(
759
+ resources: list[Any], resource_type: str, search_term: str
760
+ ) -> Any | None:
761
+ """
762
+ Fuzzy picker for resource objects, similar to _fuzzy_pick but without column dependencies.
763
+
764
+ Args:
765
+ resources: List of resource objects to choose from
766
+ resource_type: Type of resource (e.g., "agent", "tool")
767
+ search_term: The search term that led to multiple matches
768
+
769
+ Returns:
770
+ Selected resource object or None if cancelled/no selection
771
+ """
772
+ if not (_HAS_PTK and console.is_terminal and os.isatty(1)):
773
+ return None
774
+
775
+ # Build display corpus and a reverse map
776
+ labels = []
777
+ by_label: dict[str, Any] = {}
778
+ for resource in resources:
779
+ name = getattr(resource, "name", "Unknown")
780
+ _id = getattr(resource, "id", "Unknown")
781
+ # Create a display label similar to _row_display
782
+ label_parts = []
783
+ if name and name != "Unknown":
784
+ label_parts.append(name)
785
+ label_parts.append(f"[{_id[:8]}...]") # Show first 8 chars of ID
786
+ label = " • ".join(label_parts)
787
+
788
+ # Ensure uniqueness
789
+ if label in by_label:
790
+ i = 2
791
+ base = label
792
+ while f"{base} #{i}" in by_label:
793
+ i += 1
794
+ label = f"{base} #{i}"
795
+ labels.append(label)
796
+ by_label[label] = resource
797
+
798
+ # Create fuzzy completer
799
+ class FuzzyCompleter:
800
+ def __init__(self, words: list[str]):
801
+ self.words = words
802
+
803
+ def get_completions(self, document, complete_event):
804
+ word = document.get_word_before_cursor()
805
+ if not word:
806
+ return
807
+
808
+ word_lower = word.lower()
809
+ for label in self.words:
810
+ label_lower = label.lower()
811
+ # Fuzzy match logic
812
+ if self._fuzzy_match(word_lower, label_lower):
813
+ yield Completion(label, start_position=-len(word))
814
+
815
+ def _fuzzy_match(self, search: str, target: str) -> bool:
816
+ if not search:
817
+ return True
818
+
819
+ search_idx = 0
820
+ for char in target:
821
+ if search_idx < len(search) and search[search_idx] == char:
822
+ search_idx += 1
823
+ if search_idx == len(search):
824
+ return True
825
+ return False
826
+
827
+ completer = FuzzyCompleter(labels)
828
+
829
+ try:
830
+ answer = prompt(
831
+ message=f"Find 🤖 {resource_type.title()}: ",
832
+ completer=completer,
833
+ complete_in_thread=True,
834
+ complete_while_typing=True,
835
+ )
836
+ except (KeyboardInterrupt, EOFError):
837
+ return None
838
+
839
+ if not answer:
840
+ return None
841
+
842
+ # Exact label match
843
+ if answer in by_label:
844
+ return by_label[answer]
845
+
846
+ # Fuzzy search fallback
847
+ best_match = None
848
+ best_score = -1
849
+
850
+ for label in labels:
851
+ score = _fuzzy_score(answer.lower(), label.lower())
852
+ if score > best_score:
853
+ best_score = score
854
+ best_match = label
855
+
856
+ if best_match and best_score > 0:
857
+ return by_label[best_match]
858
+
859
+ return None
860
+
861
+
862
+ def resolve_resource(
863
+ ctx,
864
+ ref: str,
865
+ *,
866
+ get_by_id,
867
+ find_by_name,
868
+ label: str,
869
+ select: int | None = None,
870
+ interface_preference: str = "fuzzy",
871
+ ):
872
+ """Resolve resource reference (ID or name) with ambiguity handling.
873
+
874
+ Args:
875
+ ctx: Click context
876
+ ref: Resource reference (ID or name)
877
+ get_by_id: Function to get resource by ID
878
+ find_by_name: Function to find resources by name
879
+ label: Resource type label for error messages
880
+ select: Optional selection index for ambiguity resolution
881
+ interface_preference: "fuzzy" for fuzzy picker, "questionary" for up/down list
882
+
883
+ Returns:
884
+ Resolved resource object
885
+ """
886
+ if is_uuid(ref):
887
+ return get_by_id(ref)
888
+
889
+ # Find resources by name
890
+ matches = find_by_name(name=ref)
891
+ if not matches:
892
+ raise click.ClickException(f"{label} '{ref}' not found")
893
+
894
+ if len(matches) == 1:
895
+ return matches[0]
896
+
897
+ # Multiple matches - handle ambiguity
898
+ if select:
899
+ idx = int(select) - 1
900
+ if not (0 <= idx < len(matches)):
901
+ raise click.ClickException(f"--select must be 1..{len(matches)}")
902
+ return matches[idx]
903
+
904
+ # Choose interface based on preference
905
+ if interface_preference == "fuzzy":
906
+ # Use fuzzy picker for modern UX
907
+ picked = _fuzzy_pick_for_resources(matches, label.lower(), ref)
908
+ if picked:
909
+ return picked
910
+ # Fallback to original ambiguity handler if fuzzy picker fails
911
+ return handle_ambiguous_resource(ctx, label.lower(), ref, matches)
912
+ else:
913
+ # Use questionary interface for traditional up/down selection
914
+ return handle_ambiguous_resource(ctx, label.lower(), ref, matches)
915
+
916
+
688
917
  def handle_ambiguous_resource(
689
918
  ctx, resource_type: str, ref: str, matches: list[Any]
690
919
  ) -> Any:
@@ -712,7 +941,9 @@ def handle_ambiguous_resource(
712
941
 
713
942
  # Fallback numeric prompt
714
943
  console.print(
715
- f"[yellow]Multiple {resource_type.replace('{', '{{').replace('}', '}}')}s found matching '{ref.replace('{', '{{').replace('}', '}}')}':[/yellow]"
944
+ Text(
945
+ f"[yellow]Multiple {resource_type.replace('{', '{{').replace('}', '}}')}s found matching '{ref.replace('{', '{{').replace('}', '}}')}':[/yellow]"
946
+ )
716
947
  )
717
948
  table = Table(
718
949
  title=f"Select {resource_type.replace('{', '{{').replace('}', '}}').title()}",
@@ -5,6 +5,8 @@ Authors:
5
5
  Raymond Christopher (raymond.christopher@gdplabs.id)
6
6
  """
7
7
 
8
+ from typing import Any
9
+
8
10
  from glaip_sdk.client.agents import AgentClient
9
11
  from glaip_sdk.client.base import BaseClient
10
12
  from glaip_sdk.client.mcps import MCPClient
@@ -104,10 +106,18 @@ class Client(BaseClient):
104
106
  return tool
105
107
 
106
108
  def create_tool_from_code(
107
- self, name: str, code: str, framework: str = "langchain"
109
+ self,
110
+ name: str,
111
+ code: str,
112
+ framework: str = "langchain",
113
+ *,
114
+ description: str | None = None,
115
+ tags: list[str] | None = None,
108
116
  ) -> Tool:
109
117
  """Create a new tool plugin from code string."""
110
- tool = self.tools.create_tool_from_code(name, code, framework)
118
+ tool = self.tools.create_tool_from_code(
119
+ name, code, framework, description=description, tags=tags
120
+ )
111
121
  tool._set_client(self)
112
122
  return tool
113
123
 
@@ -119,6 +129,16 @@ class Client(BaseClient):
119
129
  """Delete a tool by ID."""
120
130
  return self.tools.delete_tool(tool_id)
121
131
 
132
+ def get_tool_script(self, tool_id: str) -> str:
133
+ """Get tool script content."""
134
+ return self.tools.get_tool_script(tool_id)
135
+
136
+ def update_tool_via_file(self, tool_id: str, file_path: str, **kwargs) -> Tool:
137
+ """Update a tool plugin via file upload."""
138
+ tool = self.tools.update_tool_via_file(tool_id, file_path, **kwargs)
139
+ tool._set_client(self)
140
+ return tool
141
+
122
142
  # ---- MCPs
123
143
  def list_mcps(self) -> list[MCP]:
124
144
  mcps = self.mcps.list_mcps()
@@ -150,6 +170,18 @@ class Client(BaseClient):
150
170
  """Update an MCP by ID."""
151
171
  return self.mcps.update_mcp(mcp_id, **kwargs)
152
172
 
173
+ def test_mcp_connection(self, config: dict[str, Any]) -> dict[str, Any]:
174
+ """Test MCP connection using configuration."""
175
+ return self.mcps.test_mcp_connection(config)
176
+
177
+ def test_mcp_connection_from_config(self, config: dict[str, Any]) -> dict[str, Any]:
178
+ """Test MCP connection using configuration (alias)."""
179
+ return self.mcps.test_mcp_connection_from_config(config)
180
+
181
+ def get_mcp_tools_from_config(self, config: dict[str, Any]) -> list[dict[str, Any]]:
182
+ """Fetch tools from MCP configuration without saving."""
183
+ return self.mcps.get_mcp_tools_from_config(config)
184
+
153
185
  def run_agent(self, agent_id: str, message: str, **kwargs) -> str:
154
186
  """Run an agent with a message."""
155
187
  return self.agents.run_agent(agent_id, message, **kwargs)
@@ -160,6 +192,26 @@ class Client(BaseClient):
160
192
  data = self._request("GET", "/language-models")
161
193
  return data or []
162
194
 
195
+ # ---- Timeout propagation ----
196
+ @property
197
+ def timeout(self) -> float: # type: ignore[override]
198
+ return super().timeout
199
+
200
+ @timeout.setter
201
+ def timeout(self, value: float) -> None: # type: ignore[override]
202
+ # Rebuild the root http client
203
+ BaseClient.timeout.fset(self, value) # call parent setter
204
+ # Propagate the new session to sub-clients so they don't hold a closed client
205
+ try:
206
+ if hasattr(self, "agents"):
207
+ self.agents.http_client = self.http_client
208
+ if hasattr(self, "tools"):
209
+ self.tools.http_client = self.http_client
210
+ if hasattr(self, "mcps"):
211
+ self.mcps.http_client = self.http_client
212
+ except Exception:
213
+ pass
214
+
163
215
  # ---- Aliases (back-compat)
164
216
  def get_agent(self, agent_id: str) -> Agent:
165
217
  return self.get_agent_by_id(agent_id)