claude-sdk-tutor 0.1.4__py3-none-any.whl → 0.1.6__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.
app.py CHANGED
@@ -1,6 +1,12 @@
1
1
  import json
2
2
 
3
- from claude_agent_sdk import AssistantMessage, ResultMessage
3
+ from claude_agent_sdk import (
4
+ AssistantMessage,
5
+ ClaudeAgentOptions,
6
+ ResultMessage,
7
+ SystemMessage,
8
+ query,
9
+ )
4
10
  from rich.markdown import Markdown as RichMarkdown
5
11
  from rich.panel import Panel
6
12
  from textual.app import App, ComposeResult
@@ -12,6 +18,10 @@ from claude.claude_agent import (
12
18
  create_claude_client,
13
19
  stream_helpful_claude,
14
20
  )
21
+ from claude.history import CommandHistory
22
+ from claude.mcp_commands import McpAsyncCommand, McpCommandHandler
23
+ from claude.mcp_config import McpConfigManager
24
+ from claude.widgets import HistoryInput
15
25
 
16
26
 
17
27
  class MyApp(App):
@@ -19,8 +29,19 @@ class MyApp(App):
19
29
  super().__init__()
20
30
  self.tutor_mode = True
21
31
  self.web_search_enabled = False
22
- self.client = create_claude_client(
23
- tutor_mode=self.tutor_mode, web_search=self.web_search_enabled
32
+ self.mcp_config = McpConfigManager()
33
+ self.mcp_handler = McpCommandHandler(self.mcp_config)
34
+ self.mcp_add_state: dict | None = None # For interactive /mcp add wizard
35
+ self.client = self._create_client()
36
+ self.history = CommandHistory()
37
+ self._query_running: bool = False # Track if a query is active
38
+
39
+ def _create_client(self):
40
+ """Create a new Claude client with current settings."""
41
+ return create_claude_client(
42
+ tutor_mode=self.tutor_mode,
43
+ web_search=self.web_search_enabled,
44
+ mcp_servers=self.mcp_config.get_enabled_servers_for_sdk(),
24
45
  )
25
46
 
26
47
  CSS = """
@@ -59,7 +80,7 @@ class MyApp(App):
59
80
  yield Static("Welcome to claude SDK tutor!", id="header")
60
81
  yield RichLog(markup=True, highlight=True)
61
82
  yield LoadingIndicator(id="spinner")
62
- yield Input()
83
+ yield HistoryInput(history=self.history)
63
84
  yield Footer()
64
85
 
65
86
  async def on_mount(self) -> None:
@@ -85,8 +106,20 @@ class MyApp(App):
85
106
  log.write(Panel(RichMarkdown(message), title="Slash", border_style="green"))
86
107
 
87
108
  def on_input_submitted(self, event: Input.Submitted) -> None:
88
- self.query_one(Input).value = ""
89
109
  command = event.value.strip()
110
+ self.query_one(HistoryInput).value = ""
111
+ if command:
112
+ self.history.add(command)
113
+
114
+ # Handle interactive MCP add wizard
115
+ if self.mcp_add_state is not None:
116
+ if command.lower() == "/cancel":
117
+ self.mcp_add_state = None
118
+ self.write_slash_message("Cancelled MCP server setup.")
119
+ return
120
+ self._handle_mcp_add_step(command)
121
+ return
122
+
90
123
  if command == "/clear":
91
124
  self.run_worker(self.clear_conversation())
92
125
  return
@@ -99,24 +132,24 @@ class MyApp(App):
99
132
  if command == "/help":
100
133
  self.show_help()
101
134
  return
135
+ if command.lower().startswith("/mcp"):
136
+ self._handle_mcp_command(command)
137
+ return
102
138
  self.write_user_message(event.value)
103
139
  self.query_one("#spinner", LoadingIndicator).display = True
140
+ self._query_running = True
104
141
  self.run_worker(self.get_response(event.value))
105
142
 
106
143
  async def clear_conversation(self) -> None:
107
144
  self.query_one(RichLog).clear()
108
- self.client = create_claude_client(
109
- tutor_mode=self.tutor_mode, web_search=self.web_search_enabled
110
- )
145
+ self.client = self._create_client()
111
146
  await connect_client(self.client)
112
147
  self.write_slash_message("Context cleared")
113
148
 
114
149
  async def toggle_tutor_mode(self) -> None:
115
150
  self.tutor_mode = not self.tutor_mode
116
151
  self.query_one(RichLog).clear()
117
- self.client = create_claude_client(
118
- tutor_mode=self.tutor_mode, web_search=self.web_search_enabled
119
- )
152
+ self.client = self._create_client()
120
153
  await connect_client(self.client)
121
154
  status = "on" if self.tutor_mode else "off"
122
155
  self.write_slash_message(f"Tutor mode {status}")
@@ -124,9 +157,7 @@ class MyApp(App):
124
157
  async def toggle_web_search(self) -> None:
125
158
  self.web_search_enabled = not self.web_search_enabled
126
159
  self.query_one(RichLog).clear()
127
- self.client = create_claude_client(
128
- tutor_mode=self.tutor_mode, web_search=self.web_search_enabled
129
- )
160
+ self.client = self._create_client()
130
161
  await connect_client(self.client)
131
162
  status = "on" if self.web_search_enabled else "off"
132
163
  self.write_slash_message(f"Web search {status}")
@@ -137,9 +168,154 @@ class MyApp(App):
137
168
  - `/help` - Show this help message
138
169
  - `/clear` - Clear conversation history and start fresh
139
170
  - `/tutor` - Toggle tutor mode on/off (guides learning vs gives code)
140
- - `/togglewebsearch` - Toggle web search on/off (allows online lookups)"""
171
+ - `/togglewebsearch` - Toggle web search on/off (allows online lookups)
172
+ - `/mcp` - Manage MCP servers (use `/mcp help` for details)"""
141
173
  self.write_slash_message(help_text)
142
174
 
175
+ def _handle_mcp_command(self, command: str) -> None:
176
+ """Handle /mcp commands."""
177
+ result = self.mcp_handler.handle_command(command)
178
+ if result is None:
179
+ # Start interactive add wizard
180
+ self.mcp_add_state = {"step": 0, "name": "", "type": "", "data": {}}
181
+ self.write_slash_message(
182
+ "**Add MCP Server**\n\nEnter server name (or `/cancel` to abort):"
183
+ )
184
+ elif isinstance(result, McpAsyncCommand):
185
+ # Async command needs connection testing
186
+ self.query_one("#spinner", LoadingIndicator).display = True
187
+ self.run_worker(self._test_mcp_connections(result))
188
+ else:
189
+ self.write_slash_message(result)
190
+
191
+ async def _test_mcp_connections(self, cmd: McpAsyncCommand) -> None:
192
+ """Test MCP server connections and display results."""
193
+ try:
194
+ mcp_servers = self.mcp_config.get_enabled_servers_for_sdk()
195
+ if not mcp_servers:
196
+ if cmd.command == "test":
197
+ self.write_slash_message(
198
+ "**MCP Test**\n\nNo enabled servers to test."
199
+ )
200
+ else:
201
+ self.write_slash_message(
202
+ self.mcp_handler.handle_list(cmd.args, connection_status=None)
203
+ )
204
+ return
205
+
206
+ # Build allowed tools for the test
207
+ allowed_tools = [f"mcp__{name}__*" for name in mcp_servers]
208
+
209
+ options = ClaudeAgentOptions(
210
+ mcp_servers=mcp_servers,
211
+ allowed_tools=allowed_tools,
212
+ max_turns=1,
213
+ )
214
+
215
+ connection_status: dict[str, str] = {}
216
+
217
+ # Run a minimal query just to get the init message with MCP status
218
+ async for message in query(prompt="test", options=options):
219
+ if isinstance(message, SystemMessage) and message.subtype == "init":
220
+ mcp_info = message.data.get("mcp_servers", [])
221
+ for server in mcp_info:
222
+ name = server.get("name", "unknown")
223
+ status = server.get("status", "unknown")
224
+ connection_status[name] = status
225
+ break
226
+
227
+ # Display results based on command
228
+ if cmd.command == "test":
229
+ self.write_slash_message(
230
+ self.mcp_handler.handle_test(cmd.args, connection_status)
231
+ )
232
+ else: # list
233
+ self.write_slash_message(
234
+ self.mcp_handler.handle_list(cmd.args, connection_status)
235
+ )
236
+ except Exception as e:
237
+ self.write_slash_message(f"**Error** testing MCP connections: {e}")
238
+ finally:
239
+ self.query_one("#spinner", LoadingIndicator).display = False
240
+
241
+ def _handle_mcp_add_step(self, user_input: str) -> None:
242
+ """Handle a step in the interactive MCP add wizard."""
243
+ state = self.mcp_add_state
244
+ if state is None:
245
+ return
246
+
247
+ step = state["step"]
248
+
249
+ if step == 0:
250
+ # Got server name
251
+ name = user_input.strip()
252
+ if not name:
253
+ self.write_slash_message("**Error**: Name cannot be empty. Try again:")
254
+ return
255
+ if self.mcp_config.get_server(name):
256
+ self.write_slash_message(
257
+ f"**Error**: Server `{name}` already exists. Enter a different name:"
258
+ )
259
+ return
260
+ state["name"] = name
261
+ state["step"] = 1
262
+ self.write_slash_message(
263
+ "Select server type:\n- `stdio` - Local process\n- `sse` - Server-Sent Events\n- `http` - HTTP endpoint"
264
+ )
265
+
266
+ elif step == 1:
267
+ # Got server type
268
+ server_type = user_input.strip().lower()
269
+ if server_type not in ("stdio", "sse", "http"):
270
+ self.write_slash_message(
271
+ f"**Error**: Invalid type `{server_type}`. Enter `stdio`, `sse`, or `http`:"
272
+ )
273
+ return
274
+ state["type"] = server_type
275
+ state["step"] = 2
276
+ if server_type == "stdio":
277
+ self.write_slash_message("Enter command to run (e.g., `npx`):")
278
+ else:
279
+ self.write_slash_message("Enter server URL:")
280
+
281
+ elif step == 2:
282
+ # Got command or URL
283
+ value = user_input.strip()
284
+ if not value:
285
+ self.write_slash_message("**Error**: Value cannot be empty. Try again:")
286
+ return
287
+
288
+ if state["type"] == "stdio":
289
+ state["data"]["command"] = value
290
+ state["step"] = 3
291
+ self.write_slash_message(
292
+ "Enter arguments (space-separated, or leave empty):"
293
+ )
294
+ else:
295
+ # SSE or HTTP - URL provided, we're done
296
+ config = {"type": state["type"], "url": value}
297
+ self.mcp_config.add_server(state["name"], config)
298
+ self.write_slash_message(
299
+ f"**Added** server `{state['name']}` ({state['type']})\n\n"
300
+ "Use `/clear` to reconnect with new MCP servers."
301
+ )
302
+ self.mcp_add_state = None
303
+
304
+ elif step == 3:
305
+ # Got args for stdio command
306
+ args = user_input.strip().split() if user_input.strip() else []
307
+ config = {
308
+ "type": "stdio",
309
+ "command": state["data"]["command"],
310
+ "args": args,
311
+ }
312
+ self.mcp_config.add_server(state["name"], config)
313
+ self.write_slash_message(
314
+ f"**Added** server `{state['name']}` (stdio)\n\n"
315
+ "Use `/clear` to reconnect with new MCP servers."
316
+ )
317
+ self.mcp_add_state = None
318
+
143
319
  async def get_response(self, text: str) -> None:
144
320
  try:
145
321
  async for message in stream_helpful_claude(self.client, text):
@@ -155,6 +331,23 @@ class MyApp(App):
155
331
  pass # Might want to add logging later
156
332
  finally:
157
333
  self.query_one("#spinner", LoadingIndicator).display = False
334
+ self._query_running = False
335
+
336
+ def action_cancel_query(self) -> None:
337
+ """Interrupt the current running query using SDK interrupt."""
338
+ if self._query_running:
339
+ self.run_worker(self._interrupt_query())
340
+
341
+ async def _interrupt_query(self) -> None:
342
+ """Send interrupt signal to the Claude SDK client."""
343
+ try:
344
+ await self.client.interrupt()
345
+ self.write_slash_message("Query interrupted.")
346
+ except Exception:
347
+ pass # Ignore errors if not connected or no active query
348
+ finally:
349
+ self._query_running = False
350
+ self.query_one("#spinner", LoadingIndicator).display = False
158
351
 
159
352
 
160
353
  def main():
claude/__init__.py CHANGED
@@ -0,0 +1,4 @@
1
+ from claude.history import CommandHistory
2
+ from claude.widgets import HistoryInput
3
+
4
+ __all__ = ["CommandHistory", "HistoryInput"]
claude/claude_agent.py CHANGED
@@ -13,12 +13,18 @@ Never write complete solutions for them. Instead, help them develop the skills t
13
13
 
14
14
 
15
15
  def create_claude_client(
16
- tutor_mode: bool = True, web_search: bool = False
16
+ tutor_mode: bool = True,
17
+ web_search: bool = False,
18
+ mcp_servers: dict | None = None,
17
19
  ) -> ClaudeSDKClient:
18
20
  tools = ["Read", "Glob", "Grep"]
19
21
  if web_search:
20
22
  tools.extend(["WebSearch", "WebFetch"])
21
- options = ClaudeAgentOptions(allowed_tools=tools)
23
+ if mcp_servers:
24
+ # Allow all tools from each configured MCP server
25
+ for server_name in mcp_servers:
26
+ tools.append(f"mcp__{server_name}__*")
27
+ options = ClaudeAgentOptions(allowed_tools=tools, mcp_servers=mcp_servers or {})
22
28
  if tutor_mode:
23
29
  options.system_prompt = TUTOR_SYSTEM_PROMPT
24
30
  return ClaudeSDKClient(options=options)
claude/history.py ADDED
@@ -0,0 +1,73 @@
1
+ from pathlib import Path
2
+
3
+ from platformdirs import user_data_dir
4
+
5
+
6
+ class CommandHistory:
7
+ """Manages command history with persistence to disk."""
8
+
9
+ MAX_ENTRIES = 1000
10
+
11
+ def __init__(self):
12
+ self.history: list[str] = []
13
+ self.index: int = -1
14
+ self.temp_input: str = ""
15
+ self._history_file = Path(user_data_dir("claude-sdk-tutor")) / "command_history.txt"
16
+ self._load()
17
+
18
+ def _load(self) -> None:
19
+ """Load history from disk."""
20
+ if self._history_file.exists():
21
+ try:
22
+ lines = self._history_file.read_text().splitlines()
23
+ self.history = lines[-self.MAX_ENTRIES :]
24
+ except OSError:
25
+ self.history = []
26
+
27
+ def _save(self) -> None:
28
+ """Save history to disk."""
29
+ try:
30
+ self._history_file.parent.mkdir(parents=True, exist_ok=True)
31
+ self._history_file.write_text("\n".join(self.history[-self.MAX_ENTRIES :]))
32
+ except OSError:
33
+ pass
34
+
35
+ def add(self, command: str) -> None:
36
+ """Add a command to history, skipping consecutive duplicates."""
37
+ command = command.strip()
38
+ if not command:
39
+ return
40
+ if not self.history or self.history[-1] != command:
41
+ self.history.append(command)
42
+ self._save()
43
+ self.reset_navigation()
44
+
45
+ def reset_navigation(self) -> None:
46
+ """Reset navigation state."""
47
+ self.index = -1
48
+ self.temp_input = ""
49
+
50
+ def navigate_up(self, current_input: str) -> str:
51
+ """Navigate to previous command in history."""
52
+ if not self.history:
53
+ return current_input
54
+
55
+ if self.index == -1:
56
+ self.temp_input = current_input
57
+ self.index = len(self.history) - 1
58
+ elif self.index > 0:
59
+ self.index -= 1
60
+
61
+ return self.history[self.index]
62
+
63
+ def navigate_down(self, current_input: str) -> str:
64
+ """Navigate to next command in history, or restore original input."""
65
+ if self.index == -1:
66
+ return current_input
67
+
68
+ if self.index < len(self.history) - 1:
69
+ self.index += 1
70
+ return self.history[self.index]
71
+ else:
72
+ self.index = -1
73
+ return self.temp_input
claude/mcp_commands.py ADDED
@@ -0,0 +1,256 @@
1
+ """MCP command handling for slash commands."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from .mcp_config import McpConfigManager, McpServerEntry
6
+
7
+
8
+ @dataclass
9
+ class McpAsyncCommand:
10
+ """Indicates an async command that needs special handling."""
11
+
12
+ command: str
13
+ args: list[str]
14
+
15
+
16
+ class McpCommandHandler:
17
+ """Handles /mcp slash commands."""
18
+
19
+ def __init__(self, config_manager: McpConfigManager):
20
+ self.config = config_manager
21
+
22
+ def parse_command(self, command: str) -> tuple[str, list[str]]:
23
+ """Parse /mcp command into subcommand and args.
24
+
25
+ Example: "/mcp add myserver" -> ("add", ["myserver"])
26
+ """
27
+ parts = command.strip().split()
28
+ # Remove /mcp prefix if present
29
+ if parts and parts[0].lower() == "/mcp":
30
+ parts = parts[1:]
31
+
32
+ if not parts:
33
+ return "help", []
34
+
35
+ subcommand = parts[0].lower()
36
+ args = parts[1:]
37
+ return subcommand, args
38
+
39
+ def handle_command(self, command: str) -> str | None | McpAsyncCommand:
40
+ """Handle a /mcp command.
41
+
42
+ Returns:
43
+ str: Markdown response to display
44
+ None: Trigger interactive mode
45
+ McpAsyncCommand: Command needs async handling
46
+ """
47
+ subcommand, args = self.parse_command(command)
48
+
49
+ # Commands that need async handling (connection testing)
50
+ if subcommand in ("test", "list"):
51
+ return McpAsyncCommand(command=subcommand, args=args)
52
+
53
+ handlers = {
54
+ "add": self.handle_add,
55
+ "remove": self.handle_remove,
56
+ "enable": self.handle_enable,
57
+ "disable": self.handle_disable,
58
+ "status": self.handle_status,
59
+ "help": self.handle_help,
60
+ }
61
+
62
+ handler = handlers.get(subcommand, self.handle_help)
63
+ return handler(args)
64
+
65
+ def handle_list(
66
+ self, _args: list[str], connection_status: dict[str, str] | None = None
67
+ ) -> str:
68
+ """List all configured MCP servers with optional connection status."""
69
+ servers = self.config.list_servers()
70
+
71
+ if not servers:
72
+ return "**MCP Servers**\n\nNo servers configured. Use `/mcp add` to add one."
73
+
74
+ lines = ["**MCP Servers**\n"]
75
+ lines.append("| Name | Type | Enabled | Connection | Target |")
76
+ lines.append("|------|------|---------|------------|--------|")
77
+
78
+ for server in servers:
79
+ server_type = server.config.get("type", "stdio")
80
+ enabled = "yes" if server.enabled else "no"
81
+ target = self._get_target_display(server)
82
+
83
+ if connection_status and server.name in connection_status:
84
+ conn = connection_status[server.name]
85
+ elif not server.enabled:
86
+ conn = "—"
87
+ else:
88
+ conn = "unknown"
89
+
90
+ lines.append(
91
+ f"| {server.name} | {server_type} | {enabled} | {conn} | {target} |"
92
+ )
93
+
94
+ return "\n".join(lines)
95
+
96
+ def handle_test(
97
+ self, args: list[str], connection_status: dict[str, str]
98
+ ) -> str:
99
+ """Format test results for MCP server connections."""
100
+ if not connection_status:
101
+ return "**MCP Test**\n\nNo enabled servers to test."
102
+
103
+ # Filter to specific server if provided
104
+ if args:
105
+ name = args[0]
106
+ if name not in connection_status:
107
+ server = self.config.get_server(name)
108
+ if not server:
109
+ return f"**Error**: Server `{name}` not found."
110
+ if not server.enabled:
111
+ return f"**Error**: Server `{name}` is disabled."
112
+ return f"**Error**: Server `{name}` was not tested."
113
+ connection_status = {name: connection_status[name]}
114
+
115
+ lines = ["**MCP Connection Test**\n"]
116
+
117
+ connected = 0
118
+ failed = 0
119
+ for name, status in connection_status.items():
120
+ if status == "connected":
121
+ lines.append(f"- `{name}`: **connected**")
122
+ connected += 1
123
+ else:
124
+ lines.append(f"- `{name}`: **{status}**")
125
+ failed += 1
126
+
127
+ lines.append(f"\n**Summary**: {connected} connected, {failed} failed")
128
+ return "\n".join(lines)
129
+
130
+ def _get_target_display(self, server: McpServerEntry) -> str:
131
+ """Get display string for server target."""
132
+ config = server.config
133
+ server_type = config.get("type", "")
134
+
135
+ if server_type == "stdio":
136
+ cmd = config.get("command", "")
137
+ args = config.get("args", [])
138
+ if args:
139
+ return f"{cmd} {' '.join(args[:2])}{'...' if len(args) > 2 else ''}"
140
+ return cmd
141
+ elif server_type in ("sse", "http"):
142
+ return config.get("url", "")
143
+ return "—"
144
+
145
+ def handle_add(self, args: list[str]) -> str | None:
146
+ """Handle /mcp add command.
147
+
148
+ If args provided: /mcp add <name> <type> <command/url> [args...]
149
+ If no args: return None to trigger interactive mode
150
+ """
151
+ if not args:
152
+ return None # Trigger interactive mode
153
+
154
+ if len(args) < 3:
155
+ return "**Error**: Usage: `/mcp add <name> <type> <command|url> [args...]`"
156
+
157
+ name = args[0]
158
+ server_type = args[1].lower()
159
+
160
+ if server_type not in ("stdio", "sse", "http"):
161
+ return f"**Error**: Invalid type `{server_type}`. Must be stdio, sse, or http."
162
+
163
+ if self.config.get_server(name):
164
+ return f"**Error**: Server `{name}` already exists."
165
+
166
+ if server_type == "stdio":
167
+ command = args[2]
168
+ cmd_args = args[3:] if len(args) > 3 else []
169
+ config = {"type": "stdio", "command": command, "args": cmd_args}
170
+ else:
171
+ url = args[2]
172
+ config = {"type": server_type, "url": url}
173
+
174
+ self.config.add_server(name, config)
175
+ return f"**Added** server `{name}` ({server_type})"
176
+
177
+ def handle_remove(self, args: list[str]) -> str:
178
+ """Handle /mcp remove <name> command."""
179
+ if not args:
180
+ return "**Error**: Usage: `/mcp remove <name>`"
181
+
182
+ name = args[0]
183
+ if self.config.remove_server(name):
184
+ return f"**Removed** server `{name}`"
185
+ return f"**Error**: Server `{name}` not found."
186
+
187
+ def handle_enable(self, args: list[str]) -> str:
188
+ """Handle /mcp enable <name> command."""
189
+ if not args:
190
+ return "**Error**: Usage: `/mcp enable <name>`"
191
+
192
+ name = args[0]
193
+ if self.config.enable_server(name):
194
+ return f"**Enabled** server `{name}`"
195
+ return f"**Error**: Server `{name}` not found."
196
+
197
+ def handle_disable(self, args: list[str]) -> str:
198
+ """Handle /mcp disable <name> command."""
199
+ if not args:
200
+ return "**Error**: Usage: `/mcp disable <name>`"
201
+
202
+ name = args[0]
203
+ if self.config.disable_server(name):
204
+ return f"**Disabled** server `{name}`"
205
+ return f"**Error**: Server `{name}` not found."
206
+
207
+ def handle_status(self, args: list[str]) -> str:
208
+ """Handle /mcp status [name] command."""
209
+ if not args:
210
+ # Show summary status
211
+ servers = self.config.list_servers()
212
+ enabled = sum(1 for s in servers if s.enabled)
213
+ return f"**MCP Status**: {enabled}/{len(servers)} servers enabled"
214
+
215
+ name = args[0]
216
+ server = self.config.get_server(name)
217
+ if not server:
218
+ return f"**Error**: Server `{name}` not found."
219
+
220
+ lines = [f"**Server: {name}**\n"]
221
+ lines.append(f"- **Type**: {server.config.get('type', 'unknown')}")
222
+ lines.append(f"- **Status**: {'enabled' if server.enabled else 'disabled'}")
223
+
224
+ server_type = server.config.get("type", "")
225
+ if server_type == "stdio":
226
+ cmd = server.config.get("command", "")
227
+ args_list = server.config.get("args", [])
228
+ full_cmd = f"{cmd} {' '.join(args_list)}".strip()
229
+ lines.append(f"- **Command**: `{full_cmd}`")
230
+ env = server.config.get("env", {})
231
+ if env:
232
+ lines.append(f"- **Env vars**: {', '.join(env.keys())}")
233
+ else:
234
+ url = server.config.get("url", "")
235
+ lines.append(f"- **URL**: {url}")
236
+
237
+ return "\n".join(lines)
238
+
239
+ def handle_help(self, _args: list[str]) -> str:
240
+ """Show help for /mcp commands."""
241
+ return """**MCP Server Commands**
242
+
243
+ - `/mcp list` - List all configured servers with connection status
244
+ - `/mcp test [name]` - Test MCP server connections
245
+ - `/mcp add` - Add a new server (interactive)
246
+ - `/mcp add <name> <type> <cmd|url> [args]` - Add server directly
247
+ - `/mcp remove <name>` - Remove a server
248
+ - `/mcp enable <name>` - Enable a server
249
+ - `/mcp disable <name>` - Disable a server
250
+ - `/mcp status [name]` - Show server config details
251
+ - `/mcp help` - Show this help
252
+
253
+ **Server Types**
254
+ - `stdio` - Local process (command + args)
255
+ - `sse` - Server-Sent Events endpoint (URL)
256
+ - `http` - HTTP endpoint (URL)"""
claude/mcp_config.py ADDED
@@ -0,0 +1,140 @@
1
+ """MCP Server configuration management."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ @dataclass
12
+ class McpServerEntry:
13
+ """An MCP server configuration entry."""
14
+
15
+ name: str
16
+ enabled: bool
17
+ config: dict[str, Any]
18
+
19
+
20
+ @dataclass
21
+ class McpConfig:
22
+ """Full MCP configuration."""
23
+
24
+ version: int = 1
25
+ servers: dict[str, dict[str, Any]] = field(default_factory=dict)
26
+
27
+
28
+ class McpConfigManager:
29
+ """Manages MCP server configurations with persistent storage."""
30
+
31
+ CONFIG_DIR = Path.home() / ".local" / "share" / "claude-sdk-tutor"
32
+ CONFIG_FILE = CONFIG_DIR / "mcp_servers.json"
33
+
34
+ def __init__(self):
35
+ self._config: McpConfig = McpConfig()
36
+ self._load()
37
+
38
+ def _load(self) -> None:
39
+ """Load configuration from disk."""
40
+ if self.CONFIG_FILE.exists():
41
+ try:
42
+ with open(self.CONFIG_FILE) as f:
43
+ data = json.load(f)
44
+ self._config = McpConfig(
45
+ version=data.get("version", 1),
46
+ servers=data.get("servers", {}),
47
+ )
48
+ except (json.JSONDecodeError, OSError):
49
+ self._config = McpConfig()
50
+ else:
51
+ self._config = McpConfig()
52
+
53
+ def _save(self) -> None:
54
+ """Save configuration to disk."""
55
+ self.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
56
+ with open(self.CONFIG_FILE, "w") as f:
57
+ json.dump(
58
+ {"version": self._config.version, "servers": self._config.servers},
59
+ f,
60
+ indent=2,
61
+ )
62
+
63
+ def list_servers(self) -> list[McpServerEntry]:
64
+ """List all configured servers."""
65
+ entries = []
66
+ for name, data in self._config.servers.items():
67
+ entries.append(
68
+ McpServerEntry(
69
+ name=name,
70
+ enabled=data.get("enabled", True),
71
+ config=data.get("config", {}),
72
+ )
73
+ )
74
+ return entries
75
+
76
+ def get_server(self, name: str) -> McpServerEntry | None:
77
+ """Get a specific server by name."""
78
+ if name not in self._config.servers:
79
+ return None
80
+ data = self._config.servers[name]
81
+ return McpServerEntry(
82
+ name=name,
83
+ enabled=data.get("enabled", True),
84
+ config=data.get("config", {}),
85
+ )
86
+
87
+ def add_server(self, name: str, config: dict[str, Any]) -> None:
88
+ """Add a new server configuration."""
89
+ self._config.servers[name] = {"enabled": True, "config": config}
90
+ self._save()
91
+
92
+ def remove_server(self, name: str) -> bool:
93
+ """Remove a server configuration. Returns True if removed."""
94
+ if name in self._config.servers:
95
+ del self._config.servers[name]
96
+ self._save()
97
+ return True
98
+ return False
99
+
100
+ def enable_server(self, name: str) -> bool:
101
+ """Enable a server. Returns True if server exists."""
102
+ if name in self._config.servers:
103
+ self._config.servers[name]["enabled"] = True
104
+ self._save()
105
+ return True
106
+ return False
107
+
108
+ def disable_server(self, name: str) -> bool:
109
+ """Disable a server. Returns True if server exists."""
110
+ if name in self._config.servers:
111
+ self._config.servers[name]["enabled"] = False
112
+ self._save()
113
+ return True
114
+ return False
115
+
116
+ def _expand_env_vars(self, value: Any) -> Any:
117
+ """Recursively expand environment variables in config values."""
118
+ if isinstance(value, str):
119
+ # Match ${VAR_NAME} pattern
120
+ pattern = r"\$\{([^}]+)\}"
121
+ matches = re.findall(pattern, value)
122
+ result = value
123
+ for var_name in matches:
124
+ env_value = os.environ.get(var_name, "")
125
+ result = result.replace(f"${{{var_name}}}", env_value)
126
+ return result
127
+ elif isinstance(value, dict):
128
+ return {k: self._expand_env_vars(v) for k, v in value.items()}
129
+ elif isinstance(value, list):
130
+ return [self._expand_env_vars(item) for item in value]
131
+ return value
132
+
133
+ def get_enabled_servers_for_sdk(self) -> dict[str, dict[str, Any]]:
134
+ """Get enabled servers in SDK-compatible format with env vars expanded."""
135
+ result = {}
136
+ for name, data in self._config.servers.items():
137
+ if data.get("enabled", True):
138
+ config = data.get("config", {})
139
+ result[name] = self._expand_env_vars(config)
140
+ return result
claude/widgets.py ADDED
@@ -0,0 +1,29 @@
1
+ from textual.binding import Binding
2
+ from textual.widgets import Input
3
+
4
+ from claude.history import CommandHistory
5
+
6
+
7
+ class HistoryInput(Input):
8
+ """Input widget with command history navigation."""
9
+
10
+ BINDINGS = [
11
+ Binding("up", "history_previous", "Previous command", show=False),
12
+ Binding("down", "history_next", "Next command", show=False),
13
+ Binding("escape", "app.cancel_query", "Cancel", show=False),
14
+ Binding("ctrl+c", "app.cancel_query", "Cancel", show=False),
15
+ ]
16
+
17
+ def __init__(self, history: CommandHistory, **kwargs):
18
+ super().__init__(**kwargs)
19
+ self.history = history
20
+
21
+ def action_history_previous(self) -> None:
22
+ """Navigate to previous command in history."""
23
+ self.value = self.history.navigate_up(self.value)
24
+ self.cursor_position = len(self.value)
25
+
26
+ def action_history_next(self) -> None:
27
+ """Navigate to next command in history."""
28
+ self.value = self.history.navigate_down(self.value)
29
+ self.cursor_position = len(self.value)
@@ -1,10 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claude-sdk-tutor
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Add your description here
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.13
7
7
  Requires-Dist: claude-agent-sdk>=0.1.26
8
+ Requires-Dist: platformdirs>=4.0.0
8
9
  Requires-Dist: textual-dev>=1.8.0
9
10
  Requires-Dist: textual>=7.5.0
10
11
  Requires-Dist: watchfiles>=1.1.1
@@ -14,6 +15,15 @@ Description-Content-Type: text/markdown
14
15
 
15
16
  A terminal-based programming tutor powered by Claude. Built with Textual and the Claude Agent SDK.
16
17
 
18
+ ## Features
19
+
20
+ - **Tutor Mode** - Claude guides your learning instead of writing code for you
21
+ - **Web Search** - Optional online lookup capability
22
+ - **MCP Servers** - Extend Claude with external tools via Model Context Protocol
23
+ - **Command History** - Navigate previous commands with up/down arrows (persisted across sessions)
24
+ - **Query Interruption** - Cancel long-running queries with Escape or Ctrl+C
25
+ - **File Access** - Claude can read files in your codebase to provide contextual help
26
+
17
27
  ## Overview
18
28
 
19
29
  Claude Tutor is a TUI (Terminal User Interface) application designed to help you learn programming concepts. Unlike a typical coding assistant, Claude Tutor focuses on teaching rather than writing code for you. It will:
@@ -57,6 +67,16 @@ Type your programming questions in the input field and press Enter to send. Clau
57
67
  - **Grey** - Tool usage (when Claude reads files in your codebase)
58
68
  - **Green** - Slash command feedback
59
69
 
70
+ ### Keyboard Shortcuts
71
+
72
+ | Key | Action |
73
+ |-----|--------|
74
+ | `Enter` | Send message |
75
+ | `Up` / `Down` | Navigate command history |
76
+ | `Escape` or `Ctrl+C` | Cancel running query |
77
+
78
+ Command history is automatically saved between sessions.
79
+
60
80
  ## Slash Commands
61
81
 
62
82
  | Command | Description |
@@ -65,6 +85,45 @@ Type your programming questions in the input field and press Enter to send. Clau
65
85
  | `/clear` | Clears the conversation history and starts fresh. Your settings are preserved. |
66
86
  | `/tutor` | Toggles tutor mode on/off. When on (default), Claude acts as a teacher. When off, Claude responds normally without the tutoring constraints. |
67
87
  | `/togglewebsearch` | Toggles web search on/off. When on, Claude can use WebSearch and WebFetch tools to look up information online. Disabled by default. |
88
+ | `/mcp` | Manage MCP servers. Use `/mcp help` for detailed subcommands. |
89
+
90
+ ## MCP Server Support
91
+
92
+ Claude Tutor supports [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers, allowing you to extend Claude's capabilities with external tools.
93
+
94
+ ### MCP Commands
95
+
96
+ | Command | Description |
97
+ |---------|-------------|
98
+ | `/mcp list` | List all configured servers with connection status |
99
+ | `/mcp test [name]` | Test MCP server connections |
100
+ | `/mcp add` | Add a new server (interactive wizard) |
101
+ | `/mcp add <name> <type> <cmd\|url> [args]` | Add server directly |
102
+ | `/mcp remove <name>` | Remove a server |
103
+ | `/mcp enable <name>` | Enable a disabled server |
104
+ | `/mcp disable <name>` | Disable a server without removing it |
105
+ | `/mcp status [name]` | Show server configuration details |
106
+
107
+ ### Server Types
108
+
109
+ - **stdio** - Local process that communicates via stdin/stdout (e.g., `npx` packages)
110
+ - **sse** - Server-Sent Events endpoint
111
+ - **http** - HTTP endpoint
112
+
113
+ ### Example: Adding an MCP Server
114
+
115
+ ```
116
+ /mcp add filesystem stdio npx -y @anthropic/mcp-filesystem
117
+ ```
118
+
119
+ Or use the interactive wizard:
120
+ ```
121
+ /mcp add
122
+ ```
123
+
124
+ After adding or modifying servers, use `/clear` to reconnect with the updated configuration.
125
+
126
+ MCP server configurations are persisted to `~/.local/share/claude-sdk-tutor/mcp_servers.json`.
68
127
 
69
128
  ## Tech Stack
70
129
 
@@ -0,0 +1,12 @@
1
+ app.py,sha256=i07mRpBc-mrNrmpnhvL3kS-FqcWM7W89Ew6fCKe7I6Y,13444
2
+ claude/__init__.py,sha256=rGXaYQtfDG3XywfxU_vHUR5afA_ArxLxzprR04pSnZM,128
3
+ claude/claude_agent.py,sha256=L-Q1qE1f50UvQ-bckpimHySF5Wek2F1kK9ZfCdwuXdg,1546
4
+ claude/history.py,sha256=-JpVhha552jZkyxuaLbbL2GluQXvFzGmhS6mtB63940,2273
5
+ claude/mcp_commands.py,sha256=O3jKPCkk5l2JuB8aBoZbxE5w1yOX6cHECAVAGX7tKK8,9051
6
+ claude/mcp_config.py,sha256=Ot-S8YweTog80C2MBDjtOdpfwjneaqQmLoTINVdOaxY,4637
7
+ claude/widgets.py,sha256=5f0PwjIBgtUotveqpqAbYYy327PyxUo9y_3wMLTEm7c,1040
8
+ claude_sdk_tutor-0.1.6.dist-info/METADATA,sha256=WBPyB-SaCM9aUwGQa3DKGIXQBXfZUzBxTCplkrPzuLg,4519
9
+ claude_sdk_tutor-0.1.6.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
10
+ claude_sdk_tutor-0.1.6.dist-info/entry_points.txt,sha256=vI78kiiqb59KzHEa8UsnkvCbmCs0IMLXOuO2qiho4U4,46
11
+ claude_sdk_tutor-0.1.6.dist-info/licenses/LICENSE,sha256=KzxybQVVAEGBifrjNj5OGwQ_rsbzCIGPm0xrTL6-VZs,1067
12
+ claude_sdk_tutor-0.1.6.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- app.py,sha256=34d9pmuZ1o-2_sSeEiwoE2BoK2VIIFID2-N9PH6LyOI,5669
2
- claude/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
- claude/claude_agent.py,sha256=-xs3CNr5p2AosKy6UJvf6nTQLdJd8GG7qtS4jd6K_ts,1304
4
- claude_sdk_tutor-0.1.4.dist-info/METADATA,sha256=qMIq_SSfX3_03Okz3uSIu2RJv2ogd60RO5LQ7EuHA1M,2445
5
- claude_sdk_tutor-0.1.4.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
6
- claude_sdk_tutor-0.1.4.dist-info/entry_points.txt,sha256=vI78kiiqb59KzHEa8UsnkvCbmCs0IMLXOuO2qiho4U4,46
7
- claude_sdk_tutor-0.1.4.dist-info/licenses/LICENSE,sha256=KzxybQVVAEGBifrjNj5OGwQ_rsbzCIGPm0xrTL6-VZs,1067
8
- claude_sdk_tutor-0.1.4.dist-info/RECORD,,