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,210 @@
1
+ """Session restore prompt for emdash CLI.
2
+
3
+ Detects recent sessions and offers to restore them with zen styling.
4
+ """
5
+
6
+ from datetime import datetime, timedelta
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from rich.console import Console
11
+ from prompt_toolkit import Application
12
+ from prompt_toolkit.key_binding import KeyBindings
13
+ from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
14
+ from prompt_toolkit.styles import Style
15
+
16
+ from ...design import (
17
+ Colors,
18
+ STATUS_ACTIVE,
19
+ STATUS_INACTIVE,
20
+ DOT_BULLET,
21
+ ARROW_PROMPT,
22
+ header,
23
+ footer,
24
+ )
25
+
26
+ console = Console()
27
+
28
+
29
+ def get_recent_session(client, max_age_hours: int = 24) -> Optional[dict]:
30
+ """Get the most recent session if within max_age.
31
+
32
+ Args:
33
+ client: Emdash client instance
34
+ max_age_hours: Maximum age in hours for session to be considered recent
35
+
36
+ Returns:
37
+ Session info dict or None
38
+ """
39
+ try:
40
+ sessions = client.list_sessions()
41
+ if not sessions:
42
+ return None
43
+
44
+ # Sort by updated_at (most recent first)
45
+ sessions = sorted(sessions, key=lambda s: s.updated_at or "", reverse=True)
46
+
47
+ if not sessions:
48
+ return None
49
+
50
+ recent = sessions[0]
51
+
52
+ # Check if session is recent enough
53
+ if recent.updated_at:
54
+ try:
55
+ updated = datetime.fromisoformat(recent.updated_at.replace("Z", "+00:00"))
56
+ cutoff = datetime.now(updated.tzinfo) - timedelta(hours=max_age_hours)
57
+ if updated < cutoff:
58
+ return None
59
+ except (ValueError, TypeError):
60
+ pass
61
+
62
+ # Only offer to restore if it has messages
63
+ if recent.message_count and recent.message_count > 0:
64
+ return {
65
+ "name": recent.name,
66
+ "summary": recent.summary,
67
+ "mode": recent.mode,
68
+ "message_count": recent.message_count,
69
+ "updated_at": recent.updated_at,
70
+ }
71
+
72
+ return None
73
+ except Exception:
74
+ return None
75
+
76
+
77
+ def format_relative_time(iso_time: str) -> str:
78
+ """Format ISO time as relative time (e.g., '2 hours ago')."""
79
+ try:
80
+ dt = datetime.fromisoformat(iso_time.replace("Z", "+00:00"))
81
+ now = datetime.now(dt.tzinfo)
82
+ delta = now - dt
83
+
84
+ if delta.days > 0:
85
+ return f"{delta.days} day{'s' if delta.days > 1 else ''} ago"
86
+ elif delta.seconds >= 3600:
87
+ hours = delta.seconds // 3600
88
+ return f"{hours} hour{'s' if hours > 1 else ''} ago"
89
+ elif delta.seconds >= 60:
90
+ minutes = delta.seconds // 60
91
+ return f"{minutes} minute{'s' if minutes > 1 else ''} ago"
92
+ else:
93
+ return "just now"
94
+ except (ValueError, TypeError):
95
+ return ""
96
+
97
+
98
+ def show_session_restore_prompt(session_info: dict) -> tuple[str, Optional[dict]]:
99
+ """Show prompt to restore a recent session.
100
+
101
+ Args:
102
+ session_info: Dict with session details
103
+
104
+ Returns:
105
+ Tuple of (choice, session_data) where choice is:
106
+ - 'restore': Restore the session
107
+ - 'new': Start new session
108
+ - 'view': View session details first
109
+ """
110
+ name = session_info.get("name", "unnamed")
111
+ summary = session_info.get("summary", "")
112
+ mode = session_info.get("mode", "code")
113
+ msg_count = session_info.get("message_count", 0)
114
+ updated = session_info.get("updated_at", "")
115
+
116
+ relative_time = format_relative_time(updated) if updated else ""
117
+
118
+ console.print()
119
+ console.print(f"[{Colors.MUTED}]{header('Session Found', 40)}[/{Colors.MUTED}]")
120
+ console.print()
121
+ console.print(f" [{Colors.DIM}]Previous session from {relative_time}:[/{Colors.DIM}]")
122
+ console.print()
123
+ console.print(f" {DOT_BULLET} [{Colors.MUTED}]{msg_count} messages[/{Colors.MUTED}]")
124
+ if summary:
125
+ truncated = summary[:60] + "..." if len(summary) > 60 else summary
126
+ console.print(f" {DOT_BULLET} [{Colors.MUTED}]{truncated}[/{Colors.MUTED}]")
127
+ console.print(f" {DOT_BULLET} [{Colors.MUTED}]Mode: {mode}[/{Colors.MUTED}]")
128
+ console.print()
129
+ console.print(f"[{Colors.MUTED}]{footer(40)}[/{Colors.MUTED}]")
130
+
131
+ selected_index = [0]
132
+ result = [("new", None)]
133
+
134
+ options = [
135
+ ("restore", "Restore this session"),
136
+ ("new", "Start new session"),
137
+ ]
138
+
139
+ kb = KeyBindings()
140
+
141
+ @kb.add("up")
142
+ @kb.add("k")
143
+ def move_up(event):
144
+ selected_index[0] = (selected_index[0] - 1) % len(options)
145
+
146
+ @kb.add("down")
147
+ @kb.add("j")
148
+ def move_down(event):
149
+ selected_index[0] = (selected_index[0] + 1) % len(options)
150
+
151
+ @kb.add("enter")
152
+ def select(event):
153
+ result[0] = (options[selected_index[0]][0], session_info if options[selected_index[0]][0] == "restore" else None)
154
+ event.app.exit()
155
+
156
+ @kb.add("r")
157
+ def restore(event):
158
+ result[0] = ("restore", session_info)
159
+ event.app.exit()
160
+
161
+ @kb.add("n")
162
+ def new(event):
163
+ result[0] = ("new", None)
164
+ event.app.exit()
165
+
166
+ @kb.add("c-c")
167
+ @kb.add("escape")
168
+ def cancel(event):
169
+ result[0] = ("new", None)
170
+ event.app.exit()
171
+
172
+ def get_formatted_options():
173
+ lines = []
174
+ for i, (key, desc) in enumerate(options):
175
+ indicator = STATUS_ACTIVE if i == selected_index[0] else STATUS_INACTIVE
176
+ style_class = "selected" if i == selected_index[0] else "option"
177
+ lines.append((f"class:{style_class}", f" {indicator} {desc}\n"))
178
+ lines.append(("class:hint", f"\n{ARROW_PROMPT} r restore n new Esc skip"))
179
+ return lines
180
+
181
+ style = Style.from_dict({
182
+ "selected": f"{Colors.SUCCESS} bold",
183
+ "option": Colors.MUTED,
184
+ "hint": f"{Colors.DIM} italic",
185
+ })
186
+
187
+ layout = Layout(
188
+ HSplit([
189
+ Window(
190
+ FormattedTextControl(get_formatted_options),
191
+ height=5,
192
+ ),
193
+ ])
194
+ )
195
+
196
+ app = Application(
197
+ layout=layout,
198
+ key_bindings=kb,
199
+ style=style,
200
+ full_screen=False,
201
+ )
202
+
203
+ console.print()
204
+
205
+ try:
206
+ app.run()
207
+ except (KeyboardInterrupt, EOFError):
208
+ return ("new", None)
209
+
210
+ return result[0]
@@ -23,16 +23,18 @@ def index():
23
23
 
24
24
  @index.command("start")
25
25
  @click.argument("repo_path", required=False)
26
- @click.option("--changed-only", is_flag=True, help="Only index changed files")
27
- @click.option("--skip-git", is_flag=True, help="Skip git history indexing")
28
- @click.option("--github-prs", default=0, help="Number of GitHub PRs to index")
26
+ @click.option("--full", is_flag=True, help="Force full reindex (default: incremental)")
27
+ @click.option("--with-git", is_flag=True, help="Include git history (Layer B)")
28
+ @click.option("--with-github", is_flag=True, help="Include GitHub PRs (Layer C)")
29
+ @click.option("--github-prs", default=50, help="Number of GitHub PRs to index (when --with-github)")
29
30
  @click.option("--detect-communities", is_flag=True, default=True, help="Run community detection")
30
31
  @click.option("--describe-communities", is_flag=True, help="Use LLM to describe communities")
31
32
  @click.option("--model", "-m", default=None, help="Model for community descriptions")
32
33
  def index_start(
33
34
  repo_path: str | None,
34
- changed_only: bool,
35
- skip_git: bool,
35
+ full: bool,
36
+ with_git: bool,
37
+ with_github: bool,
36
38
  github_prs: int,
37
39
  detect_communities: bool,
38
40
  describe_communities: bool,
@@ -42,11 +44,18 @@ def index_start(
42
44
 
43
45
  If REPO_PATH is not provided, indexes the current directory.
44
46
 
47
+ By default, indexes only code structure (Layer A - AST parsing).
48
+ Git history (Layer B) and GitHub PRs (Layer C) are skipped unless enabled.
49
+
50
+ Environment variables:
51
+ EMDASH_INDEX_GIT=true Enable git history indexing
52
+ EMDASH_INDEX_GITHUB=true Enable GitHub PR indexing
53
+
45
54
  Examples:
46
- emdash index start # Index current directory
47
- emdash index start /path/to/repo # Index specific repo
48
- emdash index start --changed-only # Only index changed files
49
- emdash index start --github-prs 50 # Also index 50 PRs
55
+ emdash index start # Code only (Layer A)
56
+ emdash index start --with-git # Include git history
57
+ emdash index start --with-github # Include GitHub PRs
58
+ emdash index start --full # Force full reindex
50
59
  """
51
60
  # Default to current directory
52
61
  if not repo_path:
@@ -58,11 +67,20 @@ def index_start(
58
67
 
59
68
  console.print(f"\n[bold cyan]Indexing[/bold cyan] {repo_path}\n")
60
69
 
70
+ # Check environment variables for Layer B and C
71
+ env_index_git = os.environ.get("EMDASH_INDEX_GIT", "").lower() == "true"
72
+ env_index_github = os.environ.get("EMDASH_INDEX_GITHUB", "").lower() == "true"
73
+
74
+ # Enable git/github if flag passed OR env var set
75
+ index_git = with_git or env_index_git
76
+ index_github = with_github or env_index_github
77
+
61
78
  # Build options
79
+ # Incremental mode is default (changed_only=True), unless --full is passed
62
80
  options = {
63
- "changed_only": changed_only,
64
- "index_git": not skip_git,
65
- "index_github": github_prs,
81
+ "changed_only": not full,
82
+ "index_git": index_git,
83
+ "index_github": github_prs if index_github else 0,
66
84
  "detect_communities": detect_communities,
67
85
  "describe_communities": describe_communities,
68
86
  }
@@ -82,7 +100,7 @@ def index_start(
82
100
  ) as progress:
83
101
  task = progress.add_task("Starting...", total=100)
84
102
 
85
- for line in client.index_start_stream(repo_path, changed_only):
103
+ for line in client.index_start_stream(repo_path, not full):
86
104
  line = line.strip()
87
105
  if line.startswith("event: "):
88
106
  continue
@@ -160,6 +178,86 @@ def _show_completion(repo_path: str, stats: dict, client: EmdashClient) -> None:
160
178
  console.print()
161
179
 
162
180
 
181
+ @index.command("hook")
182
+ @click.argument("action", type=click.Choice(["install", "uninstall"]))
183
+ @click.argument("repo_path", required=False)
184
+ def index_hook(action: str, repo_path: str | None):
185
+ """Install or uninstall the post-commit hook for automatic indexing.
186
+
187
+ The hook runs 'emdash index start' after each commit to keep the index updated.
188
+
189
+ Examples:
190
+ emdash index hook install # Install in current repo
191
+ emdash index hook install /path/repo # Install in specific repo
192
+ emdash index hook uninstall # Remove the hook
193
+ """
194
+ from pathlib import Path
195
+
196
+ # Default to current directory
197
+ if not repo_path:
198
+ repo_path = os.getcwd()
199
+
200
+ hooks_dir = Path(repo_path) / ".git" / "hooks"
201
+ hook_path = hooks_dir / "post-commit"
202
+
203
+ if not hooks_dir.exists():
204
+ console.print(f"[red]Error:[/red] Not a git repository: {repo_path}")
205
+ raise click.Abort()
206
+
207
+ hook_content = """#!/bin/sh
208
+ # emdash post-commit hook - auto-reindex on commit
209
+ # Installed by: emdash index hook install
210
+
211
+ # Run indexing in background to not block the commit
212
+ emdash index start > /dev/null 2>&1 &
213
+ """
214
+
215
+ if action == "install":
216
+ # Check if hook already exists
217
+ if hook_path.exists():
218
+ existing = hook_path.read_text()
219
+ if "emdash" in existing:
220
+ console.print("[yellow]Hook already installed[/yellow]")
221
+ return
222
+ else:
223
+ # Append to existing hook
224
+ console.print("[yellow]Appending to existing post-commit hook[/yellow]")
225
+ with open(hook_path, "a") as f:
226
+ f.write("\n# emdash auto-index\nemdash index start > /dev/null 2>&1 &\n")
227
+ else:
228
+ # Create new hook
229
+ hook_path.write_text(hook_content)
230
+
231
+ # Make executable
232
+ hook_path.chmod(0o755)
233
+ console.print(f"[green]Post-commit hook installed:[/green] {hook_path}")
234
+
235
+ elif action == "uninstall":
236
+ if not hook_path.exists():
237
+ console.print("[yellow]No post-commit hook found[/yellow]")
238
+ return
239
+
240
+ existing = hook_path.read_text()
241
+ if "emdash" not in existing:
242
+ console.print("[yellow]No emdash hook found in post-commit[/yellow]")
243
+ return
244
+
245
+ # Check if it's our hook entirely or just contains our line
246
+ if existing.strip() == hook_content.strip():
247
+ # It's only our hook, remove the file
248
+ hook_path.unlink()
249
+ console.print("[green]Post-commit hook removed[/green]")
250
+ else:
251
+ # Remove just our lines
252
+ lines = existing.split("\n")
253
+ new_lines = [
254
+ line for line in lines
255
+ if "emdash" not in line and "auto-reindex" not in line
256
+ ]
257
+ hook_path.write_text("\n".join(new_lines))
258
+ console.print("[green]Emdash hook lines removed from post-commit[/green]")
259
+
260
+
163
261
  @index.command("status")
164
262
  @click.argument("repo_path", required=False)
165
263
  def index_status(repo_path: str | None):