emdash-cli 0.1.46__py3-none-any.whl → 0.1.70__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. emdash_cli/client.py +12 -28
  2. emdash_cli/commands/__init__.py +2 -2
  3. emdash_cli/commands/agent/constants.py +78 -0
  4. emdash_cli/commands/agent/handlers/__init__.py +10 -0
  5. emdash_cli/commands/agent/handlers/agents.py +67 -39
  6. emdash_cli/commands/agent/handlers/index.py +183 -0
  7. emdash_cli/commands/agent/handlers/misc.py +119 -0
  8. emdash_cli/commands/agent/handlers/registry.py +72 -0
  9. emdash_cli/commands/agent/handlers/rules.py +48 -31
  10. emdash_cli/commands/agent/handlers/sessions.py +1 -1
  11. emdash_cli/commands/agent/handlers/setup.py +187 -54
  12. emdash_cli/commands/agent/handlers/skills.py +42 -4
  13. emdash_cli/commands/agent/handlers/telegram.py +523 -0
  14. emdash_cli/commands/agent/handlers/todos.py +55 -34
  15. emdash_cli/commands/agent/handlers/verify.py +10 -5
  16. emdash_cli/commands/agent/help.py +236 -0
  17. emdash_cli/commands/agent/interactive.py +278 -47
  18. emdash_cli/commands/agent/menus.py +116 -84
  19. emdash_cli/commands/agent/onboarding.py +619 -0
  20. emdash_cli/commands/agent/session_restore.py +210 -0
  21. emdash_cli/commands/index.py +111 -13
  22. emdash_cli/commands/registry.py +635 -0
  23. emdash_cli/commands/skills.py +72 -6
  24. emdash_cli/design.py +328 -0
  25. emdash_cli/diff_renderer.py +438 -0
  26. emdash_cli/integrations/__init__.py +1 -0
  27. emdash_cli/integrations/telegram/__init__.py +15 -0
  28. emdash_cli/integrations/telegram/bot.py +402 -0
  29. emdash_cli/integrations/telegram/bridge.py +980 -0
  30. emdash_cli/integrations/telegram/config.py +155 -0
  31. emdash_cli/integrations/telegram/formatter.py +392 -0
  32. emdash_cli/main.py +52 -2
  33. emdash_cli/sse_renderer.py +632 -171
  34. {emdash_cli-0.1.46.dist-info → emdash_cli-0.1.70.dist-info}/METADATA +2 -2
  35. emdash_cli-0.1.70.dist-info/RECORD +63 -0
  36. emdash_cli/commands/swarm.py +0 -86
  37. emdash_cli-0.1.46.dist-info/RECORD +0 -49
  38. {emdash_cli-0.1.46.dist-info → emdash_cli-0.1.70.dist-info}/WHEEL +0 -0
  39. {emdash_cli-0.1.46.dist-info → emdash_cli-0.1.70.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,155 @@
1
+ """Telegram configuration management.
2
+
3
+ Handles storage and retrieval of Telegram bot configuration including
4
+ bot token, authorized chat IDs, and user preferences.
5
+ """
6
+
7
+ import json
8
+ from dataclasses import dataclass, field, asdict
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ # Config file location
14
+ CONFIG_DIR = Path.home() / ".emdash"
15
+ CONFIG_FILE = CONFIG_DIR / "telegram.json"
16
+
17
+
18
+ @dataclass
19
+ class TelegramSettings:
20
+ """User settings for Telegram integration."""
21
+
22
+ # How to display streaming responses: "edit" updates a single message,
23
+ # "append" sends new messages for each update
24
+ streaming_mode: str = "edit"
25
+
26
+ # Minimum interval between message edits (ms) to avoid rate limits
27
+ update_interval_ms: int = 500
28
+
29
+ # Whether to show agent thinking/reasoning
30
+ show_thinking: bool = False
31
+
32
+ # Whether to show tool calls and results
33
+ show_tool_calls: bool = True
34
+
35
+ # Use compact formatting for responses
36
+ compact_mode: bool = False
37
+
38
+ # Maximum message length before splitting (Telegram limit is 4096)
39
+ max_message_length: int = 4000
40
+
41
+
42
+ @dataclass
43
+ class TelegramState:
44
+ """Runtime state for Telegram integration."""
45
+
46
+ # Whether the integration is enabled
47
+ enabled: bool = False
48
+
49
+ # Last successful connection timestamp (ISO format)
50
+ last_connected: str | None = None
51
+
52
+ # Last update_id processed (for long-polling offset)
53
+ last_update_id: int = 0
54
+
55
+
56
+ @dataclass
57
+ class TelegramConfig:
58
+ """Complete Telegram configuration."""
59
+
60
+ # Bot token from @BotFather
61
+ bot_token: str | None = None
62
+
63
+ # List of authorized chat IDs that can interact with the bot
64
+ authorized_chats: list[int] = field(default_factory=list)
65
+
66
+ # User settings
67
+ settings: TelegramSettings = field(default_factory=TelegramSettings)
68
+
69
+ # Runtime state
70
+ state: TelegramState = field(default_factory=TelegramState)
71
+
72
+ def is_configured(self) -> bool:
73
+ """Check if the bot token is configured."""
74
+ return bool(self.bot_token)
75
+
76
+ def is_chat_authorized(self, chat_id: int) -> bool:
77
+ """Check if a chat ID is authorized to use the bot."""
78
+ # If no chats are configured, allow all (during setup)
79
+ if not self.authorized_chats:
80
+ return True
81
+ return chat_id in self.authorized_chats
82
+
83
+ def add_authorized_chat(self, chat_id: int) -> None:
84
+ """Add a chat ID to the authorized list."""
85
+ if chat_id not in self.authorized_chats:
86
+ self.authorized_chats.append(chat_id)
87
+
88
+ def remove_authorized_chat(self, chat_id: int) -> None:
89
+ """Remove a chat ID from the authorized list."""
90
+ if chat_id in self.authorized_chats:
91
+ self.authorized_chats.remove(chat_id)
92
+
93
+ def to_dict(self) -> dict[str, Any]:
94
+ """Convert config to dictionary for JSON serialization."""
95
+ return {
96
+ "bot_token": self.bot_token,
97
+ "authorized_chats": self.authorized_chats,
98
+ "settings": asdict(self.settings),
99
+ "state": asdict(self.state),
100
+ }
101
+
102
+ @classmethod
103
+ def from_dict(cls, data: dict[str, Any]) -> "TelegramConfig":
104
+ """Create config from dictionary."""
105
+ settings_data = data.get("settings", {})
106
+ state_data = data.get("state", {})
107
+
108
+ return cls(
109
+ bot_token=data.get("bot_token"),
110
+ authorized_chats=data.get("authorized_chats", []),
111
+ settings=TelegramSettings(**settings_data),
112
+ state=TelegramState(**state_data),
113
+ )
114
+
115
+
116
+ def get_config() -> TelegramConfig:
117
+ """Load Telegram configuration from disk.
118
+
119
+ Returns:
120
+ TelegramConfig instance (empty config if file doesn't exist)
121
+ """
122
+ if not CONFIG_FILE.exists():
123
+ return TelegramConfig()
124
+
125
+ try:
126
+ with open(CONFIG_FILE, "r") as f:
127
+ data = json.load(f)
128
+ return TelegramConfig.from_dict(data)
129
+ except (json.JSONDecodeError, KeyError, TypeError):
130
+ # Return empty config if file is corrupted
131
+ return TelegramConfig()
132
+
133
+
134
+ def save_config(config: TelegramConfig) -> None:
135
+ """Save Telegram configuration to disk.
136
+
137
+ Args:
138
+ config: TelegramConfig instance to save
139
+ """
140
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
141
+
142
+ with open(CONFIG_FILE, "w") as f:
143
+ json.dump(config.to_dict(), f, indent=2)
144
+
145
+
146
+ def delete_config() -> bool:
147
+ """Delete the Telegram configuration file.
148
+
149
+ Returns:
150
+ True if file was deleted, False if it didn't exist
151
+ """
152
+ if CONFIG_FILE.exists():
153
+ CONFIG_FILE.unlink()
154
+ return True
155
+ return False
@@ -0,0 +1,392 @@
1
+ """Format SSE events for Telegram messages.
2
+
3
+ Converts EmDash agent SSE events into Telegram-friendly formatted messages.
4
+ Handles markdown escaping, message length limits, and visual formatting.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+
11
+ # Telegram message length limit
12
+ MAX_MESSAGE_LENGTH = 4096
13
+
14
+ # Status icons for Telegram
15
+ ICON_SESSION = "🚀"
16
+ ICON_THINKING = "💭"
17
+ ICON_TOOL = "🔧"
18
+ ICON_TOOL_SUCCESS = "✅"
19
+ ICON_TOOL_ERROR = "❌"
20
+ ICON_RESPONSE = "💬"
21
+ ICON_ERROR = "⚠️"
22
+ ICON_COMPLETE = "✨"
23
+ ICON_PROGRESS = "⏳"
24
+ ICON_CLARIFICATION = "❓"
25
+
26
+
27
+ def escape_markdown(text: str) -> str:
28
+ """Escape special characters for Telegram Markdown.
29
+
30
+ Telegram uses a subset of Markdown. Characters that need escaping:
31
+ _ * [ ] ( ) ~ ` > # + - = | { } . !
32
+
33
+ Args:
34
+ text: Raw text to escape
35
+
36
+ Returns:
37
+ Escaped text safe for Telegram Markdown
38
+ """
39
+ # Characters to escape for MarkdownV2
40
+ # For regular Markdown mode, we only need to escape a few
41
+ escape_chars = ["_", "*", "[", "`"]
42
+ for char in escape_chars:
43
+ text = text.replace(char, f"\\{char}")
44
+ return text
45
+
46
+
47
+ def truncate_text(text: str, max_length: int = MAX_MESSAGE_LENGTH) -> str:
48
+ """Truncate text to fit Telegram's message limit.
49
+
50
+ Args:
51
+ text: Text to truncate
52
+ max_length: Maximum length (default: Telegram's 4096 limit)
53
+
54
+ Returns:
55
+ Truncated text with ellipsis if needed
56
+ """
57
+ if len(text) <= max_length:
58
+ return text
59
+ return text[: max_length - 3] + "..."
60
+
61
+
62
+ @dataclass
63
+ class TelegramMessage:
64
+ """A formatted message ready for Telegram."""
65
+
66
+ text: str
67
+ parse_mode: str | None = "Markdown"
68
+ is_update: bool = False # If True, update previous message instead of sending new
69
+
70
+ def __post_init__(self):
71
+ # Ensure text fits in Telegram's limit
72
+ self.text = truncate_text(self.text)
73
+
74
+
75
+ @dataclass
76
+ class MessageAggregator:
77
+ """Aggregates partial responses into complete messages.
78
+
79
+ Handles rate limiting by batching updates and only sending
80
+ when enough content has accumulated or time has passed.
81
+ """
82
+
83
+ # Current accumulated content
84
+ content: str = ""
85
+
86
+ # Whether we have unsent content
87
+ dirty: bool = False
88
+
89
+ # Message ID of the message being updated (for edit mode)
90
+ message_id: int | None = None
91
+
92
+ # Minimum characters before sending an update
93
+ min_update_chars: int = 50
94
+
95
+ # Current tool being executed (for status display)
96
+ current_tool: str | None = None
97
+
98
+ # Session info
99
+ session_id: str | None = None
100
+
101
+ # Completed tools for summary
102
+ completed_tools: list = field(default_factory=list)
103
+
104
+ def add_partial(self, content: str) -> bool:
105
+ """Add partial content.
106
+
107
+ Args:
108
+ content: Partial response content to add
109
+
110
+ Returns:
111
+ True if enough content has accumulated to send an update
112
+ """
113
+ self.content += content
114
+ self.dirty = True
115
+ return len(self.content) >= self.min_update_chars
116
+
117
+ def get_update_message(self) -> TelegramMessage | None:
118
+ """Get the current content as an update message.
119
+
120
+ Returns:
121
+ TelegramMessage if there's content to send, None otherwise
122
+ """
123
+ if not self.content:
124
+ return None
125
+
126
+ self.dirty = False
127
+ return TelegramMessage(
128
+ text=self.content,
129
+ is_update=self.message_id is not None,
130
+ )
131
+
132
+ def reset(self) -> None:
133
+ """Reset the aggregator for a new response."""
134
+ self.content = ""
135
+ self.dirty = False
136
+ self.message_id = None
137
+ self.current_tool = None
138
+ self.completed_tools = []
139
+
140
+
141
+ class SSEEventFormatter:
142
+ """Formats SSE events into Telegram messages."""
143
+
144
+ def __init__(self, show_thinking: bool = False, show_tools: bool = True, compact: bool = False):
145
+ """Initialize the formatter.
146
+
147
+ Args:
148
+ show_thinking: Whether to show agent thinking/reasoning
149
+ show_tools: Whether to show tool calls
150
+ compact: Use compact formatting
151
+ """
152
+ self.show_thinking = show_thinking
153
+ self.show_tools = show_tools
154
+ self.compact = compact
155
+ self.aggregator = MessageAggregator()
156
+
157
+ def format_event(self, event_type: str, data: dict) -> TelegramMessage | None:
158
+ """Format an SSE event into a Telegram message.
159
+
160
+ Args:
161
+ event_type: Type of SSE event
162
+ data: Event data dict
163
+
164
+ Returns:
165
+ TelegramMessage if the event should be sent, None to skip
166
+ """
167
+ if event_type == "session_start":
168
+ return self._format_session_start(data)
169
+ elif event_type == "thinking":
170
+ return self._format_thinking(data)
171
+ elif event_type == "tool_start":
172
+ return self._format_tool_start(data)
173
+ elif event_type == "tool_result":
174
+ return self._format_tool_result(data)
175
+ elif event_type == "partial_response":
176
+ return self._format_partial(data)
177
+ elif event_type == "response":
178
+ return self._format_response(data)
179
+ elif event_type == "error":
180
+ return self._format_error(data)
181
+ elif event_type == "clarification":
182
+ return self._format_clarification(data)
183
+ elif event_type == "session_end":
184
+ return self._format_session_end(data)
185
+ elif event_type == "progress":
186
+ return self._format_progress(data)
187
+
188
+ return None
189
+
190
+ def _format_session_start(self, data: dict) -> TelegramMessage:
191
+ """Format session start event."""
192
+ self.aggregator.reset()
193
+ self.aggregator.session_id = data.get("session_id")
194
+
195
+ agent = data.get("agent_name", "Agent")
196
+ if self.compact:
197
+ return TelegramMessage(text=f"{ICON_SESSION} *{agent}* started")
198
+ return TelegramMessage(
199
+ text=f"{ICON_SESSION} *{agent}* session started\n_{self.aggregator.session_id}_"
200
+ )
201
+
202
+ def _format_thinking(self, data: dict) -> TelegramMessage | None:
203
+ """Format thinking event."""
204
+ if not self.show_thinking:
205
+ return None
206
+
207
+ content = data.get("message", data.get("content", ""))
208
+ if not content:
209
+ return None
210
+
211
+ # Truncate long thinking
212
+ if len(content) > 200:
213
+ content = content[:197] + "..."
214
+
215
+ return TelegramMessage(text=f"{ICON_THINKING} _{escape_markdown(content)}_")
216
+
217
+ def _format_tool_start(self, data: dict) -> TelegramMessage | None:
218
+ """Format tool start event."""
219
+ if not self.show_tools:
220
+ return None
221
+
222
+ name = data.get("name", "unknown")
223
+ self.aggregator.current_tool = name
224
+
225
+ # Skip sub-agent tool events
226
+ if data.get("subagent_id"):
227
+ return None
228
+
229
+ # Format tool args summary
230
+ args = data.get("args", {})
231
+ summary = self._format_tool_args(name, args)
232
+
233
+ if self.compact:
234
+ return TelegramMessage(text=f"{ICON_TOOL} `{name}`")
235
+
236
+ if summary:
237
+ return TelegramMessage(text=f"{ICON_TOOL} `{name}` {summary}")
238
+ return TelegramMessage(text=f"{ICON_TOOL} `{name}`")
239
+
240
+ def _format_tool_result(self, data: dict) -> TelegramMessage | None:
241
+ """Format tool result event."""
242
+ if not self.show_tools:
243
+ return None
244
+
245
+ name = data.get("name", "unknown")
246
+ success = data.get("success", True)
247
+
248
+ # Skip sub-agent tool events
249
+ if data.get("subagent_id"):
250
+ return None
251
+
252
+ # Track completed tools
253
+ self.aggregator.completed_tools.append({"name": name, "success": success})
254
+ self.aggregator.current_tool = None
255
+
256
+ # In compact mode, don't show individual tool results
257
+ if self.compact:
258
+ return None
259
+
260
+ icon = ICON_TOOL_SUCCESS if success else ICON_TOOL_ERROR
261
+ return TelegramMessage(text=f"{icon} `{name}` completed")
262
+
263
+ def _format_partial(self, data: dict) -> TelegramMessage | None:
264
+ """Format partial response event.
265
+
266
+ Accumulates content and returns update when threshold is reached.
267
+ """
268
+ content = data.get("content", "")
269
+ if not content:
270
+ return None
271
+
272
+ should_update = self.aggregator.add_partial(content)
273
+ if should_update:
274
+ return self.aggregator.get_update_message()
275
+
276
+ return None
277
+
278
+ def _format_response(self, data: dict) -> TelegramMessage:
279
+ """Format final response event."""
280
+ content = data.get("content", "")
281
+
282
+ # Check if we were streaming partial content (should update existing message)
283
+ was_streaming = bool(self.aggregator.content)
284
+
285
+ # Reset aggregator
286
+ self.aggregator.reset()
287
+
288
+ # Format response with icon
289
+ if self.compact:
290
+ return TelegramMessage(text=content, parse_mode="Markdown", is_update=was_streaming)
291
+
292
+ return TelegramMessage(
293
+ text=f"{ICON_RESPONSE}\n\n{content}",
294
+ parse_mode="Markdown",
295
+ is_update=was_streaming,
296
+ )
297
+
298
+ def _format_error(self, data: dict) -> TelegramMessage:
299
+ """Format error event."""
300
+ message = data.get("message", "Unknown error")
301
+ details = data.get("details", "")
302
+
303
+ text = f"{ICON_ERROR} *Error:* {escape_markdown(message)}"
304
+ if details and not self.compact:
305
+ text += f"\n_{escape_markdown(details)}_"
306
+
307
+ return TelegramMessage(text=text)
308
+
309
+ def _format_clarification(self, data: dict) -> TelegramMessage:
310
+ """Format clarification request."""
311
+ question = data.get("question", "")
312
+ options = data.get("options", [])
313
+
314
+ text = f"{ICON_CLARIFICATION} *Question:*\n{question}"
315
+
316
+ if options and isinstance(options, list):
317
+ text += "\n\n*Options:*"
318
+ for i, opt in enumerate(options, 1):
319
+ text += f"\n{i}. {opt}"
320
+
321
+ return TelegramMessage(text=text)
322
+
323
+ def _format_session_end(self, data: dict) -> TelegramMessage | None:
324
+ """Format session end event."""
325
+ success = data.get("success", True)
326
+
327
+ if not success:
328
+ error = data.get("error", "Unknown error")
329
+ return TelegramMessage(text=f"{ICON_ERROR} Session ended with error: {error}")
330
+
331
+ if self.compact:
332
+ tools_count = len(self.aggregator.completed_tools)
333
+ if tools_count > 0:
334
+ return TelegramMessage(text=f"{ICON_COMPLETE} Done ({tools_count} tools)")
335
+ return TelegramMessage(text=f"{ICON_COMPLETE} Done")
336
+
337
+ return None
338
+
339
+ def _format_progress(self, data: dict) -> TelegramMessage | None:
340
+ """Format progress event."""
341
+ if self.compact:
342
+ return None
343
+
344
+ message = data.get("message", "")
345
+ percent = data.get("percent")
346
+
347
+ if percent is not None:
348
+ return TelegramMessage(text=f"{ICON_PROGRESS} {message} ({percent}%)")
349
+
350
+ return TelegramMessage(text=f"{ICON_PROGRESS} {message}")
351
+
352
+ def _format_tool_args(self, tool_name: str, args: dict) -> str:
353
+ """Format tool args into a short summary."""
354
+ if not args:
355
+ return ""
356
+
357
+ # Tool-specific formatting
358
+ if tool_name in ("glob", "grep", "semantic_search"):
359
+ pattern = args.get("pattern", args.get("query", ""))
360
+ if pattern:
361
+ if len(pattern) > 40:
362
+ pattern = pattern[:37] + "..."
363
+ return f'`"{pattern}"`'
364
+
365
+ elif tool_name in ("read_file", "write_to_file", "write_file", "edit"):
366
+ path = args.get("path", args.get("file_path", ""))
367
+ if path:
368
+ # Show just filename
369
+ if "/" in path:
370
+ path = path.split("/")[-1]
371
+ return f"`{path}`"
372
+
373
+ elif tool_name == "bash":
374
+ cmd = args.get("command", "")
375
+ if cmd:
376
+ if len(cmd) > 40:
377
+ cmd = cmd[:37] + "..."
378
+ return f"`{cmd}`"
379
+
380
+ return ""
381
+
382
+ def get_pending_content(self) -> TelegramMessage | None:
383
+ """Get any pending accumulated content.
384
+
385
+ Call this when the stream ends to flush remaining content.
386
+
387
+ Returns:
388
+ TelegramMessage with remaining content, or None
389
+ """
390
+ if self.aggregator.dirty and self.aggregator.content:
391
+ return self.aggregator.get_update_message()
392
+ return None
emdash_cli/main.py CHANGED
@@ -1,6 +1,8 @@
1
1
  """Main CLI entry point for emdash-cli."""
2
2
 
3
3
  import os
4
+ import subprocess
5
+ import sys
4
6
 
5
7
  import click
6
8
 
@@ -12,12 +14,12 @@ from .commands import (
12
14
  embed,
13
15
  index,
14
16
  plan,
17
+ registry,
15
18
  rules,
16
19
  search,
17
20
  server,
18
21
  skills,
19
22
  team,
20
- swarm,
21
23
  projectmd,
22
24
  research,
23
25
  spec,
@@ -43,11 +45,11 @@ cli.add_command(analyze)
43
45
  cli.add_command(embed)
44
46
  cli.add_command(index)
45
47
  cli.add_command(plan)
48
+ cli.add_command(registry)
46
49
  cli.add_command(rules)
47
50
  cli.add_command(server)
48
51
  cli.add_command(skills)
49
52
  cli.add_command(team)
50
- cli.add_command(swarm)
51
53
 
52
54
  # Register standalone commands
53
55
  cli.add_command(search)
@@ -61,6 +63,54 @@ from .commands.server import server_killall
61
63
  cli.add_command(server_killall, name="killall")
62
64
 
63
65
 
66
+ # Update command - runs install.sh to update emdash
67
+ @click.command()
68
+ @click.option("--with-graph", is_flag=True, help="Install with graph database support")
69
+ @click.option("--reinstall", is_flag=True, help="Force reinstall (removes existing installation)")
70
+ def update(with_graph: bool, reinstall: bool):
71
+ """Update emdash to the latest version.
72
+
73
+ Downloads and runs the official install script from GitHub.
74
+
75
+ Examples:
76
+ emdash update # Update to latest
77
+ emdash update --with-graph # Update with graph support
78
+ emdash update --reinstall # Force reinstall
79
+ """
80
+ install_url = "https://raw.githubusercontent.com/mendyEdri/emdash.dev/main/scripts/install.sh"
81
+
82
+ # Build command
83
+ cmd = f"curl -sSL {install_url} | bash"
84
+ if with_graph or reinstall:
85
+ args = []
86
+ if with_graph:
87
+ args.append("--with-graph")
88
+ if reinstall:
89
+ args.append("--reinstall")
90
+ cmd = f"curl -sSL {install_url} | bash -s -- {' '.join(args)}"
91
+
92
+ click.echo("Updating emdash...")
93
+ click.echo()
94
+
95
+ # Run the install script
96
+ try:
97
+ result = subprocess.run(
98
+ cmd,
99
+ shell=True,
100
+ executable="/bin/bash",
101
+ )
102
+ sys.exit(result.returncode)
103
+ except KeyboardInterrupt:
104
+ click.echo("\nUpdate cancelled.")
105
+ sys.exit(1)
106
+ except Exception as e:
107
+ click.echo(f"Update failed: {e}", err=True)
108
+ sys.exit(1)
109
+
110
+
111
+ cli.add_command(update)
112
+
113
+
64
114
  # Direct entry point for `em` command - wraps agent_code with click
65
115
  @click.command()
66
116
  @click.argument("task", required=False)