tsugite-cli 0.3.3__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 (101) hide show
  1. tsugite/__init__.py +6 -0
  2. tsugite/agent_composition.py +163 -0
  3. tsugite/agent_inheritance.py +479 -0
  4. tsugite/agent_preparation.py +236 -0
  5. tsugite/agent_runner/__init__.py +45 -0
  6. tsugite/agent_runner/helpers.py +106 -0
  7. tsugite/agent_runner/history_integration.py +248 -0
  8. tsugite/agent_runner/metrics.py +100 -0
  9. tsugite/agent_runner/runner.py +1879 -0
  10. tsugite/agent_runner/validation.py +70 -0
  11. tsugite/agent_utils.py +167 -0
  12. tsugite/attachments/__init__.py +65 -0
  13. tsugite/attachments/auto_context.py +199 -0
  14. tsugite/attachments/base.py +34 -0
  15. tsugite/attachments/file.py +51 -0
  16. tsugite/attachments/inline.py +31 -0
  17. tsugite/attachments/storage.py +178 -0
  18. tsugite/attachments/url.py +59 -0
  19. tsugite/attachments/youtube.py +101 -0
  20. tsugite/benchmark/__init__.py +62 -0
  21. tsugite/benchmark/config.py +183 -0
  22. tsugite/benchmark/core.py +292 -0
  23. tsugite/benchmark/discovery.py +377 -0
  24. tsugite/benchmark/evaluators.py +671 -0
  25. tsugite/benchmark/execution.py +657 -0
  26. tsugite/benchmark/metrics.py +204 -0
  27. tsugite/benchmark/reports.py +420 -0
  28. tsugite/benchmark/utils.py +288 -0
  29. tsugite/builtin_agents/chat-assistant.md +53 -0
  30. tsugite/builtin_agents/default.md +140 -0
  31. tsugite/builtin_agents.py +5 -0
  32. tsugite/cache.py +195 -0
  33. tsugite/cli/__init__.py +1042 -0
  34. tsugite/cli/agents.py +148 -0
  35. tsugite/cli/attachments.py +193 -0
  36. tsugite/cli/benchmark.py +663 -0
  37. tsugite/cli/cache.py +113 -0
  38. tsugite/cli/config.py +272 -0
  39. tsugite/cli/helpers.py +534 -0
  40. tsugite/cli/history.py +193 -0
  41. tsugite/cli/init.py +387 -0
  42. tsugite/cli/mcp.py +193 -0
  43. tsugite/cli/tools.py +419 -0
  44. tsugite/config.py +204 -0
  45. tsugite/console.py +48 -0
  46. tsugite/constants.py +21 -0
  47. tsugite/core/__init__.py +19 -0
  48. tsugite/core/agent.py +774 -0
  49. tsugite/core/executor.py +300 -0
  50. tsugite/core/memory.py +67 -0
  51. tsugite/core/tools.py +271 -0
  52. tsugite/docker_cli.py +270 -0
  53. tsugite/events/__init__.py +55 -0
  54. tsugite/events/base.py +46 -0
  55. tsugite/events/bus.py +62 -0
  56. tsugite/events/events.py +224 -0
  57. tsugite/exceptions.py +40 -0
  58. tsugite/history/__init__.py +29 -0
  59. tsugite/history/index.py +210 -0
  60. tsugite/history/models.py +106 -0
  61. tsugite/history/storage.py +157 -0
  62. tsugite/mcp_client.py +219 -0
  63. tsugite/mcp_config.py +174 -0
  64. tsugite/md_agents.py +751 -0
  65. tsugite/models.py +257 -0
  66. tsugite/renderer.py +151 -0
  67. tsugite/shell_tool_config.py +265 -0
  68. tsugite/templates/assistant.md +14 -0
  69. tsugite/tools/__init__.py +265 -0
  70. tsugite/tools/agents.py +312 -0
  71. tsugite/tools/edit_strategies.py +393 -0
  72. tsugite/tools/fs.py +329 -0
  73. tsugite/tools/http.py +239 -0
  74. tsugite/tools/interactive.py +430 -0
  75. tsugite/tools/shell.py +129 -0
  76. tsugite/tools/shell_tools.py +214 -0
  77. tsugite/tools/tasks.py +339 -0
  78. tsugite/tsugite.py +7 -0
  79. tsugite/ui/__init__.py +46 -0
  80. tsugite/ui/base.py +638 -0
  81. tsugite/ui/chat.py +265 -0
  82. tsugite/ui/chat.tcss +92 -0
  83. tsugite/ui/chat_history.py +286 -0
  84. tsugite/ui/helpers.py +102 -0
  85. tsugite/ui/jsonl.py +125 -0
  86. tsugite/ui/live_template.py +529 -0
  87. tsugite/ui/plain.py +419 -0
  88. tsugite/ui/textual_chat.py +642 -0
  89. tsugite/ui/textual_handler.py +225 -0
  90. tsugite/ui/widgets/__init__.py +6 -0
  91. tsugite/ui/widgets/base_scroll_log.py +27 -0
  92. tsugite/ui/widgets/message_list.py +121 -0
  93. tsugite/ui/widgets/thought_log.py +80 -0
  94. tsugite/ui_context.py +90 -0
  95. tsugite/utils.py +367 -0
  96. tsugite/xdg.py +104 -0
  97. tsugite_cli-0.3.3.dist-info/METADATA +325 -0
  98. tsugite_cli-0.3.3.dist-info/RECORD +101 -0
  99. tsugite_cli-0.3.3.dist-info/WHEEL +4 -0
  100. tsugite_cli-0.3.3.dist-info/entry_points.txt +5 -0
  101. tsugite_cli-0.3.3.dist-info/licenses/LICENSE +235 -0
tsugite/mcp_client.py ADDED
@@ -0,0 +1,219 @@
1
+ """MCP client using official Python SDK.
2
+
3
+ Connects to MCP servers (stdio or HTTP) and loads their tools.
4
+ """
5
+
6
+ from typing import List, Optional
7
+
8
+ from mcp import ClientSession
9
+ from mcp.client.stdio import StdioServerParameters, stdio_client
10
+ from mcp.client.streamable_http import streamablehttp_client
11
+
12
+ from tsugite.core.tools import Tool
13
+ from tsugite.mcp_config import MCPServerConfig
14
+
15
+
16
+ class MCPClient:
17
+ """Client for connecting to MCP servers.
18
+
19
+ Handles both stdio and HTTP transports. Use as async context manager.
20
+
21
+ Example:
22
+ # Create config
23
+ config = MCPServerConfig(
24
+ name="basic-memory",
25
+ command="uvx",
26
+ args=["basic-memory-mcp"]
27
+ )
28
+
29
+ # Connect and load tools (preferred - automatic cleanup)
30
+ async with MCPClient(config) as client:
31
+ tools = await client.get_tools()
32
+ # Use tools in agent
33
+ agent = TsugiteAgent(model="...", tools=tools)
34
+
35
+ # Or use connect/disconnect manually if needed
36
+ client = MCPClient(config)
37
+ await client.connect()
38
+ tools = await client.get_tools()
39
+ await client.disconnect()
40
+ """
41
+
42
+ def __init__(self, server_config: MCPServerConfig):
43
+ """Initialize MCP client.
44
+
45
+ Args:
46
+ server_config: Configuration for MCP server
47
+ """
48
+ self.config = server_config
49
+ self.session = None
50
+ self.session_ctx = None
51
+ self.mcp_tools = [] # Raw MCP tool objects
52
+ self.transport = None
53
+ self.transport_ctx = None
54
+
55
+ async def __aenter__(self):
56
+ """Enter async context manager - connect to server."""
57
+ await self.connect()
58
+ return self
59
+
60
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
61
+ """Exit async context manager - disconnect from server."""
62
+ await self.disconnect()
63
+ return False # Don't suppress exceptions
64
+
65
+ async def connect(self):
66
+ """Connect to MCP server and initialize session.
67
+
68
+ Must be called before get_tools() if not using context manager.
69
+ """
70
+ if self.config.is_stdio():
71
+ # Connect via stdio (command-line server)
72
+ server_params = StdioServerParameters(
73
+ command=self.config.command,
74
+ args=self.config.args or [],
75
+ env=self.config.env,
76
+ )
77
+
78
+ # Start stdio transport using async with
79
+ self.transport_ctx = stdio_client(server_params)
80
+ # stdio_client returns a proper async context manager, but pylint can't introspect it
81
+ read, write = await self.transport_ctx.__aenter__() # pylint: disable=no-member
82
+ self.transport = (read, write)
83
+
84
+ elif self.config.is_http():
85
+ # Connect via HTTP using async with
86
+ self.transport_ctx = streamablehttp_client(self.config.url)
87
+ # streamablehttp_client returns a proper async context manager, but pylint can't introspect it
88
+ read, write, _ = await self.transport_ctx.__aenter__() # pylint: disable=no-member
89
+ self.transport = (read, write)
90
+ else:
91
+ raise ValueError(f"Unknown transport type: {self.config.type}")
92
+
93
+ # Create session using async with
94
+ self.session_ctx = ClientSession(read, write)
95
+ self.session = await self.session_ctx.__aenter__()
96
+
97
+ # Initialize connection
98
+ await self.session.initialize()
99
+
100
+ # Load available tools
101
+ response = await self.session.list_tools()
102
+ self.mcp_tools = response.tools
103
+
104
+ async def disconnect(self):
105
+ """Disconnect from MCP server and cleanup resources.
106
+
107
+ Closes the session and transport, preventing resource leaks.
108
+ Should be called when done using the client (unless using context manager).
109
+ """
110
+ # Exit session context manager
111
+ if self.session_ctx:
112
+ try:
113
+ await self.session_ctx.__aexit__(None, None, None)
114
+ except Exception:
115
+ pass # Best effort cleanup
116
+ self.session_ctx = None
117
+ self.session = None
118
+
119
+ # Exit transport context manager
120
+ if self.transport_ctx:
121
+ try:
122
+ # Proper async context manager, but pylint can't introspect it
123
+ await self.transport_ctx.__aexit__(None, None, None) # pylint: disable=no-member
124
+ except Exception:
125
+ pass # Best effort cleanup
126
+ self.transport_ctx = None
127
+ self.transport = None
128
+
129
+ async def get_tools(self, allowed_tools: Optional[List[str]] = None) -> List[Tool]:
130
+ """Get tools from MCP server as Tool objects.
131
+
132
+ Args:
133
+ allowed_tools: If provided, only return these tools
134
+
135
+ Returns:
136
+ List[Tool]: Tools wrapped in our Tool interface
137
+ """
138
+ tools = []
139
+
140
+ for mcp_tool in self.mcp_tools:
141
+ # Filter if needed
142
+ if allowed_tools and mcp_tool.name not in allowed_tools:
143
+ continue
144
+
145
+ # Convert to our Tool format
146
+ tool = Tool(
147
+ name=mcp_tool.name,
148
+ description=mcp_tool.description or "",
149
+ parameters=mcp_tool.inputSchema or {}, # Already JSON schema!
150
+ function=self._create_tool_caller(mcp_tool.name),
151
+ )
152
+ tools.append(tool)
153
+
154
+ return tools
155
+
156
+ def _create_tool_caller(self, tool_name: str):
157
+ """Create async function that calls MCP tool.
158
+
159
+ Returns a function that agents can call.
160
+ """
161
+
162
+ async def call_mcp_tool(**kwargs):
163
+ """Call MCP tool and return result."""
164
+ result = await self.session.call_tool(tool_name, arguments=kwargs)
165
+
166
+ # Parse result content
167
+ # MCP returns list of content items
168
+ if result.content:
169
+ # Handle different content types
170
+ content_items = []
171
+ for item in result.content:
172
+ if item.type == "text":
173
+ content_items.append(item.text)
174
+ elif item.type == "image":
175
+ content_items.append(f"[Image: {item.data}]")
176
+ # Add other types as needed
177
+
178
+ # Return combined content
179
+ return "\n".join(content_items) if content_items else None
180
+
181
+ return None
182
+
183
+ return call_mcp_tool
184
+
185
+
186
+ async def load_mcp_tools(
187
+ server_config: MCPServerConfig, allowed_tools: Optional[List[str]] = None
188
+ ) -> tuple[MCPClient, List[Tool]]:
189
+ """Connect to MCP server and load tools.
190
+
191
+ Returns both the client and tools. The client must remain alive for tools
192
+ to work, so caller should call client.disconnect() when done with the tools.
193
+
194
+ Args:
195
+ server_config: MCP server configuration
196
+ allowed_tools: Optional list of tool names to load
197
+
198
+ Returns:
199
+ tuple[MCPClient, List[Tool]]: Client and tools from MCP server
200
+
201
+ Example:
202
+ from tsugite.mcp_config import load_mcp_config
203
+
204
+ # Load config
205
+ config = load_mcp_config()
206
+ server_config = config["basic-memory"]
207
+
208
+ # Load tools and keep client alive
209
+ client, tools = await load_mcp_tools(server_config)
210
+
211
+ # Use tools...
212
+
213
+ # Cleanup when done
214
+ await client.disconnect()
215
+ """
216
+ client = MCPClient(server_config)
217
+ await client.connect()
218
+ tools = await client.get_tools(allowed_tools)
219
+ return client, tools
tsugite/mcp_config.py ADDED
@@ -0,0 +1,174 @@
1
+ """MCP server configuration loading and management."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Dict, List, Optional
6
+
7
+ from pydantic import BaseModel, ConfigDict, model_validator
8
+
9
+ from .xdg import get_xdg_config_path, get_xdg_write_path
10
+
11
+
12
+ class MCPServerConfig(BaseModel):
13
+ """Configuration for a single MCP server."""
14
+
15
+ model_config = ConfigDict(
16
+ extra="forbid",
17
+ str_strip_whitespace=True,
18
+ )
19
+
20
+ name: str
21
+ command: Optional[str] = None
22
+ args: Optional[List[str]] = None
23
+ env: Optional[Dict[str, str]] = None
24
+ url: Optional[str] = None
25
+ type: Optional[str] = None
26
+
27
+ @model_validator(mode="after")
28
+ def validate_server_config(self) -> "MCPServerConfig":
29
+ """Validate server configuration and auto-detect type."""
30
+ if self.type is None:
31
+ if self.command:
32
+ self.type = "stdio"
33
+ elif self.url:
34
+ self.type = "http"
35
+ else:
36
+ raise ValueError(f"Server '{self.name}' must have either 'command' or 'url' specified")
37
+
38
+ if self.type == "stdio" and not self.command:
39
+ raise ValueError(f"Stdio server '{self.name}' must have 'command' specified")
40
+
41
+ if self.type == "http" and not self.url:
42
+ raise ValueError(f"HTTP server '{self.name}' must have 'url' specified")
43
+
44
+ return self
45
+
46
+ def is_stdio(self) -> bool:
47
+ return self.type == "stdio"
48
+
49
+ def is_http(self) -> bool:
50
+ return self.type == "http"
51
+
52
+
53
+ def get_default_config_path() -> Path:
54
+ return get_xdg_config_path("mcp.json")
55
+
56
+
57
+ def get_config_path_for_write() -> Path:
58
+ return get_xdg_write_path("mcp.json")
59
+
60
+
61
+ def load_mcp_config(path: Optional[Path] = None) -> Dict[str, MCPServerConfig]:
62
+ """Load MCP server configurations from JSON file.
63
+
64
+ Args:
65
+ path: Path to mcp.json file. If None, uses default ~/.tsugite/mcp.json
66
+
67
+ Returns:
68
+ Dictionary mapping server names to MCPServerConfig objects.
69
+ Returns empty dict if file doesn't exist or is invalid.
70
+ """
71
+ if path is None:
72
+ path = get_default_config_path()
73
+
74
+ if not path.exists():
75
+ return {}
76
+
77
+ try:
78
+ with open(path, "r", encoding="utf-8") as f:
79
+ data = json.load(f)
80
+
81
+ if "mcpServers" not in data:
82
+ print(f"Warning: No 'mcpServers' key found in {path}")
83
+ return {}
84
+
85
+ servers = {}
86
+ for name, config in data["mcpServers"].items():
87
+ try:
88
+ servers[name] = MCPServerConfig(
89
+ name=name,
90
+ command=config.get("command"),
91
+ args=config.get("args"),
92
+ env=config.get("env"),
93
+ url=config.get("url"),
94
+ type=config.get("type"),
95
+ )
96
+ except ValueError as e:
97
+ print(f"Warning: Invalid config for server '{name}': {e}")
98
+ continue
99
+
100
+ return servers
101
+
102
+ except json.JSONDecodeError as e:
103
+ print(f"Warning: Failed to parse MCP config at {path}: {e}")
104
+ return {}
105
+ except Exception as e:
106
+ print(f"Warning: Failed to load MCP config from {path}: {e}")
107
+ return {}
108
+
109
+
110
+ def save_mcp_config(servers: Dict[str, MCPServerConfig], path: Optional[Path] = None) -> None:
111
+ """Save MCP server configurations to JSON file.
112
+
113
+ Args:
114
+ servers: Dictionary mapping server names to MCPServerConfig objects
115
+ path: Path to mcp.json file. If None, uses appropriate config location
116
+
117
+ Raises:
118
+ IOError: If file cannot be written
119
+ """
120
+ if path is None:
121
+ path = get_config_path_for_write()
122
+
123
+ # Ensure directory exists
124
+ path.parent.mkdir(parents=True, exist_ok=True)
125
+
126
+ # Convert MCPServerConfig objects to JSON-serializable dicts
127
+ config_data = {"mcpServers": {}}
128
+
129
+ for name, server in servers.items():
130
+ # Use Pydantic's model_dump to serialize, excluding None values and the name field
131
+ server_dict = server.model_dump(exclude_none=True, exclude={"name"})
132
+
133
+ # For stdio servers, don't include 'type' field (it's implicit)
134
+ if server.is_stdio() and "type" in server_dict:
135
+ del server_dict["type"]
136
+
137
+ config_data["mcpServers"][name] = server_dict
138
+
139
+ # Write with pretty formatting
140
+ with open(path, "w", encoding="utf-8") as f:
141
+ json.dump(config_data, f, indent=2)
142
+
143
+
144
+ def add_server_to_config(server: MCPServerConfig, path: Optional[Path] = None, overwrite: bool = False) -> bool:
145
+ """Add or update an MCP server in the configuration file.
146
+
147
+ Args:
148
+ server: MCPServerConfig object to add
149
+ path: Path to mcp.json file. If None, uses appropriate config location
150
+ overwrite: If True, overwrite existing server. If False, raise error if exists.
151
+
152
+ Returns:
153
+ True if server was added/updated successfully
154
+
155
+ Raises:
156
+ ValueError: If server already exists and overwrite is False
157
+ """
158
+ if path is None:
159
+ path = get_config_path_for_write()
160
+
161
+ # Load existing config
162
+ servers = load_mcp_config(path)
163
+
164
+ # Check if server already exists
165
+ if server.name in servers and not overwrite:
166
+ raise ValueError(f"Server '{server.name}' already exists. Use --force to overwrite.")
167
+
168
+ # Add/update server
169
+ servers[server.name] = server
170
+
171
+ # Save config
172
+ save_mcp_config(servers, path)
173
+
174
+ return True