claude-sdk-tutor 0.1.5__py3-none-any.whl → 0.1.7__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,11 +1,18 @@
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
7
13
  from textual.containers import Vertical
8
- from textual.widgets import Static, Footer, Input, RichLog, LoadingIndicator
14
+ from rich.box import ROUNDED
15
+ from textual.widgets import Static, Footer, Input, RichLog
9
16
 
10
17
  from claude.claude_agent import (
11
18
  connect_client,
@@ -13,85 +20,123 @@ from claude.claude_agent import (
13
20
  stream_helpful_claude,
14
21
  )
15
22
  from claude.history import CommandHistory
16
- from claude.widgets import HistoryInput
23
+ from claude.mcp_commands import McpAsyncCommand, McpCommandHandler
24
+ from claude.mcp_config import McpConfigManager
25
+ from claude.widgets import ASCIISpinner, HistoryInput, StatusBar
26
+
27
+
28
+ # Message border colors (vivid)
29
+ USER_COLOR = "#00aaff" # Vivid cyan-blue
30
+ CLAUDE_COLOR = "#ff3333" # Vivid red
31
+ TOOL_COLOR = "#cccccc" # Bright grey
32
+ SYSTEM_COLOR = "#33ff66" # Vivid green
33
+
34
+ HEADER_TEXT = "Claude SDK Tutor"
17
35
 
18
36
 
19
37
  class MyApp(App):
38
+ CSS_PATH = "phosphor.tcss"
39
+
20
40
  def __init__(self):
21
41
  super().__init__()
22
42
  self.tutor_mode = True
23
43
  self.web_search_enabled = False
24
- self.client = create_claude_client(
25
- tutor_mode=self.tutor_mode, web_search=self.web_search_enabled
26
- )
44
+ self.mcp_config = McpConfigManager()
45
+ self.mcp_handler = McpCommandHandler(self.mcp_config)
46
+ self.mcp_add_state: dict | None = None # For interactive /mcp add wizard
47
+ self.client = self._create_client()
27
48
  self.history = CommandHistory()
49
+ self._query_running: bool = False # Track if a query is active
28
50
 
29
- CSS = """
30
- #main {
31
- height: 100%;
32
- }
33
- Input {
34
- height: auto;
35
- margin-top: 1;
36
- margin-left: 3;
37
- margin-right: 3;
38
- margin-bottom: 1;
39
- }
40
- #header {
41
- content-align: center middle;
42
- width: 100%;
43
- margin-top: 1;
44
- margin-bottom: 1;
45
- height: auto;
46
- }
47
- RichLog {
48
- background: $boost;
49
- margin-left: 3;
50
- margin-right: 3;
51
- height: 1fr;
52
- }
53
- LoadingIndicator {
54
- height: auto;
55
- margin-left: 3;
56
- margin-right: 3;
57
- }
58
- """
51
+ def _create_client(self):
52
+ """Create a new Claude client with current settings."""
53
+ return create_claude_client(
54
+ tutor_mode=self.tutor_mode,
55
+ web_search=self.web_search_enabled,
56
+ mcp_servers=self.mcp_config.get_enabled_servers_for_sdk(),
57
+ )
59
58
 
60
59
  def compose(self) -> ComposeResult:
61
60
  with Vertical(id="main"):
62
- yield Static("Welcome to claude SDK tutor!", id="header")
61
+ yield Static(HEADER_TEXT, id="header")
62
+ yield StatusBar(id="status-bar")
63
63
  yield RichLog(markup=True, highlight=True)
64
- yield LoadingIndicator(id="spinner")
65
- yield HistoryInput(history=self.history)
64
+ yield ASCIISpinner(id="spinner")
65
+ yield HistoryInput(
66
+ history=self.history,
67
+ placeholder="Type a message or /help for commands...",
68
+ )
66
69
  yield Footer()
67
70
 
68
71
  async def on_mount(self) -> None:
69
- self.query_one("#spinner", LoadingIndicator).display = False
72
+ self.query_one("#spinner", ASCIISpinner).display = False
73
+ self._update_status_bar()
70
74
  await connect_client(self.client)
71
75
 
76
+ def _update_status_bar(self) -> None:
77
+ """Update the status bar with current mode states."""
78
+ status_bar = self.query_one("#status-bar", StatusBar)
79
+ status_bar.tutor_on = self.tutor_mode
80
+ status_bar.web_on = self.web_search_enabled
81
+ status_bar.mcp_count = len(self.mcp_config.get_enabled_servers_for_sdk())
82
+
72
83
  def write_user_message(self, message: str) -> None:
73
84
  log = self.query_one(RichLog)
74
- log.write(Panel(RichMarkdown(message), title="You", border_style="dodger_blue1"))
85
+ log.write(Panel(
86
+ RichMarkdown(message),
87
+ title="You",
88
+ border_style=USER_COLOR,
89
+ box=ROUNDED,
90
+ padding=(1, 2),
91
+ ))
75
92
 
76
93
  def write_system_message(self, message: str) -> None:
77
94
  log = self.query_one(RichLog)
78
- log.write(Panel(RichMarkdown(message), title="Claude", border_style="red"))
95
+ log.write(Panel(
96
+ RichMarkdown(message),
97
+ title="Claude",
98
+ border_style=CLAUDE_COLOR,
99
+ box=ROUNDED,
100
+ padding=(1, 2),
101
+ ))
79
102
 
80
103
  def write_tool_message(self, name: str, input: dict) -> None:
81
104
  log = self.query_one(RichLog)
82
105
  input_str = json.dumps(input, indent=2)
83
106
  content = f"**{name}**\n```json\n{input_str}\n```"
84
- log.write(Panel(RichMarkdown(content), title="Tool", border_style="grey50"))
107
+ log.write(Panel(
108
+ RichMarkdown(content),
109
+ title="Tool",
110
+ border_style=TOOL_COLOR,
111
+ box=ROUNDED,
112
+ padding=(1, 2),
113
+ ))
85
114
 
86
115
  def write_slash_message(self, message: str) -> None:
87
116
  log = self.query_one(RichLog)
88
- log.write(Panel(RichMarkdown(message), title="Slash", border_style="green"))
117
+ log.write(Panel(
118
+ RichMarkdown(message),
119
+ title="System",
120
+ border_style=SYSTEM_COLOR,
121
+ box=ROUNDED,
122
+ padding=(1, 2),
123
+ ))
89
124
 
90
125
  def on_input_submitted(self, event: Input.Submitted) -> None:
91
126
  command = event.value.strip()
92
127
  self.query_one(HistoryInput).value = ""
93
128
  if command:
94
129
  self.history.add(command)
130
+
131
+ # Handle interactive MCP add wizard
132
+ if self.mcp_add_state is not None:
133
+ if command.lower() == "/cancel":
134
+ self.mcp_add_state = None
135
+ self.write_slash_message("Cancelled MCP server setup.")
136
+ return
137
+ self._handle_mcp_add_step(command)
138
+ return
139
+
95
140
  if command == "/clear":
96
141
  self.run_worker(self.clear_conversation())
97
142
  return
@@ -104,36 +149,37 @@ class MyApp(App):
104
149
  if command == "/help":
105
150
  self.show_help()
106
151
  return
152
+ if command.lower().startswith("/mcp"):
153
+ self._handle_mcp_command(command)
154
+ return
107
155
  self.write_user_message(event.value)
108
- self.query_one("#spinner", LoadingIndicator).display = True
156
+ self.query_one("#spinner", ASCIISpinner).start("Processing query...")
157
+ self._query_running = True
109
158
  self.run_worker(self.get_response(event.value))
110
159
 
111
160
  async def clear_conversation(self) -> None:
112
161
  self.query_one(RichLog).clear()
113
- self.client = create_claude_client(
114
- tutor_mode=self.tutor_mode, web_search=self.web_search_enabled
115
- )
162
+ self.client = self._create_client()
116
163
  await connect_client(self.client)
164
+ self._update_status_bar()
117
165
  self.write_slash_message("Context cleared")
118
166
 
119
167
  async def toggle_tutor_mode(self) -> None:
120
168
  self.tutor_mode = not self.tutor_mode
121
169
  self.query_one(RichLog).clear()
122
- self.client = create_claude_client(
123
- tutor_mode=self.tutor_mode, web_search=self.web_search_enabled
124
- )
170
+ self.client = self._create_client()
125
171
  await connect_client(self.client)
126
- status = "on" if self.tutor_mode else "off"
172
+ self._update_status_bar()
173
+ status = "enabled" if self.tutor_mode else "disabled"
127
174
  self.write_slash_message(f"Tutor mode {status}")
128
175
 
129
176
  async def toggle_web_search(self) -> None:
130
177
  self.web_search_enabled = not self.web_search_enabled
131
178
  self.query_one(RichLog).clear()
132
- self.client = create_claude_client(
133
- tutor_mode=self.tutor_mode, web_search=self.web_search_enabled
134
- )
179
+ self.client = self._create_client()
135
180
  await connect_client(self.client)
136
- status = "on" if self.web_search_enabled else "off"
181
+ self._update_status_bar()
182
+ status = "enabled" if self.web_search_enabled else "disabled"
137
183
  self.write_slash_message(f"Web search {status}")
138
184
 
139
185
  def show_help(self) -> None:
@@ -142,9 +188,154 @@ class MyApp(App):
142
188
  - `/help` - Show this help message
143
189
  - `/clear` - Clear conversation history and start fresh
144
190
  - `/tutor` - Toggle tutor mode on/off (guides learning vs gives code)
145
- - `/togglewebsearch` - Toggle web search on/off (allows online lookups)"""
191
+ - `/togglewebsearch` - Toggle web search on/off (allows online lookups)
192
+ - `/mcp` - Manage MCP servers (use `/mcp help` for details)"""
146
193
  self.write_slash_message(help_text)
147
194
 
195
+ def _handle_mcp_command(self, command: str) -> None:
196
+ """Handle /mcp commands."""
197
+ result = self.mcp_handler.handle_command(command)
198
+ if result is None:
199
+ # Start interactive add wizard
200
+ self.mcp_add_state = {"step": 0, "name": "", "type": "", "data": {}}
201
+ self.write_slash_message(
202
+ "**Add MCP Server**\n\nEnter server name (or `/cancel` to abort):"
203
+ )
204
+ elif isinstance(result, McpAsyncCommand):
205
+ # Async command needs connection testing
206
+ self.query_one("#spinner", ASCIISpinner).start("Testing MCP connections...")
207
+ self.run_worker(self._test_mcp_connections(result))
208
+ else:
209
+ self.write_slash_message(result)
210
+
211
+ async def _test_mcp_connections(self, cmd: McpAsyncCommand) -> None:
212
+ """Test MCP server connections and display results."""
213
+ try:
214
+ mcp_servers = self.mcp_config.get_enabled_servers_for_sdk()
215
+ if not mcp_servers:
216
+ if cmd.command == "test":
217
+ self.write_slash_message(
218
+ "**MCP Test**\n\nNo enabled servers to test."
219
+ )
220
+ else:
221
+ self.write_slash_message(
222
+ self.mcp_handler.handle_list(cmd.args, connection_status=None)
223
+ )
224
+ return
225
+
226
+ # Build allowed tools for the test
227
+ allowed_tools = [f"mcp__{name}__*" for name in mcp_servers]
228
+
229
+ options = ClaudeAgentOptions(
230
+ mcp_servers=mcp_servers,
231
+ allowed_tools=allowed_tools,
232
+ max_turns=1,
233
+ )
234
+
235
+ connection_status: dict[str, str] = {}
236
+
237
+ # Run a minimal query just to get the init message with MCP status
238
+ async for message in query(prompt="test", options=options):
239
+ if isinstance(message, SystemMessage) and message.subtype == "init":
240
+ mcp_info = message.data.get("mcp_servers", [])
241
+ for server in mcp_info:
242
+ name = server.get("name", "unknown")
243
+ status = server.get("status", "unknown")
244
+ connection_status[name] = status
245
+ break
246
+
247
+ # Display results based on command
248
+ if cmd.command == "test":
249
+ self.write_slash_message(
250
+ self.mcp_handler.handle_test(cmd.args, connection_status)
251
+ )
252
+ else: # list
253
+ self.write_slash_message(
254
+ self.mcp_handler.handle_list(cmd.args, connection_status)
255
+ )
256
+ except Exception as e:
257
+ self.write_slash_message(f"**Error** testing MCP connections: {e}")
258
+ finally:
259
+ self.query_one("#spinner", ASCIISpinner).stop()
260
+
261
+ def _handle_mcp_add_step(self, user_input: str) -> None:
262
+ """Handle a step in the interactive MCP add wizard."""
263
+ state = self.mcp_add_state
264
+ if state is None:
265
+ return
266
+
267
+ step = state["step"]
268
+
269
+ if step == 0:
270
+ # Got server name
271
+ name = user_input.strip()
272
+ if not name:
273
+ self.write_slash_message("**Error**: Name cannot be empty. Try again:")
274
+ return
275
+ if self.mcp_config.get_server(name):
276
+ self.write_slash_message(
277
+ f"**Error**: Server `{name}` already exists. Enter a different name:"
278
+ )
279
+ return
280
+ state["name"] = name
281
+ state["step"] = 1
282
+ self.write_slash_message(
283
+ "Select server type:\n- `stdio` - Local process\n- `sse` - Server-Sent Events\n- `http` - HTTP endpoint"
284
+ )
285
+
286
+ elif step == 1:
287
+ # Got server type
288
+ server_type = user_input.strip().lower()
289
+ if server_type not in ("stdio", "sse", "http"):
290
+ self.write_slash_message(
291
+ f"**Error**: Invalid type `{server_type}`. Enter `stdio`, `sse`, or `http`:"
292
+ )
293
+ return
294
+ state["type"] = server_type
295
+ state["step"] = 2
296
+ if server_type == "stdio":
297
+ self.write_slash_message("Enter command to run (e.g., `npx`):")
298
+ else:
299
+ self.write_slash_message("Enter server URL:")
300
+
301
+ elif step == 2:
302
+ # Got command or URL
303
+ value = user_input.strip()
304
+ if not value:
305
+ self.write_slash_message("**Error**: Value cannot be empty. Try again:")
306
+ return
307
+
308
+ if state["type"] == "stdio":
309
+ state["data"]["command"] = value
310
+ state["step"] = 3
311
+ self.write_slash_message(
312
+ "Enter arguments (space-separated, or leave empty):"
313
+ )
314
+ else:
315
+ # SSE or HTTP - URL provided, we're done
316
+ config = {"type": state["type"], "url": value}
317
+ self.mcp_config.add_server(state["name"], config)
318
+ self.write_slash_message(
319
+ f"**Added** server `{state['name']}` ({state['type']})\n\n"
320
+ "Use `/clear` to reconnect with new MCP servers."
321
+ )
322
+ self.mcp_add_state = None
323
+
324
+ elif step == 3:
325
+ # Got args for stdio command
326
+ args = user_input.strip().split() if user_input.strip() else []
327
+ config = {
328
+ "type": "stdio",
329
+ "command": state["data"]["command"],
330
+ "args": args,
331
+ }
332
+ self.mcp_config.add_server(state["name"], config)
333
+ self.write_slash_message(
334
+ f"**Added** server `{state['name']}` (stdio)\n\n"
335
+ "Use `/clear` to reconnect with new MCP servers."
336
+ )
337
+ self.mcp_add_state = None
338
+
148
339
  async def get_response(self, text: str) -> None:
149
340
  try:
150
341
  async for message in stream_helpful_claude(self.client, text):
@@ -159,7 +350,24 @@ class MyApp(App):
159
350
  elif isinstance(message, ResultMessage):
160
351
  pass # Might want to add logging later
161
352
  finally:
162
- self.query_one("#spinner", LoadingIndicator).display = False
353
+ self.query_one("#spinner", ASCIISpinner).stop()
354
+ self._query_running = False
355
+
356
+ def action_cancel_query(self) -> None:
357
+ """Interrupt the current running query using SDK interrupt."""
358
+ if self._query_running:
359
+ self.run_worker(self._interrupt_query())
360
+
361
+ async def _interrupt_query(self) -> None:
362
+ """Send interrupt signal to the Claude SDK client."""
363
+ try:
364
+ await self.client.interrupt()
365
+ self.write_slash_message("Query interrupted.")
366
+ except Exception:
367
+ pass # Ignore errors if not connected or no active query
368
+ finally:
369
+ self._query_running = False
370
+ self.query_one("#spinner", ASCIISpinner).stop()
163
371
 
164
372
 
165
373
  def main():
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/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 CHANGED
@@ -1,15 +1,75 @@
1
1
  from textual.binding import Binding
2
- from textual.widgets import Input
2
+ from textual.reactive import reactive
3
+ from textual.widgets import Input, Static
3
4
 
4
5
  from claude.history import CommandHistory
5
6
 
6
7
 
8
+ class StatusBar(Static):
9
+ """Reactive status bar showing tutor/web/mcp states."""
10
+
11
+ tutor_on: reactive[bool] = reactive(True)
12
+ web_on: reactive[bool] = reactive(False)
13
+ mcp_count: reactive[int] = reactive(0)
14
+
15
+ def render(self) -> str:
16
+ tutor = "on" if self.tutor_on else "off"
17
+ web = "on" if self.web_on else "off"
18
+ mcp = f"{self.mcp_count} server{'s' if self.mcp_count != 1 else ''}"
19
+ return f"tutor: {tutor} · web: {web} · mcp: {mcp}"
20
+
21
+
22
+ class ASCIISpinner(Static):
23
+ """Minimal spinner that cycles through frames with a label."""
24
+
25
+ SPINNER_FRAMES = ["· ", "·· ", "···", " ··", " ·", " "]
26
+
27
+ _frame: reactive[int] = reactive(0)
28
+ _label: reactive[str] = reactive("")
29
+ _running: reactive[bool] = reactive(False)
30
+
31
+ def __init__(self, label: str = "Processing...", **kwargs):
32
+ super().__init__(**kwargs)
33
+ self._label = label
34
+ self._timer = None
35
+
36
+ def render(self) -> str:
37
+ if not self._running:
38
+ return ""
39
+ frame = self.SPINNER_FRAMES[self._frame % len(self.SPINNER_FRAMES)]
40
+ return f"{frame} {self._label}"
41
+
42
+ def start(self, label: str = "Processing query...") -> None:
43
+ """Start the spinner animation."""
44
+ self._label = label
45
+ self._running = True
46
+ self._frame = 0
47
+ self.display = True
48
+ if self._timer is None:
49
+ self._timer = self.set_interval(0.1, self._advance_frame)
50
+
51
+ def stop(self) -> None:
52
+ """Stop the spinner animation."""
53
+ self._running = False
54
+ self.display = False
55
+ if self._timer is not None:
56
+ self._timer.stop()
57
+ self._timer = None
58
+
59
+ def _advance_frame(self) -> None:
60
+ """Advance to the next spinner frame."""
61
+ if self._running:
62
+ self._frame = (self._frame + 1) % len(self.SPINNER_FRAMES)
63
+
64
+
7
65
  class HistoryInput(Input):
8
66
  """Input widget with command history navigation."""
9
67
 
10
68
  BINDINGS = [
11
69
  Binding("up", "history_previous", "Previous command", show=False),
12
70
  Binding("down", "history_next", "Next command", show=False),
71
+ Binding("escape", "app.cancel_query", "Cancel", show=False),
72
+ Binding("ctrl+c", "app.cancel_query", "Cancel", show=False),
13
73
  ]
14
74
 
15
75
  def __init__(self, history: CommandHistory, **kwargs):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claude-sdk-tutor
3
- Version: 0.1.5
3
+ Version: 0.1.7
4
4
  Summary: Add your description here
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.13
@@ -15,6 +15,15 @@ Description-Content-Type: text/markdown
15
15
 
16
16
  A terminal-based programming tutor powered by Claude. Built with Textual and the Claude Agent SDK.
17
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
+
18
27
  ## Overview
19
28
 
20
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:
@@ -58,6 +67,16 @@ Type your programming questions in the input field and press Enter to send. Clau
58
67
  - **Grey** - Tool usage (when Claude reads files in your codebase)
59
68
  - **Green** - Slash command feedback
60
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
+
61
80
  ## Slash Commands
62
81
 
63
82
  | Command | Description |
@@ -66,6 +85,45 @@ Type your programming questions in the input field and press Enter to send. Clau
66
85
  | `/clear` | Clears the conversation history and starts fresh. Your settings are preserved. |
67
86
  | `/tutor` | Toggles tutor mode on/off. When on (default), Claude acts as a teacher. When off, Claude responds normally without the tutoring constraints. |
68
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`.
69
127
 
70
128
  ## Tech Stack
71
129
 
@@ -0,0 +1,12 @@
1
+ app.py,sha256=iTxl7N0Y8ED1zB0LUSc3jodpiRLjjVR6TWvKHdKOa7s,14203
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=fIpoV0UMtIcR5W315wxe2Cz6KLxJovWJQQn1acnRXoU,2960
8
+ claude_sdk_tutor-0.1.7.dist-info/METADATA,sha256=JnzBT4rW2BzMCkL8L_wWzGF3vTkHpZGw-NpUBcjwVxE,4519
9
+ claude_sdk_tutor-0.1.7.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
10
+ claude_sdk_tutor-0.1.7.dist-info/entry_points.txt,sha256=vI78kiiqb59KzHEa8UsnkvCbmCs0IMLXOuO2qiho4U4,46
11
+ claude_sdk_tutor-0.1.7.dist-info/licenses/LICENSE,sha256=KzxybQVVAEGBifrjNj5OGwQ_rsbzCIGPm0xrTL6-VZs,1067
12
+ claude_sdk_tutor-0.1.7.dist-info/RECORD,,
@@ -1,10 +0,0 @@
1
- app.py,sha256=rZvIJ18RaJSR1PLe6lN-4GrAthzB_lYOi5o549N71DA,5883
2
- claude/__init__.py,sha256=rGXaYQtfDG3XywfxU_vHUR5afA_ArxLxzprR04pSnZM,128
3
- claude/claude_agent.py,sha256=-xs3CNr5p2AosKy6UJvf6nTQLdJd8GG7qtS4jd6K_ts,1304
4
- claude/history.py,sha256=-JpVhha552jZkyxuaLbbL2GluQXvFzGmhS6mtB63940,2273
5
- claude/widgets.py,sha256=yYhoxhiEhYK1PjTKNB3YYbCIvEl1SrjhhGFknGNcADM,902
6
- claude_sdk_tutor-0.1.5.dist-info/METADATA,sha256=bbocPf-vW0ZI00XJaSCBmaCozQ5j0_NCaJ2UzTT7Cw8,2480
7
- claude_sdk_tutor-0.1.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
8
- claude_sdk_tutor-0.1.5.dist-info/entry_points.txt,sha256=vI78kiiqb59KzHEa8UsnkvCbmCs0IMLXOuO2qiho4U4,46
9
- claude_sdk_tutor-0.1.5.dist-info/licenses/LICENSE,sha256=KzxybQVVAEGBifrjNj5OGwQ_rsbzCIGPm0xrTL6-VZs,1067
10
- claude_sdk_tutor-0.1.5.dist-info/RECORD,,