deepagent-code 0.1.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.
deepagent_code/cli.py ADDED
@@ -0,0 +1,1368 @@
1
+ """
2
+ CLI for running arbitrary LangGraph agents from the terminal.
3
+ Styled after Claude Code / nanocode.
4
+ """
5
+ import asyncio
6
+ import importlib.util
7
+ import json
8
+ import os
9
+ import re
10
+ import sys
11
+ import threading
12
+ import time
13
+ import uuid
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional, Tuple
16
+
17
+ # Platform-specific imports for keyboard input
18
+ IS_WINDOWS = sys.platform == "win32"
19
+ if IS_WINDOWS:
20
+ import msvcrt
21
+ else:
22
+ import termios
23
+ import tty
24
+
25
+ import click
26
+
27
+ # Try to import readline for tab completion (not available on all platforms)
28
+ try:
29
+ import readline
30
+ HAS_READLINE = True
31
+ except ImportError:
32
+ HAS_READLINE = False
33
+
34
+ from deepagent_code.utils import (
35
+ prepare_agent_input,
36
+ stream_graph_updates,
37
+ astream_graph_updates,
38
+ )
39
+
40
+
41
+ # ANSI color codes (matching nanocode style)
42
+ RESET, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m"
43
+ ITALIC, UNDERLINE = "\033[3m", "\033[4m"
44
+ BLUE, CYAN, GREEN, YELLOW, RED = "\033[34m", "\033[36m", "\033[32m", "\033[33m", "\033[31m"
45
+ MAGENTA, WHITE, GRAY = "\033[35m", "\033[37m", "\033[90m"
46
+
47
+ # Bright variants for gradient effects
48
+ BRIGHT_CYAN, BRIGHT_BLUE = "\033[96m", "\033[94m"
49
+ BRIGHT_GREEN, BRIGHT_YELLOW = "\033[92m", "\033[93m"
50
+
51
+ # Spinner frames for thinking animation
52
+ SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
53
+
54
+
55
+ # Version info
56
+ __version__ = "0.1.3"
57
+
58
+
59
+ # Slash command registry
60
+ class SlashCommand:
61
+ """Represents a slash command with its handler and metadata."""
62
+
63
+ def __init__(
64
+ self,
65
+ name: str,
66
+ handler: callable,
67
+ description: str,
68
+ aliases: Optional[List[str]] = None,
69
+ usage: Optional[str] = None,
70
+ ):
71
+ self.name = name
72
+ self.handler = handler
73
+ self.description = description
74
+ self.aliases = aliases or []
75
+ self.usage = usage or f"/{name}"
76
+
77
+ def execute(self, args: str, context: Dict[str, Any]) -> Optional[str]:
78
+ """Execute the command with given arguments and context."""
79
+ return self.handler(args, context)
80
+
81
+
82
+ class CommandRegistry:
83
+ """Registry for slash commands."""
84
+
85
+ def __init__(self):
86
+ self._commands: Dict[str, SlashCommand] = {}
87
+ self._alias_map: Dict[str, str] = {}
88
+
89
+ def register(self, command: SlashCommand):
90
+ """Register a slash command."""
91
+ self._commands[command.name] = command
92
+ for alias in command.aliases:
93
+ self._alias_map[alias] = command.name
94
+
95
+ def get(self, name: str) -> Optional[SlashCommand]:
96
+ """Get a command by name or alias."""
97
+ # Check if it's an alias
98
+ if name in self._alias_map:
99
+ name = self._alias_map[name]
100
+ return self._commands.get(name)
101
+
102
+ def all_commands(self) -> List[SlashCommand]:
103
+ """Get all registered commands."""
104
+ return list(self._commands.values())
105
+
106
+ def parse_input(self, user_input: str) -> Tuple[Optional[str], str]:
107
+ """Parse user input to extract command name and arguments.
108
+
109
+ Returns:
110
+ Tuple of (command_name, arguments) or (None, original_input) if not a command
111
+ """
112
+ if not user_input.startswith("/"):
113
+ return None, user_input
114
+
115
+ # Split into command and args
116
+ parts = user_input[1:].split(maxsplit=1)
117
+ cmd_name = parts[0].lower() if parts else ""
118
+ args = parts[1] if len(parts) > 1 else ""
119
+
120
+ return cmd_name, args
121
+
122
+
123
+ # Global command registry
124
+ command_registry = CommandRegistry()
125
+
126
+
127
+ def rl_wrap(code: str) -> str:
128
+ """Wrap ANSI escape code for readline to ignore in length calculations.
129
+
130
+ On terminals, ANSI codes are invisible but counted in string length.
131
+ This causes issues with line wrapping when using input().
132
+ Wrapping with \\001 and \\002 tells readline to ignore these characters.
133
+ """
134
+ if HAS_READLINE:
135
+ return f"\001{code}\002"
136
+ return code
137
+
138
+
139
+ def make_prompt(text: str = "❯", color: str = BRIGHT_BLUE) -> str:
140
+ """Create a prompt string with proper readline escaping for ANSI codes.
141
+
142
+ This prevents line wrapping issues on Windows and other terminals.
143
+ """
144
+ return f"{rl_wrap(BOLD)}{rl_wrap(color)}{text}{rl_wrap(RESET)} "
145
+
146
+
147
+ def register_command(
148
+ name: str,
149
+ description: str,
150
+ aliases: Optional[List[str]] = None,
151
+ usage: Optional[str] = None,
152
+ ):
153
+ """Decorator to register a slash command handler."""
154
+ def decorator(func):
155
+ command = SlashCommand(
156
+ name=name,
157
+ handler=func,
158
+ description=description,
159
+ aliases=aliases or [],
160
+ usage=usage,
161
+ )
162
+ command_registry.register(command)
163
+ return func
164
+ return decorator
165
+
166
+
167
+ class Spinner:
168
+ """A simple terminal spinner for showing activity with elapsed time."""
169
+
170
+ def __init__(self, message: str = "Thinking"):
171
+ self.message = message
172
+ self.running = False
173
+ self.thread = None
174
+ self.frame_idx = 0
175
+ self.start_time = None
176
+
177
+ def _spin(self):
178
+ """Run the spinner animation with elapsed time display."""
179
+ while self.running:
180
+ frame = SPINNER_FRAMES[self.frame_idx % len(SPINNER_FRAMES)]
181
+ elapsed = time.time() - self.start_time
182
+ elapsed_str = f"{int(elapsed)}s"
183
+ print(f"\r{CYAN}{frame}{RESET} {DIM}{self.message}... {elapsed_str}{RESET}", end="", flush=True)
184
+ self.frame_idx += 1
185
+ time.sleep(0.08)
186
+
187
+ def start(self):
188
+ """Start the spinner."""
189
+ self.running = True
190
+ self.start_time = time.time()
191
+ self.thread = threading.Thread(target=self._spin, daemon=True)
192
+ self.thread.start()
193
+
194
+ def stop(self):
195
+ """Stop the spinner and clear the line."""
196
+ self.running = False
197
+ if self.thread:
198
+ self.thread.join(timeout=0.2)
199
+ # Clear the spinner line
200
+ print("\r\033[2K", end="", flush=True)
201
+
202
+
203
+ def get_terminal_width() -> int:
204
+ """Get terminal width, capped at 100 for readability."""
205
+ try:
206
+ return min(os.get_terminal_size().columns, 100)
207
+ except OSError:
208
+ return 80
209
+
210
+
211
+ def separator(style: str = "light") -> str:
212
+ """Return a styled separator line.
213
+
214
+ Args:
215
+ style: 'light' for thin line, 'heavy' for thick line, 'dots' for dotted
216
+ """
217
+ width = get_terminal_width()
218
+ if style == "heavy":
219
+ return f"{DIM}{'━' * width}{RESET}"
220
+ elif style == "dots":
221
+ return f"{DIM}{'·' * width}{RESET}"
222
+ else:
223
+ return f"{DIM}{'─' * width}{RESET}"
224
+
225
+
226
+ def print_welcome():
227
+ """Print a welcome message with tips."""
228
+ tips = [
229
+ f"Type {CYAN}/help{RESET} for commands",
230
+ f"Use {CYAN}/c{RESET} to clear conversation",
231
+ f"Press {CYAN}Ctrl+C{RESET} to exit",
232
+ f"Press {CYAN}Tab{RESET} to autocomplete commands",
233
+ ]
234
+ tip = tips[int(time.time()) % len(tips)] # Rotate tips
235
+ print(f"\n{DIM}Tip: {tip}{RESET}\n")
236
+
237
+
238
+ def print_goodbye():
239
+ """Print a goodbye message."""
240
+ print(f"\n{DIM}Goodbye!{RESET}\n")
241
+
242
+
243
+ def get_agent_name(graph) -> str:
244
+ """Extract agent name from graph object, defaulting to 'AgentCode'."""
245
+ # Try common attribute names for agent/graph name
246
+ for attr in ('name', 'agent_name', '_name', '__name__'):
247
+ if hasattr(graph, attr):
248
+ name = getattr(graph, attr)
249
+ if name and isinstance(name, str):
250
+ return name
251
+ # Check if it's a compiled graph with a name in builder
252
+ if hasattr(graph, 'builder') and hasattr(graph.builder, 'name'):
253
+ name = graph.builder.name
254
+ if name and isinstance(name, str):
255
+ return name
256
+ return "AgentCode"
257
+
258
+
259
+ def print_header_box(agent_name: str, cwd: str):
260
+ """Print an elegant header with the agent name and version."""
261
+ term_width = get_terminal_width()
262
+
263
+ # Box drawing characters
264
+ TL, TR, BL, BR = "╭", "╮", "╰", "╯" # corners
265
+ H, V = "─", "│" # horizontal and vertical
266
+
267
+ # Calculate inner width (accounting for borders and padding)
268
+ inner_width = term_width - 4 # 2 for borders, 2 for padding
269
+
270
+ # Build the header content
271
+ title_line = agent_name.center(inner_width)
272
+ cwd_display = cwd if len(cwd) <= inner_width else "..." + cwd[-(inner_width - 3):]
273
+ cwd_line = cwd_display.center(inner_width)
274
+
275
+ # Print the box with gradient-style coloring
276
+ print()
277
+ print(f"{BRIGHT_CYAN}{TL}{H * (term_width - 2)}{TR}{RESET}")
278
+ print(f"{BRIGHT_CYAN}{V}{RESET} {BOLD}{BRIGHT_CYAN}{title_line}{RESET} {BRIGHT_CYAN}{V}{RESET}")
279
+ print(f"{CYAN}{V}{RESET} {DIM}{cwd_line}{RESET} {CYAN}{V}{RESET}")
280
+ print(f"{CYAN}{BL}{H * (term_width - 2)}{BR}{RESET}")
281
+
282
+
283
+ def render_markdown(text: str) -> str:
284
+ """Render markdown formatting for terminal display.
285
+
286
+ Supports: **bold**, *italic*, `code`, [links](url)
287
+ """
288
+ # Bold: **text**
289
+ text = re.sub(r"\*\*(.+?)\*\*", f"{BOLD}\\1{RESET}", text)
290
+ # Italic: *text* (but not inside **)
291
+ text = re.sub(r"(?<!\*)\*([^*]+?)\*(?!\*)", f"{ITALIC}\\1{RESET}", text)
292
+ # Inline code: `code`
293
+ text = re.sub(r"`([^`]+?)`", f"{CYAN}\\1{RESET}", text)
294
+ # Links: [text](url) - show text in underline
295
+ text = re.sub(r"\[([^\]]+?)\]\([^)]+?\)", f"{UNDERLINE}\\1{RESET}", text)
296
+ return text
297
+
298
+
299
+ def parse_agent_spec(agent_spec: str) -> Tuple[str, str]:
300
+ """
301
+ Parse DEEPAGENT_AGENT_SPEC format: path/to/file.py:variable_name.
302
+
303
+ Args:
304
+ agent_spec: Agent specification string
305
+
306
+ Returns:
307
+ Tuple of (file_path, variable_name)
308
+
309
+ Raises:
310
+ ValueError: If format is invalid
311
+ """
312
+ if ':' not in agent_spec:
313
+ raise ValueError(
314
+ f"Invalid agent spec format: '{agent_spec}'. "
315
+ f"Expected format: 'path/to/file.py:variable_name'"
316
+ )
317
+
318
+ parts = agent_spec.rsplit(':', 1)
319
+ file_path = parts[0]
320
+ variable_name = parts[1]
321
+
322
+ if not file_path.endswith('.py'):
323
+ raise ValueError(f"Agent spec file must be a .py file: {file_path}")
324
+
325
+ return file_path, variable_name
326
+
327
+
328
+ def load_graph_from_file(file_path: str, graph_name: str = "graph"):
329
+ """
330
+ Dynamically load a LangGraph graph from a Python file.
331
+
332
+ Args:
333
+ file_path: Path to the Python file containing the graph
334
+ graph_name: Name of the graph variable (default: "graph")
335
+
336
+ Returns:
337
+ The loaded graph object
338
+
339
+ Raises:
340
+ FileNotFoundError: If the file doesn't exist
341
+ AttributeError: If the graph variable doesn't exist in the module
342
+ Exception: For other loading errors
343
+ """
344
+ file_path = Path(file_path).resolve()
345
+
346
+ if not file_path.exists():
347
+ raise FileNotFoundError(f"File not found: {file_path}")
348
+
349
+ # Load the module
350
+ spec = importlib.util.spec_from_file_location("graph_module", file_path)
351
+ if spec is None or spec.loader is None:
352
+ raise Exception(f"Could not load module from {file_path}")
353
+
354
+ module = importlib.util.module_from_spec(spec)
355
+ sys.modules["graph_module"] = module
356
+ spec.loader.exec_module(module)
357
+
358
+ # Get the graph object
359
+ if not hasattr(module, graph_name):
360
+ raise AttributeError(
361
+ f"Module does not have a '{graph_name}' variable. "
362
+ f"Available: {', '.join(dir(module))}"
363
+ )
364
+
365
+ graph = getattr(module, graph_name)
366
+ return graph
367
+
368
+
369
+ def load_graph_from_module(module_path: str, graph_name: str = "graph"):
370
+ """
371
+ Dynamically load a LangGraph graph from a Python module path.
372
+
373
+ Args:
374
+ module_path: Dotted module path (e.g., "mypackage.agents.chatbot")
375
+ graph_name: Name of the graph variable (default: "graph")
376
+
377
+ Returns:
378
+ The loaded graph object
379
+
380
+ Raises:
381
+ ModuleNotFoundError: If the module doesn't exist
382
+ AttributeError: If the graph variable doesn't exist in the module
383
+ """
384
+ import importlib
385
+ module = importlib.import_module(module_path)
386
+
387
+ if not hasattr(module, graph_name):
388
+ raise AttributeError(
389
+ f"Module '{module_path}' does not have a '{graph_name}' variable. "
390
+ f"Available: {', '.join(dir(module))}"
391
+ )
392
+
393
+ graph = getattr(module, graph_name)
394
+ return graph
395
+
396
+
397
+ def load_graph(spec: str, default_graph_name: str = "graph"):
398
+ """
399
+ Load a graph from either a file path or module path.
400
+
401
+ Supports formats:
402
+ - path/to/file.py (uses default_graph_name)
403
+ - path/to/file.py:graph_name
404
+ - package.module (uses default_graph_name)
405
+ - package.module:graph_name
406
+
407
+ Args:
408
+ spec: File path or module path, optionally with :graph_name suffix
409
+ default_graph_name: Graph name to use if not specified in spec
410
+
411
+ Returns:
412
+ The loaded graph object
413
+ """
414
+ # Parse the spec to extract graph name if present
415
+ if ':' in spec:
416
+ path_or_module, graph_name = spec.rsplit(':', 1)
417
+ if not graph_name:
418
+ graph_name = default_graph_name
419
+ else:
420
+ path_or_module = spec
421
+ graph_name = default_graph_name
422
+
423
+ # Determine if it's a file path or module path
424
+ # File paths end with .py or contain path separators
425
+ is_file_path = (
426
+ path_or_module.endswith('.py') or
427
+ '/' in path_or_module or
428
+ '\\' in path_or_module or
429
+ Path(path_or_module).exists()
430
+ )
431
+
432
+ if is_file_path:
433
+ return load_graph_from_file(path_or_module, graph_name), graph_name
434
+ else:
435
+ return load_graph_from_module(path_or_module, graph_name), graph_name
436
+
437
+
438
+ def get_tool_arg_preview(args: Dict[str, Any]) -> str:
439
+ """Get a preview of the first argument value (nanocode style)."""
440
+ if not args:
441
+ return ""
442
+ # Get first value
443
+ first_val = str(list(args.values())[0])
444
+ # Truncate if needed
445
+ if len(first_val) > 50:
446
+ return first_val[:50] + "..."
447
+ return first_val
448
+
449
+
450
+ def format_result_preview(result: str) -> str:
451
+ """Format a result preview with line count indicator."""
452
+ if not result:
453
+ return "(empty)"
454
+ lines = result.split("\n")
455
+ preview = lines[0][:60]
456
+ if len(lines) > 1:
457
+ preview += f" ... +{len(lines) - 1} lines"
458
+ elif len(lines[0]) > 60:
459
+ preview += "..."
460
+ return preview
461
+
462
+
463
+ def format_duration(seconds: float) -> str:
464
+ """Format duration in human-readable format."""
465
+ if seconds < 1:
466
+ return f"{seconds * 1000:.0f}ms"
467
+ elif seconds < 60:
468
+ return f"{seconds:.1f}s"
469
+ else:
470
+ minutes = int(seconds // 60)
471
+ secs = seconds % 60
472
+ return f"{minutes}m {secs:.1f}s"
473
+
474
+
475
+ def print_timing(duration: float, verbose: bool = False):
476
+ """Print response timing information."""
477
+ formatted = format_duration(duration)
478
+ if verbose:
479
+ print(f"\n{DIM}Response time: {formatted}{RESET}")
480
+ else:
481
+ print(f"\n{DIM}{formatted}{RESET}")
482
+
483
+
484
+ def print_chunk(chunk: Dict[str, Any], verbose: bool = False):
485
+ """
486
+ Pretty print a chunk from the stream using Claude Code styling.
487
+
488
+ Args:
489
+ chunk: The chunk dictionary
490
+ verbose: Whether to show verbose output
491
+ """
492
+ status = chunk.get("status")
493
+
494
+ if status == "streaming":
495
+ # Handle text chunks - cyan bullet with text
496
+ if "chunk" in chunk:
497
+ text = chunk["chunk"]
498
+ node = chunk.get("node", "unknown")
499
+ if verbose:
500
+ print(f"{DIM}[{node}]{RESET} {text}", end="")
501
+ else:
502
+ # Print text output with cyan bullet
503
+ print(f"{CYAN}⏺{RESET} {render_markdown(text)}", end="")
504
+
505
+ # Handle tool calls - green tool name
506
+ elif "tool_calls" in chunk:
507
+ for tool_call in chunk["tool_calls"]:
508
+ tool_name = tool_call["name"]
509
+ args = tool_call.get("args", {})
510
+ arg_preview = get_tool_arg_preview(args)
511
+
512
+ print(f"\n{GREEN}● {tool_name}{RESET}")
513
+ if arg_preview:
514
+ print(f" {DIM}└─ {arg_preview}{RESET}")
515
+
516
+ # Handle tool results - indented with result preview
517
+ elif "tool_result" in chunk:
518
+ result = chunk.get("tool_result", "")
519
+ preview = format_result_preview(str(result))
520
+ print(f" {DIM} ↳ {preview}{RESET}")
521
+
522
+ elif status == "interrupt":
523
+ interrupt_data = chunk.get("interrupt", {})
524
+ action_requests = interrupt_data.get("action_requests", [])
525
+
526
+ print(f"\n{YELLOW}⚠ Action Required{RESET}")
527
+ if action_requests:
528
+ for i, action in enumerate(action_requests):
529
+ tool = action.get('tool', 'unknown')
530
+ args_preview = get_tool_arg_preview(action.get('args', {}))
531
+ print(f" {DIM}{i + 1}. {tool}{RESET}")
532
+ if args_preview:
533
+ print(f" {DIM}└─ {args_preview}{RESET}")
534
+
535
+ elif status == "complete":
536
+ pass # No output on complete (nanocode style)
537
+
538
+ elif status == "error":
539
+ error_msg = chunk.get("error", "Unknown error")
540
+ print(f"\n{RED}✗ Error: {error_msg}{RESET}")
541
+
542
+
543
+ def get_key() -> str:
544
+ """Read a single keypress from stdin (cross-platform)."""
545
+ if IS_WINDOWS:
546
+ # Windows implementation using msvcrt
547
+ ch = msvcrt.getch()
548
+ if ch in (b'\x00', b'\xe0'): # Special keys (arrows, function keys)
549
+ ch2 = msvcrt.getch()
550
+ if ch2 == b'H':
551
+ return 'up'
552
+ elif ch2 == b'P':
553
+ return 'down'
554
+ return ch2.decode('utf-8', errors='ignore')
555
+ elif ch == b'\r':
556
+ return 'enter'
557
+ elif ch == b'\x03': # Ctrl+C
558
+ return 'ctrl-c'
559
+ return ch.decode('utf-8', errors='ignore')
560
+ else:
561
+ # Unix implementation using termios/tty
562
+ fd = sys.stdin.fileno()
563
+ old_settings = termios.tcgetattr(fd)
564
+ try:
565
+ tty.setraw(fd)
566
+ ch = sys.stdin.read(1)
567
+ # Handle escape sequences (arrow keys)
568
+ if ch == '\x1b':
569
+ ch2 = sys.stdin.read(1)
570
+ if ch2 == '[':
571
+ ch3 = sys.stdin.read(1)
572
+ if ch3 == 'A':
573
+ return 'up'
574
+ elif ch3 == 'B':
575
+ return 'down'
576
+ elif ch == '\r' or ch == '\n':
577
+ return 'enter'
578
+ elif ch == '\x03': # Ctrl+C
579
+ return 'ctrl-c'
580
+ return ch
581
+ finally:
582
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
583
+
584
+
585
+ def select_option(options: List[str], prompt: str = "Select an option:") -> int:
586
+ """
587
+ Interactive option selector using arrow keys.
588
+
589
+ Args:
590
+ options: List of option strings to display
591
+ prompt: Prompt to show above options
592
+
593
+ Returns:
594
+ Index of selected option (0-based)
595
+ """
596
+ selected = 0
597
+ num_options = len(options)
598
+
599
+ # Hide cursor
600
+ print("\033[?25l", end="")
601
+
602
+ try:
603
+ print(f"\n{BOLD}{prompt}{RESET}")
604
+
605
+ # Print initial options
606
+ for i, opt in enumerate(options):
607
+ if i == selected:
608
+ print(f" {CYAN}❯ {opt}{RESET}")
609
+ else:
610
+ print(f" {DIM}{opt}{RESET}")
611
+
612
+ while True:
613
+ key = get_key()
614
+
615
+ if key == 'up' and selected > 0:
616
+ selected -= 1
617
+ elif key == 'down' and selected < num_options - 1:
618
+ selected += 1
619
+ elif key == 'enter':
620
+ break
621
+ elif key == 'ctrl-c':
622
+ print("\033[?25h", end="") # Show cursor
623
+ sys.exit(0)
624
+
625
+ # Move cursor up to redraw options
626
+ print(f"\033[{num_options}A", end="")
627
+
628
+ # Redraw options
629
+ for i, opt in enumerate(options):
630
+ # Clear line and print option
631
+ print("\033[2K", end="") # Clear line
632
+ if i == selected:
633
+ print(f" {CYAN}❯ {opt}{RESET}")
634
+ else:
635
+ print(f" {DIM}{opt}{RESET}")
636
+
637
+ return selected
638
+ finally:
639
+ # Show cursor
640
+ print("\033[?25h", end="")
641
+
642
+
643
+ def handle_interrupt_input(num_actions: int = 1) -> List[Dict[str, Any]]:
644
+ """
645
+ Handle user input for interrupt decisions using arrow key navigation.
646
+
647
+ Args:
648
+ num_actions: Number of pending tool calls that need decisions
649
+
650
+ Returns:
651
+ List of decision objects (one for each pending action)
652
+ """
653
+ options = [
654
+ "Approve all actions",
655
+ "Reject all actions",
656
+ "Provide custom decision (JSON)",
657
+ "Exit",
658
+ ]
659
+
660
+ choice = select_option(options, "How would you like to proceed?")
661
+
662
+ if choice == 0:
663
+ # Return approve decision for each pending action
664
+ return [{"type": "approve"} for _ in range(num_actions)]
665
+ elif choice == 1:
666
+ # Return reject decision for each pending action
667
+ return [{"type": "reject"} for _ in range(num_actions)]
668
+ elif choice == 2:
669
+ print("Enter your decision as JSON (will be applied to all actions):")
670
+ json_str = input(make_prompt("❯", BLUE)).strip()
671
+ try:
672
+ decision = json.loads(json_str)
673
+ return [decision for _ in range(num_actions)]
674
+ except json.JSONDecodeError as e:
675
+ print(f"{RED}⏺ Invalid JSON: {e}{RESET}")
676
+ return [{"type": "reject"} for _ in range(num_actions)]
677
+ else:
678
+ sys.exit(0)
679
+
680
+
681
+ async def run_single_turn_async(
682
+ graph,
683
+ message: str,
684
+ config: Optional[Dict[str, Any]] = None,
685
+ interactive: bool = True,
686
+ verbose: bool = False,
687
+ stream_mode: str = "updates",
688
+ ) -> float:
689
+ """Run a single turn of an async LangGraph graph. Returns total duration in seconds."""
690
+ input_data = prepare_agent_input(message=message)
691
+ start_time = time.time()
692
+
693
+ while True:
694
+ has_interrupt = False
695
+ num_pending_actions = 0
696
+ first_chunk = True
697
+ spinner = Spinner("Thinking")
698
+ spinner.start()
699
+
700
+ async for chunk in astream_graph_updates(graph, input_data, config=config, stream_mode=stream_mode):
701
+ # Stop spinner on first chunk
702
+ if first_chunk:
703
+ spinner.stop()
704
+ first_chunk = False
705
+
706
+ print_chunk(chunk, verbose=verbose)
707
+
708
+ if chunk.get("status") == "interrupt":
709
+ has_interrupt = True
710
+ # Count pending action requests
711
+ interrupt_data = chunk.get("interrupt", {})
712
+ action_requests = interrupt_data.get("action_requests", [])
713
+ num_pending_actions = len(action_requests) if action_requests else 1
714
+
715
+ # Ensure spinner is stopped even if no chunks received
716
+ if first_chunk:
717
+ spinner.stop()
718
+
719
+ if has_interrupt and interactive:
720
+ decisions = handle_interrupt_input(num_pending_actions)
721
+ input_data = prepare_agent_input(decisions=decisions)
722
+ else:
723
+ break
724
+
725
+ return time.time() - start_time
726
+
727
+
728
+ def run_single_turn_sync(
729
+ graph,
730
+ message: str,
731
+ config: Optional[Dict[str, Any]] = None,
732
+ interactive: bool = True,
733
+ verbose: bool = False,
734
+ stream_mode: str = "updates",
735
+ ) -> float:
736
+ """Run a single turn of a sync LangGraph graph. Returns total duration in seconds."""
737
+ input_data = prepare_agent_input(message=message)
738
+ start_time = time.time()
739
+
740
+ while True:
741
+ has_interrupt = False
742
+ num_pending_actions = 0
743
+ first_chunk = True
744
+ spinner = Spinner("Thinking")
745
+ spinner.start()
746
+
747
+ for chunk in stream_graph_updates(graph, input_data, config=config, stream_mode=stream_mode):
748
+ # Stop spinner on first chunk
749
+ if first_chunk:
750
+ spinner.stop()
751
+ first_chunk = False
752
+
753
+ print_chunk(chunk, verbose=verbose)
754
+
755
+ if chunk.get("status") == "interrupt":
756
+ has_interrupt = True
757
+ # Count pending action requests
758
+ interrupt_data = chunk.get("interrupt", {})
759
+ action_requests = interrupt_data.get("action_requests", [])
760
+ num_pending_actions = len(action_requests) if action_requests else 1
761
+
762
+ # Ensure spinner is stopped even if no chunks received
763
+ if first_chunk:
764
+ spinner.stop()
765
+
766
+ if has_interrupt and interactive:
767
+ decisions = handle_interrupt_input(num_pending_actions)
768
+ input_data = prepare_agent_input(decisions=decisions)
769
+ else:
770
+ break
771
+
772
+ return time.time() - start_time
773
+
774
+
775
+ def print_help():
776
+ """Print formatted help information."""
777
+ print(f"\n{BOLD}{BRIGHT_CYAN}Commands{RESET}")
778
+ print(f"{DIM}{'─' * 40}{RESET}")
779
+
780
+ # Get all registered commands and display them
781
+ commands = command_registry.all_commands()
782
+ for cmd in sorted(commands, key=lambda c: c.name):
783
+ aliases_str = ""
784
+ if cmd.aliases:
785
+ aliases_str = f", {CYAN}/{RESET}, {CYAN}/".join([""] + cmd.aliases)[4:]
786
+ print(f" {CYAN}/{cmd.name}{RESET}{aliases_str}")
787
+ print(f" {DIM}{cmd.description}{RESET}")
788
+
789
+ print()
790
+ print(f"{BOLD}{BRIGHT_CYAN}Shortcuts{RESET}")
791
+ print(f"{DIM}{'─' * 40}{RESET}")
792
+ print(f" {CYAN}Tab{RESET} Autocomplete commands")
793
+ print(f" {CYAN}Ctrl+C{RESET} Exit at any time")
794
+ print(f" {CYAN}↑/↓{RESET} Navigate options")
795
+ print()
796
+
797
+
798
+ # --- Built-in Slash Commands ---
799
+
800
+ @register_command(
801
+ name="help",
802
+ description="Show this help message",
803
+ aliases=["h", "?"],
804
+ )
805
+ def cmd_help(args: str, context: Dict[str, Any]) -> Optional[str]:
806
+ """Display help information."""
807
+ if args:
808
+ # Show help for a specific command
809
+ cmd = command_registry.get(args)
810
+ if cmd:
811
+ print(f"\n{BOLD}{BRIGHT_CYAN}/{cmd.name}{RESET}")
812
+ print(f" {cmd.description}")
813
+ if cmd.aliases:
814
+ print(f" {DIM}Aliases: /{', /'.join(cmd.aliases)}{RESET}")
815
+ if cmd.usage:
816
+ print(f" {DIM}Usage: {cmd.usage}{RESET}")
817
+ print()
818
+ else:
819
+ print(f"{YELLOW}Unknown command: /{args}{RESET}")
820
+ else:
821
+ print_help()
822
+ return None
823
+
824
+
825
+ @register_command(
826
+ name="quit",
827
+ description="Exit the CLI",
828
+ aliases=["q", "exit"],
829
+ )
830
+ def cmd_quit(args: str, context: Dict[str, Any]) -> Optional[str]:
831
+ """Exit the CLI."""
832
+ return "exit" # Special return value to signal exit
833
+
834
+
835
+ @register_command(
836
+ name="clear",
837
+ description="Clear conversation history",
838
+ aliases=["c"],
839
+ )
840
+ def cmd_clear(args: str, context: Dict[str, Any]) -> Optional[str]:
841
+ """Clear the conversation history."""
842
+ context["config"]["configurable"]["thread_id"] = str(uuid.uuid4())
843
+ print(f"\n{GREEN}✓ Conversation cleared{RESET}\n")
844
+ return None
845
+
846
+
847
+ @register_command(
848
+ name="version",
849
+ description="Show version information",
850
+ aliases=["v"],
851
+ )
852
+ def cmd_version(args: str, context: Dict[str, Any]) -> Optional[str]:
853
+ """Display version information."""
854
+ print(f"\n{BOLD}{BRIGHT_CYAN}deepagent-code{RESET} v{__version__}")
855
+ agent_name = context.get("agent_name", "Unknown")
856
+ print(f"{DIM}Agent: {agent_name}{RESET}\n")
857
+ return None
858
+
859
+
860
+ @register_command(
861
+ name="status",
862
+ description="Show current session status",
863
+ aliases=["s"],
864
+ )
865
+ def cmd_status(args: str, context: Dict[str, Any]) -> Optional[str]:
866
+ """Display current session status."""
867
+ config = context.get("config", {})
868
+ thread_id = config.get("configurable", {}).get("thread_id", "N/A")
869
+ agent_name = context.get("agent_name", "Unknown")
870
+ verbose = context.get("verbose", False)
871
+ use_async = context.get("use_async", False)
872
+ stream_mode = context.get("stream_mode", "updates")
873
+
874
+ print(f"\n{BOLD}{BRIGHT_CYAN}Session Status{RESET}")
875
+ print(f"{DIM}{'─' * 30}{RESET}")
876
+ print(f" {DIM}Agent:{RESET} {agent_name}")
877
+ print(f" {DIM}Thread ID:{RESET} {thread_id[:8]}...")
878
+ print(f" {DIM}Mode:{RESET} {'async' if use_async else 'sync'}")
879
+ print(f" {DIM}Stream:{RESET} {stream_mode}")
880
+ print(f" {DIM}Verbose:{RESET} {'on' if verbose else 'off'}")
881
+ print(f" {DIM}CWD:{RESET} {os.getcwd()}")
882
+ print()
883
+ return None
884
+
885
+
886
+ @register_command(
887
+ name="config",
888
+ description="Show or set configuration",
889
+ aliases=["cfg"],
890
+ usage="/config [key] [value]",
891
+ )
892
+ def cmd_config(args: str, context: Dict[str, Any]) -> Optional[str]:
893
+ """Show or modify configuration."""
894
+ config = context.get("config", {})
895
+
896
+ if not args:
897
+ # Show current config
898
+ print(f"\n{BOLD}{BRIGHT_CYAN}Configuration{RESET}")
899
+ print(f"{DIM}{'─' * 30}{RESET}")
900
+ for key, value in config.items():
901
+ if isinstance(value, dict):
902
+ print(f" {CYAN}{key}:{RESET}")
903
+ for k, v in value.items():
904
+ # Truncate long values
905
+ v_str = str(v)
906
+ if len(v_str) > 30:
907
+ v_str = v_str[:30] + "..."
908
+ print(f" {DIM}{k}:{RESET} {v_str}")
909
+ else:
910
+ print(f" {CYAN}{key}:{RESET} {value}")
911
+ print()
912
+ else:
913
+ parts = args.split(maxsplit=1)
914
+ if len(parts) == 1:
915
+ # Show specific config key
916
+ key = parts[0]
917
+ if key in config:
918
+ print(f"\n{CYAN}{key}:{RESET} {config[key]}\n")
919
+ elif "configurable" in config and key in config["configurable"]:
920
+ print(f"\n{CYAN}{key}:{RESET} {config['configurable'][key]}\n")
921
+ else:
922
+ print(f"{YELLOW}Unknown config key: {key}{RESET}")
923
+ else:
924
+ # Set config value
925
+ key, value = parts
926
+ if key == "verbose":
927
+ context["verbose"] = value.lower() in ("true", "1", "on", "yes")
928
+ print(f"{GREEN}✓ Set verbose = {context['verbose']}{RESET}")
929
+ else:
930
+ print(f"{YELLOW}Cannot modify {key} at runtime{RESET}")
931
+ return None
932
+
933
+
934
+ @register_command(
935
+ name="history",
936
+ description="Show recent messages (if available)",
937
+ aliases=["hist"],
938
+ )
939
+ def cmd_history(args: str, context: Dict[str, Any]) -> Optional[str]:
940
+ """Display conversation history if available."""
941
+ graph = context.get("graph")
942
+ config = context.get("config", {})
943
+
944
+ if graph is None:
945
+ print(f"{YELLOW}No graph available{RESET}")
946
+ return None
947
+
948
+ try:
949
+ # Try to get state from the graph's checkpointer
950
+ if hasattr(graph, "get_state"):
951
+ state = graph.get_state(config)
952
+ if state and hasattr(state, "values"):
953
+ messages = state.values.get("messages", [])
954
+ if messages:
955
+ print(f"\n{BOLD}{BRIGHT_CYAN}Conversation History{RESET}")
956
+ print(f"{DIM}{'─' * 40}{RESET}")
957
+
958
+ # Show last N messages
959
+ limit = 10
960
+ if args:
961
+ try:
962
+ limit = int(args)
963
+ except ValueError:
964
+ pass
965
+
966
+ for msg in messages[-limit:]:
967
+ role = getattr(msg, "type", "unknown")
968
+ content = getattr(msg, "content", str(msg))
969
+
970
+ if role == "human":
971
+ print(f"\n {BRIGHT_BLUE}You:{RESET}")
972
+ elif role == "ai":
973
+ print(f"\n {BRIGHT_CYAN}Agent:{RESET}")
974
+ else:
975
+ print(f"\n {DIM}{role}:{RESET}")
976
+
977
+ # Truncate long content
978
+ if len(content) > 200:
979
+ content = content[:200] + "..."
980
+ print(f" {DIM}{content}{RESET}")
981
+ print()
982
+ else:
983
+ print(f"{DIM}No messages in history{RESET}")
984
+ else:
985
+ print(f"{DIM}No state available{RESET}")
986
+ else:
987
+ print(f"{DIM}Graph does not support state retrieval{RESET}")
988
+ except Exception as e:
989
+ print(f"{DIM}Could not retrieve history: {e}{RESET}")
990
+
991
+ return None
992
+
993
+
994
+ @register_command(
995
+ name="reset",
996
+ description="Reset the session (clear history and restart)",
997
+ aliases=["restart"],
998
+ )
999
+ def cmd_reset(args: str, context: Dict[str, Any]) -> Optional[str]:
1000
+ """Reset the session."""
1001
+ context["config"]["configurable"]["thread_id"] = str(uuid.uuid4())
1002
+ print(f"\n{GREEN}✓ Session reset{RESET}")
1003
+ print(f"{DIM}New thread ID: {context['config']['configurable']['thread_id'][:8]}...{RESET}\n")
1004
+ return None
1005
+
1006
+
1007
+ @register_command(
1008
+ name="verbose",
1009
+ description="Toggle verbose output mode",
1010
+ usage="/verbose [on|off]",
1011
+ )
1012
+ def cmd_verbose(args: str, context: Dict[str, Any]) -> Optional[str]:
1013
+ """Toggle or show verbose output mode."""
1014
+ verbose = context.get("verbose", False)
1015
+ if args:
1016
+ if args.lower() in ("on", "true", "1"):
1017
+ context["verbose"] = True
1018
+ print(f"{GREEN}✓ Verbose mode enabled{RESET}")
1019
+ elif args.lower() in ("off", "false", "0"):
1020
+ context["verbose"] = False
1021
+ print(f"{GREEN}✓ Verbose mode disabled{RESET}")
1022
+ else:
1023
+ print(f"{DIM}Verbose mode: {'on' if verbose else 'off'}{RESET}")
1024
+ print(f"{DIM}Use /verbose on or /verbose off to change{RESET}")
1025
+ return None
1026
+
1027
+
1028
+ def get_command_suggestions(partial: str) -> List[str]:
1029
+ """Get command suggestions based on partial input.
1030
+
1031
+ Args:
1032
+ partial: Partial command name (without leading /)
1033
+
1034
+ Returns:
1035
+ List of matching command names
1036
+ """
1037
+ partial_lower = partial.lower()
1038
+ suggestions = []
1039
+
1040
+ for cmd in command_registry.all_commands():
1041
+ # Check main command name
1042
+ if cmd.name.startswith(partial_lower):
1043
+ suggestions.append(cmd.name)
1044
+ # Check aliases
1045
+ for alias in cmd.aliases:
1046
+ if alias.startswith(partial_lower) and cmd.name not in suggestions:
1047
+ suggestions.append(cmd.name)
1048
+
1049
+ return sorted(suggestions)
1050
+
1051
+
1052
+ def command_completer(text: str, state: int) -> Optional[str]:
1053
+ """Readline completer for slash commands.
1054
+
1055
+ Args:
1056
+ text: Current text being completed
1057
+ state: State index for multiple completions
1058
+
1059
+ Returns:
1060
+ Next completion or None
1061
+ """
1062
+ # Only complete if starting with /
1063
+ if not text.startswith("/"):
1064
+ return None
1065
+
1066
+ partial = text[1:] # Remove leading /
1067
+ suggestions = ["/" + s for s in get_command_suggestions(partial)]
1068
+
1069
+ if state < len(suggestions):
1070
+ return suggestions[state]
1071
+ return None
1072
+
1073
+
1074
+ def setup_readline_completion():
1075
+ """Set up readline for tab completion of slash commands."""
1076
+ if not HAS_READLINE:
1077
+ return
1078
+
1079
+ readline.set_completer(command_completer)
1080
+ readline.set_completer_delims(" \t\n")
1081
+
1082
+ # Use tab for completion
1083
+ if sys.platform == "darwin":
1084
+ readline.parse_and_bind("bind ^I rl_complete")
1085
+ else:
1086
+ readline.parse_and_bind("tab: complete")
1087
+
1088
+
1089
+ def run_conversation_loop(
1090
+ graph,
1091
+ config: Dict[str, Any],
1092
+ agent_name: str = "AgentCode",
1093
+ use_async: bool = False,
1094
+ interactive: bool = True,
1095
+ verbose: bool = False,
1096
+ stream_mode: str = "updates",
1097
+ initial_message: Optional[str] = None,
1098
+ ):
1099
+ """
1100
+ Run a continuous conversation loop with the LangGraph agent.
1101
+ Styled after Claude Code / nanocode.
1102
+ """
1103
+ # Set up tab completion for slash commands
1104
+ setup_readline_completion()
1105
+
1106
+ # Print box-drawn header with agent name
1107
+ print_header_box(agent_name, os.getcwd())
1108
+
1109
+ # Print welcome message with tips
1110
+ print_welcome()
1111
+
1112
+ # Create command context (mutable dict that commands can modify)
1113
+ command_context = {
1114
+ "graph": graph,
1115
+ "config": config,
1116
+ "agent_name": agent_name,
1117
+ "use_async": use_async,
1118
+ "interactive": interactive,
1119
+ "verbose": verbose,
1120
+ "stream_mode": stream_mode,
1121
+ }
1122
+
1123
+ # Process initial message if provided
1124
+ if initial_message:
1125
+ print(f"\n{BOLD}{BRIGHT_BLUE}You{RESET}")
1126
+ print(f"{initial_message}")
1127
+ print()
1128
+
1129
+ if use_async:
1130
+ duration = asyncio.run(
1131
+ run_single_turn_async(graph, initial_message, config, interactive, verbose, stream_mode)
1132
+ )
1133
+ else:
1134
+ duration = run_single_turn_sync(graph, initial_message, config, interactive, verbose, stream_mode)
1135
+ print_timing(duration, verbose)
1136
+ print()
1137
+
1138
+ # Main conversation loop
1139
+ while True:
1140
+ try:
1141
+ print(separator("dots"))
1142
+ user_input = input(make_prompt()).strip()
1143
+
1144
+ if not user_input:
1145
+ continue
1146
+
1147
+ # Check if it's a slash command
1148
+ cmd_name, cmd_args = command_registry.parse_input(user_input)
1149
+
1150
+ if cmd_name is not None:
1151
+ # It's a slash command
1152
+ cmd = command_registry.get(cmd_name)
1153
+ if cmd:
1154
+ result = cmd.execute(cmd_args, command_context)
1155
+ # Update local vars from context (commands may modify these)
1156
+ verbose = command_context.get("verbose", verbose)
1157
+ if result == "exit":
1158
+ break
1159
+ else:
1160
+ # Show suggestions for unknown commands
1161
+ suggestions = get_command_suggestions(cmd_name)
1162
+ print(f"{YELLOW}Unknown command: /{cmd_name}{RESET}")
1163
+ if suggestions:
1164
+ suggestion_str = ", ".join([f"/{s}" for s in suggestions[:3]])
1165
+ print(f"{DIM}Did you mean: {suggestion_str}?{RESET}")
1166
+ else:
1167
+ print(f"{DIM}Type /help to see available commands{RESET}")
1168
+ continue
1169
+
1170
+ # Handle "exit" as a special case (without slash)
1171
+ if user_input.lower() == "exit":
1172
+ break
1173
+
1174
+ print() # Space before response
1175
+
1176
+ # Run the agent
1177
+ if use_async:
1178
+ duration = asyncio.run(
1179
+ run_single_turn_async(graph, user_input, config, interactive, verbose, stream_mode)
1180
+ )
1181
+ else:
1182
+ duration = run_single_turn_sync(graph, user_input, config, interactive, verbose, stream_mode)
1183
+ print_timing(duration, verbose)
1184
+ print()
1185
+
1186
+ except (EOFError, KeyboardInterrupt):
1187
+ break
1188
+ except Exception as err:
1189
+ print(f"\n{RED}✗ Error: {err}{RESET}\n")
1190
+
1191
+ # Print goodbye message
1192
+ print_goodbye()
1193
+
1194
+
1195
+ @click.command()
1196
+ @click.argument("agent_spec", required=False)
1197
+ @click.option(
1198
+ "--graph-name",
1199
+ "-g",
1200
+ help="Name of the graph variable (default: 'graph', overridden if spec includes :name)",
1201
+ )
1202
+ @click.option(
1203
+ "--message",
1204
+ "-m",
1205
+ help="Input message to send to the agent",
1206
+ )
1207
+ @click.option(
1208
+ "--config",
1209
+ "-c",
1210
+ help="Configuration JSON string or path to JSON file",
1211
+ )
1212
+ @click.option(
1213
+ "--interactive/--no-interactive",
1214
+ default=True,
1215
+ help="Handle interrupts interactively (default: True)",
1216
+ )
1217
+ @click.option(
1218
+ "--async-mode/--sync-mode",
1219
+ "use_async",
1220
+ default=False,
1221
+ help="Use async streaming (default: sync)",
1222
+ )
1223
+ @click.option(
1224
+ "--stream-mode",
1225
+ help="Stream mode for LangGraph (default: 'updates')",
1226
+ )
1227
+ @click.option(
1228
+ "--verbose",
1229
+ "-v",
1230
+ is_flag=True,
1231
+ help="Show verbose output including node names",
1232
+ )
1233
+ def main(
1234
+ agent_spec: Optional[str],
1235
+ graph_name: Optional[str],
1236
+ message: Optional[str],
1237
+ config: Optional[str],
1238
+ interactive: bool,
1239
+ use_async: bool,
1240
+ stream_mode: Optional[str],
1241
+ verbose: bool,
1242
+ ):
1243
+ """
1244
+ Run a LangGraph agent from the command line.
1245
+
1246
+ AGENT_SPEC can be:
1247
+ \b
1248
+ - path/to/file.py (uses default graph name 'graph')
1249
+ - path/to/file.py:agent (specifies graph variable name)
1250
+ - package.module (Python module path)
1251
+ - package.module:agent (module with graph variable name)
1252
+
1253
+ Supports environment variables for configuration:
1254
+
1255
+ \b
1256
+ - DEEPAGENT_AGENT_SPEC: Agent location (same formats as above)
1257
+ - DEEPAGENT_WORKSPACE_ROOT: Working directory for the agent
1258
+ - DEEPAGENT_CONFIG: Configuration JSON string or path to JSON file
1259
+ - DEEPAGENT_STREAM_MODE: Stream mode for LangGraph (updates or values)
1260
+
1261
+ Command-line arguments override environment variables.
1262
+
1263
+ \b
1264
+ Examples:
1265
+ deepagent-code my_agent.py
1266
+ deepagent-code my_agent.py:graph
1267
+ deepagent-code mypackage.agents:chatbot
1268
+ deepagent-code -m "Hello, agent!"
1269
+ """
1270
+ try:
1271
+ # Get environment variables
1272
+ env_agent_spec = os.getenv('DEEPAGENT_AGENT_SPEC')
1273
+ env_workspace_root = os.getenv('DEEPAGENT_WORKSPACE_ROOT')
1274
+ env_config = os.getenv('DEEPAGENT_CONFIG')
1275
+ env_stream_mode = os.getenv('DEEPAGENT_STREAM_MODE', 'updates')
1276
+
1277
+ # Determine which spec to use (CLI arg > env var > default)
1278
+ final_spec = agent_spec or env_agent_spec
1279
+ default_graph_name = graph_name or "graph"
1280
+
1281
+ # If no spec provided, try the default agent
1282
+ if not final_spec:
1283
+ default_agent_path = Path(__file__).parent.parent / "examples" / "agent.py"
1284
+ if default_agent_path.exists():
1285
+ final_spec = f"{default_agent_path}:agent"
1286
+ else:
1287
+ print(f"{RED}⏺ Error: No agent specified.{RESET}")
1288
+ print(f"\n{DIM}Usage:{RESET}")
1289
+ print(f" deepagent-code path/to/agent.py:graph")
1290
+ print(f" deepagent-code mypackage.module:agent")
1291
+ print(f"\n{DIM}Or set DEEPAGENT_AGENT_SPEC environment variable{RESET}")
1292
+ sys.exit(1)
1293
+
1294
+ # Change to workspace root if specified
1295
+ if env_workspace_root:
1296
+ workspace_path = Path(env_workspace_root).resolve()
1297
+ if workspace_path.exists():
1298
+ os.chdir(workspace_path)
1299
+
1300
+ # Load the graph with a spinner
1301
+ spinner = Spinner("Loading agent")
1302
+ spinner.start()
1303
+ graph, final_graph_name = load_graph(final_spec, default_graph_name)
1304
+ spinner.stop()
1305
+ print(f"{GREEN}✓{RESET} {DIM}Loaded {final_spec}{RESET}")
1306
+
1307
+ # Parse config
1308
+ config_dict = None
1309
+ config_source = config or env_config
1310
+
1311
+ if config_source:
1312
+ config_path = Path(config_source)
1313
+ if config_path.exists():
1314
+ with open(config_path) as f:
1315
+ config_dict = json.load(f)
1316
+ else:
1317
+ try:
1318
+ config_dict = json.loads(config_source)
1319
+ except json.JSONDecodeError as e:
1320
+ print(f"{RED}⏺ Invalid config JSON: {e}{RESET}")
1321
+ sys.exit(1)
1322
+
1323
+ # Get stream mode
1324
+ final_stream_mode = stream_mode or env_stream_mode
1325
+
1326
+ # Ensure config has a thread_id for checkpointer support
1327
+ if config_dict is None:
1328
+ config_dict = {}
1329
+ if "configurable" not in config_dict:
1330
+ config_dict["configurable"] = {}
1331
+ if "thread_id" not in config_dict["configurable"]:
1332
+ config_dict["configurable"]["thread_id"] = str(uuid.uuid4())
1333
+
1334
+ # Extract agent name from graph object
1335
+ agent_name = get_agent_name(graph)
1336
+
1337
+ # Run the conversation loop
1338
+ run_conversation_loop(
1339
+ graph=graph,
1340
+ config=config_dict,
1341
+ agent_name=agent_name,
1342
+ use_async=use_async,
1343
+ interactive=interactive,
1344
+ verbose=verbose,
1345
+ stream_mode=final_stream_mode,
1346
+ initial_message=message,
1347
+ )
1348
+
1349
+ except FileNotFoundError as e:
1350
+ print(f"{RED}⏺ Error: {e}{RESET}")
1351
+ sys.exit(1)
1352
+ except AttributeError as e:
1353
+ print(f"{RED}⏺ Error: {e}{RESET}")
1354
+ sys.exit(1)
1355
+ except ModuleNotFoundError as e:
1356
+ print(f"{RED}⏺ Error: {e}{RESET}")
1357
+ print(f"\n{DIM}Make sure your agent's dependencies are installed.{RESET}")
1358
+ sys.exit(1)
1359
+ except Exception as e:
1360
+ print(f"{RED}⏺ Error: {e}{RESET}")
1361
+ if verbose:
1362
+ import traceback
1363
+ print(traceback.format_exc())
1364
+ sys.exit(1)
1365
+
1366
+
1367
+ if __name__ == "__main__":
1368
+ main()