deepagent-code 0.1.0__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,796 @@
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 termios
12
+ import threading
13
+ import time
14
+ import tty
15
+ import uuid
16
+ from pathlib import Path
17
+ from typing import Any, Dict, List, Optional, Tuple
18
+
19
+ import click
20
+
21
+ from deepagent_code.utils import (
22
+ prepare_agent_input,
23
+ stream_graph_updates,
24
+ astream_graph_updates,
25
+ )
26
+
27
+
28
+ # ANSI color codes (matching nanocode style)
29
+ RESET, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m"
30
+ BLUE, CYAN, GREEN, YELLOW, RED = "\033[34m", "\033[36m", "\033[32m", "\033[33m", "\033[31m"
31
+
32
+ # Spinner frames for thinking animation
33
+ SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
34
+
35
+
36
+ class Spinner:
37
+ """A simple terminal spinner for showing activity."""
38
+
39
+ def __init__(self, message: str = "Thinking"):
40
+ self.message = message
41
+ self.running = False
42
+ self.thread = None
43
+ self.frame_idx = 0
44
+
45
+ def _spin(self):
46
+ """Run the spinner animation."""
47
+ while self.running:
48
+ frame = SPINNER_FRAMES[self.frame_idx % len(SPINNER_FRAMES)]
49
+ print(f"\r{CYAN}{frame}{RESET} {DIM}{self.message}...{RESET}", end="", flush=True)
50
+ self.frame_idx += 1
51
+ time.sleep(0.08)
52
+
53
+ def start(self):
54
+ """Start the spinner."""
55
+ self.running = True
56
+ self.thread = threading.Thread(target=self._spin, daemon=True)
57
+ self.thread.start()
58
+
59
+ def stop(self):
60
+ """Stop the spinner and clear the line."""
61
+ self.running = False
62
+ if self.thread:
63
+ self.thread.join(timeout=0.2)
64
+ # Clear the spinner line
65
+ print("\r\033[2K", end="", flush=True)
66
+
67
+
68
+ def separator() -> str:
69
+ """Return a dim separator line."""
70
+ try:
71
+ width = min(os.get_terminal_size().columns, 80)
72
+ except OSError:
73
+ width = 80
74
+ return f"{DIM}{'─' * width}{RESET}"
75
+
76
+
77
+ def get_agent_name(graph) -> str:
78
+ """Extract agent name from graph object, defaulting to 'AgentCode'."""
79
+ # Try common attribute names for agent/graph name
80
+ for attr in ('name', 'agent_name', '_name', '__name__'):
81
+ if hasattr(graph, attr):
82
+ name = getattr(graph, attr)
83
+ if name and isinstance(name, str):
84
+ return name
85
+ # Check if it's a compiled graph with a name in builder
86
+ if hasattr(graph, 'builder') and hasattr(graph.builder, 'name'):
87
+ name = graph.builder.name
88
+ if name and isinstance(name, str):
89
+ return name
90
+ return "AgentCode"
91
+
92
+
93
+ def print_header_box(agent_name: str, cwd: str):
94
+ """Print a box-drawn header with the agent name."""
95
+ try:
96
+ term_width = min(os.get_terminal_size().columns, 80)
97
+ except OSError:
98
+ term_width = 80
99
+
100
+ # Box drawing characters
101
+ TL, TR, BL, BR = "╭", "╮", "╰", "╯" # corners
102
+ H, V = "─", "│" # horizontal and vertical
103
+
104
+ # Calculate inner width (accounting for borders and padding)
105
+ inner_width = term_width - 4 # 2 for borders, 2 for padding
106
+
107
+ # Build the header content
108
+ title_line = agent_name.center(inner_width)
109
+ cwd_display = cwd if len(cwd) <= inner_width else "..." + cwd[-(inner_width - 3):]
110
+ cwd_line = cwd_display.center(inner_width)
111
+
112
+ # Print the box
113
+ print(f"{CYAN}{TL}{H * (term_width - 2)}{TR}{RESET}")
114
+ print(f"{CYAN}{V}{RESET} {BOLD}{title_line}{RESET} {CYAN}{V}{RESET}")
115
+ print(f"{CYAN}{V}{RESET} {DIM}{cwd_line}{RESET} {CYAN}{V}{RESET}")
116
+ print(f"{CYAN}{BL}{H * (term_width - 2)}{BR}{RESET}")
117
+ print()
118
+
119
+
120
+ def render_markdown(text: str) -> str:
121
+ """Simple markdown rendering for **bold** text."""
122
+ return re.sub(r"\*\*(.+?)\*\*", f"{BOLD}\\1{RESET}", text)
123
+
124
+
125
+ def parse_agent_spec(agent_spec: str) -> Tuple[str, str]:
126
+ """
127
+ Parse DEEPAGENT_AGENT_SPEC format: path/to/file.py:variable_name.
128
+
129
+ Args:
130
+ agent_spec: Agent specification string
131
+
132
+ Returns:
133
+ Tuple of (file_path, variable_name)
134
+
135
+ Raises:
136
+ ValueError: If format is invalid
137
+ """
138
+ if ':' not in agent_spec:
139
+ raise ValueError(
140
+ f"Invalid agent spec format: '{agent_spec}'. "
141
+ f"Expected format: 'path/to/file.py:variable_name'"
142
+ )
143
+
144
+ parts = agent_spec.rsplit(':', 1)
145
+ file_path = parts[0]
146
+ variable_name = parts[1]
147
+
148
+ if not file_path.endswith('.py'):
149
+ raise ValueError(f"Agent spec file must be a .py file: {file_path}")
150
+
151
+ return file_path, variable_name
152
+
153
+
154
+ def load_graph_from_file(file_path: str, graph_name: str = "graph"):
155
+ """
156
+ Dynamically load a LangGraph graph from a Python file.
157
+
158
+ Args:
159
+ file_path: Path to the Python file containing the graph
160
+ graph_name: Name of the graph variable (default: "graph")
161
+
162
+ Returns:
163
+ The loaded graph object
164
+
165
+ Raises:
166
+ FileNotFoundError: If the file doesn't exist
167
+ AttributeError: If the graph variable doesn't exist in the module
168
+ Exception: For other loading errors
169
+ """
170
+ file_path = Path(file_path).resolve()
171
+
172
+ if not file_path.exists():
173
+ raise FileNotFoundError(f"File not found: {file_path}")
174
+
175
+ # Load the module
176
+ spec = importlib.util.spec_from_file_location("graph_module", file_path)
177
+ if spec is None or spec.loader is None:
178
+ raise Exception(f"Could not load module from {file_path}")
179
+
180
+ module = importlib.util.module_from_spec(spec)
181
+ sys.modules["graph_module"] = module
182
+ spec.loader.exec_module(module)
183
+
184
+ # Get the graph object
185
+ if not hasattr(module, graph_name):
186
+ raise AttributeError(
187
+ f"Module does not have a '{graph_name}' variable. "
188
+ f"Available: {', '.join(dir(module))}"
189
+ )
190
+
191
+ graph = getattr(module, graph_name)
192
+ return graph
193
+
194
+
195
+ def load_graph_from_module(module_path: str, graph_name: str = "graph"):
196
+ """
197
+ Dynamically load a LangGraph graph from a Python module path.
198
+
199
+ Args:
200
+ module_path: Dotted module path (e.g., "mypackage.agents.chatbot")
201
+ graph_name: Name of the graph variable (default: "graph")
202
+
203
+ Returns:
204
+ The loaded graph object
205
+
206
+ Raises:
207
+ ModuleNotFoundError: If the module doesn't exist
208
+ AttributeError: If the graph variable doesn't exist in the module
209
+ """
210
+ import importlib
211
+ module = importlib.import_module(module_path)
212
+
213
+ if not hasattr(module, graph_name):
214
+ raise AttributeError(
215
+ f"Module '{module_path}' does not have a '{graph_name}' variable. "
216
+ f"Available: {', '.join(dir(module))}"
217
+ )
218
+
219
+ graph = getattr(module, graph_name)
220
+ return graph
221
+
222
+
223
+ def load_graph(spec: str, default_graph_name: str = "graph"):
224
+ """
225
+ Load a graph from either a file path or module path.
226
+
227
+ Supports formats:
228
+ - path/to/file.py (uses default_graph_name)
229
+ - path/to/file.py:graph_name
230
+ - package.module (uses default_graph_name)
231
+ - package.module:graph_name
232
+
233
+ Args:
234
+ spec: File path or module path, optionally with :graph_name suffix
235
+ default_graph_name: Graph name to use if not specified in spec
236
+
237
+ Returns:
238
+ The loaded graph object
239
+ """
240
+ # Parse the spec to extract graph name if present
241
+ if ':' in spec:
242
+ path_or_module, graph_name = spec.rsplit(':', 1)
243
+ if not graph_name:
244
+ graph_name = default_graph_name
245
+ else:
246
+ path_or_module = spec
247
+ graph_name = default_graph_name
248
+
249
+ # Determine if it's a file path or module path
250
+ # File paths end with .py or contain path separators
251
+ is_file_path = (
252
+ path_or_module.endswith('.py') or
253
+ '/' in path_or_module or
254
+ '\\' in path_or_module or
255
+ Path(path_or_module).exists()
256
+ )
257
+
258
+ if is_file_path:
259
+ return load_graph_from_file(path_or_module, graph_name), graph_name
260
+ else:
261
+ return load_graph_from_module(path_or_module, graph_name), graph_name
262
+
263
+
264
+ def get_tool_arg_preview(args: Dict[str, Any]) -> str:
265
+ """Get a preview of the first argument value (nanocode style)."""
266
+ if not args:
267
+ return ""
268
+ # Get first value
269
+ first_val = str(list(args.values())[0])
270
+ # Truncate if needed
271
+ if len(first_val) > 50:
272
+ return first_val[:50] + "..."
273
+ return first_val
274
+
275
+
276
+ def format_result_preview(result: str) -> str:
277
+ """Format a result preview with line count indicator."""
278
+ if not result:
279
+ return "(empty)"
280
+ lines = result.split("\n")
281
+ preview = lines[0][:60]
282
+ if len(lines) > 1:
283
+ preview += f" ... +{len(lines) - 1} lines"
284
+ elif len(lines[0]) > 60:
285
+ preview += "..."
286
+ return preview
287
+
288
+
289
+ def print_chunk(chunk: Dict[str, Any], verbose: bool = False):
290
+ """
291
+ Pretty print a chunk from the stream using Claude Code styling.
292
+
293
+ Args:
294
+ chunk: The chunk dictionary
295
+ verbose: Whether to show verbose output
296
+ """
297
+ status = chunk.get("status")
298
+
299
+ if status == "streaming":
300
+ # Handle text chunks - cyan bullet with text
301
+ if "chunk" in chunk:
302
+ text = chunk["chunk"]
303
+ node = chunk.get("node", "unknown")
304
+ if verbose:
305
+ print(f"{DIM}[{node}]{RESET} {text}", end="")
306
+ else:
307
+ # Print text output with cyan bullet (only on first chunk or after newline)
308
+ print(f"{CYAN}⏺{RESET} {render_markdown(text)}", end="")
309
+
310
+ # Handle tool calls - green bullet with tool name
311
+ elif "tool_calls" in chunk:
312
+ for tool_call in chunk["tool_calls"]:
313
+ tool_name = tool_call["name"]
314
+ args = tool_call.get("args", {})
315
+ arg_preview = get_tool_arg_preview(args)
316
+
317
+ print(f"\n{GREEN}⏺ {tool_name.capitalize()}{RESET}({DIM}{arg_preview}{RESET})")
318
+
319
+ # Handle tool results - indented with result preview
320
+ elif "tool_result" in chunk:
321
+ result = chunk.get("tool_result", "")
322
+ preview = format_result_preview(str(result))
323
+ print(f" {DIM}⎿ {preview}{RESET}")
324
+
325
+ elif status == "interrupt":
326
+ interrupt_data = chunk.get("interrupt", {})
327
+ action_requests = interrupt_data.get("action_requests", [])
328
+
329
+ print(f"\n{YELLOW}⏺ Interrupt{RESET}")
330
+ if action_requests:
331
+ for i, action in enumerate(action_requests):
332
+ tool = action.get('tool', 'unknown')
333
+ args_preview = get_tool_arg_preview(action.get('args', {}))
334
+ print(f" {DIM}{i + 1}. {tool}({args_preview}){RESET}")
335
+
336
+ elif status == "complete":
337
+ pass # No output on complete (nanocode style)
338
+
339
+ elif status == "error":
340
+ error_msg = chunk.get("error", "Unknown error")
341
+ print(f"\n{RED}⏺ Error: {error_msg}{RESET}")
342
+
343
+
344
+ def get_key() -> str:
345
+ """Read a single keypress from stdin."""
346
+ fd = sys.stdin.fileno()
347
+ old_settings = termios.tcgetattr(fd)
348
+ try:
349
+ tty.setraw(fd)
350
+ ch = sys.stdin.read(1)
351
+ # Handle escape sequences (arrow keys)
352
+ if ch == '\x1b':
353
+ ch2 = sys.stdin.read(1)
354
+ if ch2 == '[':
355
+ ch3 = sys.stdin.read(1)
356
+ if ch3 == 'A':
357
+ return 'up'
358
+ elif ch3 == 'B':
359
+ return 'down'
360
+ elif ch == '\r' or ch == '\n':
361
+ return 'enter'
362
+ elif ch == '\x03': # Ctrl+C
363
+ return 'ctrl-c'
364
+ return ch
365
+ finally:
366
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
367
+
368
+
369
+ def select_option(options: List[str], prompt: str = "Select an option:") -> int:
370
+ """
371
+ Interactive option selector using arrow keys.
372
+
373
+ Args:
374
+ options: List of option strings to display
375
+ prompt: Prompt to show above options
376
+
377
+ Returns:
378
+ Index of selected option (0-based)
379
+ """
380
+ selected = 0
381
+ num_options = len(options)
382
+
383
+ # Hide cursor
384
+ print("\033[?25l", end="")
385
+
386
+ try:
387
+ print(f"\n{BOLD}{prompt}{RESET}")
388
+
389
+ # Print initial options
390
+ for i, opt in enumerate(options):
391
+ if i == selected:
392
+ print(f" {CYAN}❯ {opt}{RESET}")
393
+ else:
394
+ print(f" {DIM}{opt}{RESET}")
395
+
396
+ while True:
397
+ key = get_key()
398
+
399
+ if key == 'up' and selected > 0:
400
+ selected -= 1
401
+ elif key == 'down' and selected < num_options - 1:
402
+ selected += 1
403
+ elif key == 'enter':
404
+ break
405
+ elif key == 'ctrl-c':
406
+ print("\033[?25h", end="") # Show cursor
407
+ sys.exit(0)
408
+
409
+ # Move cursor up to redraw options
410
+ print(f"\033[{num_options}A", end="")
411
+
412
+ # Redraw options
413
+ for i, opt in enumerate(options):
414
+ # Clear line and print option
415
+ print("\033[2K", end="") # Clear line
416
+ if i == selected:
417
+ print(f" {CYAN}❯ {opt}{RESET}")
418
+ else:
419
+ print(f" {DIM}{opt}{RESET}")
420
+
421
+ return selected
422
+ finally:
423
+ # Show cursor
424
+ print("\033[?25h", end="")
425
+
426
+
427
+ def handle_interrupt_input(num_actions: int = 1) -> List[Dict[str, Any]]:
428
+ """
429
+ Handle user input for interrupt decisions using arrow key navigation.
430
+
431
+ Args:
432
+ num_actions: Number of pending tool calls that need decisions
433
+
434
+ Returns:
435
+ List of decision objects (one for each pending action)
436
+ """
437
+ options = [
438
+ "Approve all actions",
439
+ "Reject all actions",
440
+ "Provide custom decision (JSON)",
441
+ "Exit",
442
+ ]
443
+
444
+ choice = select_option(options, "How would you like to proceed?")
445
+
446
+ if choice == 0:
447
+ # Return approve decision for each pending action
448
+ return [{"type": "approve"} for _ in range(num_actions)]
449
+ elif choice == 1:
450
+ # Return reject decision for each pending action
451
+ return [{"type": "reject"} for _ in range(num_actions)]
452
+ elif choice == 2:
453
+ print("Enter your decision as JSON (will be applied to all actions):")
454
+ json_str = input(f"{BOLD}{BLUE}❯{RESET} ").strip()
455
+ try:
456
+ decision = json.loads(json_str)
457
+ return [decision for _ in range(num_actions)]
458
+ except json.JSONDecodeError as e:
459
+ print(f"{RED}⏺ Invalid JSON: {e}{RESET}")
460
+ return [{"type": "reject"} for _ in range(num_actions)]
461
+ else:
462
+ sys.exit(0)
463
+
464
+
465
+ async def run_single_turn_async(
466
+ graph,
467
+ message: str,
468
+ config: Optional[Dict[str, Any]] = None,
469
+ interactive: bool = True,
470
+ verbose: bool = False,
471
+ stream_mode: str = "updates",
472
+ ):
473
+ """Run a single turn of an async LangGraph graph."""
474
+ input_data = prepare_agent_input(message=message)
475
+
476
+ while True:
477
+ has_interrupt = False
478
+ num_pending_actions = 0
479
+ first_chunk = True
480
+ spinner = Spinner("Thinking")
481
+ spinner.start()
482
+
483
+ async for chunk in astream_graph_updates(graph, input_data, config=config, stream_mode=stream_mode):
484
+ # Stop spinner on first chunk
485
+ if first_chunk:
486
+ spinner.stop()
487
+ first_chunk = False
488
+
489
+ print_chunk(chunk, verbose=verbose)
490
+
491
+ if chunk.get("status") == "interrupt":
492
+ has_interrupt = True
493
+ # Count pending action requests
494
+ interrupt_data = chunk.get("interrupt", {})
495
+ action_requests = interrupt_data.get("action_requests", [])
496
+ num_pending_actions = len(action_requests) if action_requests else 1
497
+
498
+ # Ensure spinner is stopped even if no chunks received
499
+ if first_chunk:
500
+ spinner.stop()
501
+
502
+ if has_interrupt and interactive:
503
+ decisions = handle_interrupt_input(num_pending_actions)
504
+ input_data = prepare_agent_input(decisions=decisions)
505
+ else:
506
+ break
507
+
508
+
509
+ def run_single_turn_sync(
510
+ graph,
511
+ message: str,
512
+ config: Optional[Dict[str, Any]] = None,
513
+ interactive: bool = True,
514
+ verbose: bool = False,
515
+ stream_mode: str = "updates",
516
+ ):
517
+ """Run a single turn of a sync LangGraph graph."""
518
+ input_data = prepare_agent_input(message=message)
519
+
520
+ while True:
521
+ has_interrupt = False
522
+ num_pending_actions = 0
523
+ first_chunk = True
524
+ spinner = Spinner("Thinking")
525
+ spinner.start()
526
+
527
+ for chunk in stream_graph_updates(graph, input_data, config=config, stream_mode=stream_mode):
528
+ # Stop spinner on first chunk
529
+ if first_chunk:
530
+ spinner.stop()
531
+ first_chunk = False
532
+
533
+ print_chunk(chunk, verbose=verbose)
534
+
535
+ if chunk.get("status") == "interrupt":
536
+ has_interrupt = True
537
+ # Count pending action requests
538
+ interrupt_data = chunk.get("interrupt", {})
539
+ action_requests = interrupt_data.get("action_requests", [])
540
+ num_pending_actions = len(action_requests) if action_requests else 1
541
+
542
+ # Ensure spinner is stopped even if no chunks received
543
+ if first_chunk:
544
+ spinner.stop()
545
+
546
+ if has_interrupt and interactive:
547
+ decisions = handle_interrupt_input(num_pending_actions)
548
+ input_data = prepare_agent_input(decisions=decisions)
549
+ else:
550
+ break
551
+
552
+
553
+ def run_conversation_loop(
554
+ graph,
555
+ config: Dict[str, Any],
556
+ agent_name: str = "AgentCode",
557
+ use_async: bool = False,
558
+ interactive: bool = True,
559
+ verbose: bool = False,
560
+ stream_mode: str = "updates",
561
+ initial_message: Optional[str] = None,
562
+ ):
563
+ """
564
+ Run a continuous conversation loop with the LangGraph agent.
565
+ Styled after Claude Code / nanocode.
566
+ """
567
+ # Print box-drawn header with agent name
568
+ print_header_box(agent_name, os.getcwd())
569
+
570
+ # Process initial message if provided
571
+ if initial_message:
572
+ print(separator())
573
+ print(f"{BOLD}{BLUE}❯{RESET} {initial_message}")
574
+ print(separator())
575
+
576
+ if use_async:
577
+ asyncio.run(
578
+ run_single_turn_async(graph, initial_message, config, interactive, verbose, stream_mode)
579
+ )
580
+ else:
581
+ run_single_turn_sync(graph, initial_message, config, interactive, verbose, stream_mode)
582
+ print()
583
+
584
+ # Main conversation loop
585
+ while True:
586
+ try:
587
+ print(separator())
588
+ user_input = input(f"{BOLD}{BLUE}❯{RESET} ").strip()
589
+ print(separator())
590
+
591
+ if not user_input:
592
+ continue
593
+
594
+ # Handle special commands
595
+ if user_input in ("/q", "/quit", "/exit", "exit"):
596
+ break
597
+
598
+ if user_input == "/c":
599
+ # Generate new thread_id to start fresh conversation
600
+ config["configurable"]["thread_id"] = str(uuid.uuid4())
601
+ print(f"{GREEN}⏺ Cleared conversation{RESET}")
602
+ continue
603
+
604
+ if user_input in ("/h", "/help"):
605
+ print(f"\n{BOLD}Commands:{RESET}")
606
+ print(f" /q, /quit, exit - Exit")
607
+ print(f" /c - Clear conversation")
608
+ print(f" /h, /help - Show this help\n")
609
+ continue
610
+
611
+ # Run the agent
612
+ if use_async:
613
+ asyncio.run(
614
+ run_single_turn_async(graph, user_input, config, interactive, verbose, stream_mode)
615
+ )
616
+ else:
617
+ run_single_turn_sync(graph, user_input, config, interactive, verbose, stream_mode)
618
+ print()
619
+
620
+ except (EOFError, KeyboardInterrupt):
621
+ break
622
+ except Exception as err:
623
+ print(f"{RED}⏺ Error: {err}{RESET}")
624
+
625
+
626
+ @click.command()
627
+ @click.argument("agent_spec", required=False)
628
+ @click.option(
629
+ "--graph-name",
630
+ "-g",
631
+ help="Name of the graph variable (default: 'graph', overridden if spec includes :name)",
632
+ )
633
+ @click.option(
634
+ "--message",
635
+ "-m",
636
+ help="Input message to send to the agent",
637
+ )
638
+ @click.option(
639
+ "--config",
640
+ "-c",
641
+ help="Configuration JSON string or path to JSON file",
642
+ )
643
+ @click.option(
644
+ "--interactive/--no-interactive",
645
+ default=True,
646
+ help="Handle interrupts interactively (default: True)",
647
+ )
648
+ @click.option(
649
+ "--async-mode/--sync-mode",
650
+ "use_async",
651
+ default=False,
652
+ help="Use async streaming (default: sync)",
653
+ )
654
+ @click.option(
655
+ "--stream-mode",
656
+ help="Stream mode for LangGraph (default: 'updates')",
657
+ )
658
+ @click.option(
659
+ "--verbose",
660
+ "-v",
661
+ is_flag=True,
662
+ help="Show verbose output including node names",
663
+ )
664
+ def main(
665
+ agent_spec: Optional[str],
666
+ graph_name: Optional[str],
667
+ message: Optional[str],
668
+ config: Optional[str],
669
+ interactive: bool,
670
+ use_async: bool,
671
+ stream_mode: Optional[str],
672
+ verbose: bool,
673
+ ):
674
+ """
675
+ Run a LangGraph agent from the command line.
676
+
677
+ AGENT_SPEC can be:
678
+ \b
679
+ - path/to/file.py (uses default graph name 'graph')
680
+ - path/to/file.py:agent (specifies graph variable name)
681
+ - package.module (Python module path)
682
+ - package.module:agent (module with graph variable name)
683
+
684
+ Supports environment variables for configuration:
685
+
686
+ \b
687
+ - DEEPAGENT_AGENT_SPEC: Agent location (same formats as above)
688
+ - DEEPAGENT_WORKSPACE_ROOT: Working directory for the agent
689
+ - DEEPAGENT_CONFIG: Configuration JSON string or path to JSON file
690
+ - DEEPAGENT_STREAM_MODE: Stream mode for LangGraph (updates or values)
691
+
692
+ Command-line arguments override environment variables.
693
+
694
+ \b
695
+ Examples:
696
+ deepagent-code my_agent.py
697
+ deepagent-code my_agent.py:graph
698
+ deepagent-code mypackage.agents:chatbot
699
+ deepagent-code -m "Hello, agent!"
700
+ """
701
+ try:
702
+ # Get environment variables
703
+ env_agent_spec = os.getenv('DEEPAGENT_AGENT_SPEC')
704
+ env_workspace_root = os.getenv('DEEPAGENT_WORKSPACE_ROOT')
705
+ env_config = os.getenv('DEEPAGENT_CONFIG')
706
+ env_stream_mode = os.getenv('DEEPAGENT_STREAM_MODE', 'updates')
707
+
708
+ # Determine which spec to use (CLI arg > env var > default)
709
+ final_spec = agent_spec or env_agent_spec
710
+ default_graph_name = graph_name or "graph"
711
+
712
+ # If no spec provided, try the default agent
713
+ if not final_spec:
714
+ default_agent_path = Path(__file__).parent.parent / "examples" / "agent.py"
715
+ if default_agent_path.exists():
716
+ final_spec = f"{default_agent_path}:agent"
717
+ else:
718
+ print(f"{RED}⏺ Error: No agent specified.{RESET}")
719
+ print(f"\n{DIM}Usage:{RESET}")
720
+ print(f" deepagent-code path/to/agent.py:graph")
721
+ print(f" deepagent-code mypackage.module:agent")
722
+ print(f"\n{DIM}Or set DEEPAGENT_AGENT_SPEC environment variable{RESET}")
723
+ sys.exit(1)
724
+
725
+ # Change to workspace root if specified
726
+ if env_workspace_root:
727
+ workspace_path = Path(env_workspace_root).resolve()
728
+ if workspace_path.exists():
729
+ os.chdir(workspace_path)
730
+
731
+ # Load the graph
732
+ print(f"{DIM}Loading {final_spec}...{RESET}")
733
+ graph, final_graph_name = load_graph(final_spec, default_graph_name)
734
+
735
+ # Parse config
736
+ config_dict = None
737
+ config_source = config or env_config
738
+
739
+ if config_source:
740
+ config_path = Path(config_source)
741
+ if config_path.exists():
742
+ with open(config_path) as f:
743
+ config_dict = json.load(f)
744
+ else:
745
+ try:
746
+ config_dict = json.loads(config_source)
747
+ except json.JSONDecodeError as e:
748
+ print(f"{RED}⏺ Invalid config JSON: {e}{RESET}")
749
+ sys.exit(1)
750
+
751
+ # Get stream mode
752
+ final_stream_mode = stream_mode or env_stream_mode
753
+
754
+ # Ensure config has a thread_id for checkpointer support
755
+ if config_dict is None:
756
+ config_dict = {}
757
+ if "configurable" not in config_dict:
758
+ config_dict["configurable"] = {}
759
+ if "thread_id" not in config_dict["configurable"]:
760
+ config_dict["configurable"]["thread_id"] = str(uuid.uuid4())
761
+
762
+ # Extract agent name from graph object
763
+ agent_name = get_agent_name(graph)
764
+
765
+ # Run the conversation loop
766
+ run_conversation_loop(
767
+ graph=graph,
768
+ config=config_dict,
769
+ agent_name=agent_name,
770
+ use_async=use_async,
771
+ interactive=interactive,
772
+ verbose=verbose,
773
+ stream_mode=final_stream_mode,
774
+ initial_message=message,
775
+ )
776
+
777
+ except FileNotFoundError as e:
778
+ print(f"{RED}⏺ Error: {e}{RESET}")
779
+ sys.exit(1)
780
+ except AttributeError as e:
781
+ print(f"{RED}⏺ Error: {e}{RESET}")
782
+ sys.exit(1)
783
+ except ModuleNotFoundError as e:
784
+ print(f"{RED}⏺ Error: {e}{RESET}")
785
+ print(f"\n{DIM}Make sure your agent's dependencies are installed.{RESET}")
786
+ sys.exit(1)
787
+ except Exception as e:
788
+ print(f"{RED}⏺ Error: {e}{RESET}")
789
+ if verbose:
790
+ import traceback
791
+ print(traceback.format_exc())
792
+ sys.exit(1)
793
+
794
+
795
+ if __name__ == "__main__":
796
+ main()