emdash-cli 0.1.35__py3-none-any.whl → 0.1.67__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.
- emdash_cli/client.py +41 -22
- emdash_cli/clipboard.py +30 -61
- emdash_cli/commands/__init__.py +2 -2
- emdash_cli/commands/agent/__init__.py +14 -0
- emdash_cli/commands/agent/cli.py +100 -0
- emdash_cli/commands/agent/constants.py +63 -0
- emdash_cli/commands/agent/file_utils.py +178 -0
- emdash_cli/commands/agent/handlers/__init__.py +51 -0
- emdash_cli/commands/agent/handlers/agents.py +449 -0
- emdash_cli/commands/agent/handlers/auth.py +69 -0
- emdash_cli/commands/agent/handlers/doctor.py +319 -0
- emdash_cli/commands/agent/handlers/hooks.py +121 -0
- emdash_cli/commands/agent/handlers/index.py +183 -0
- emdash_cli/commands/agent/handlers/mcp.py +183 -0
- emdash_cli/commands/agent/handlers/misc.py +319 -0
- emdash_cli/commands/agent/handlers/registry.py +72 -0
- emdash_cli/commands/agent/handlers/rules.py +411 -0
- emdash_cli/commands/agent/handlers/sessions.py +168 -0
- emdash_cli/commands/agent/handlers/setup.py +715 -0
- emdash_cli/commands/agent/handlers/skills.py +478 -0
- emdash_cli/commands/agent/handlers/telegram.py +475 -0
- emdash_cli/commands/agent/handlers/todos.py +119 -0
- emdash_cli/commands/agent/handlers/verify.py +653 -0
- emdash_cli/commands/agent/help.py +236 -0
- emdash_cli/commands/agent/interactive.py +842 -0
- emdash_cli/commands/agent/menus.py +760 -0
- emdash_cli/commands/agent/onboarding.py +619 -0
- emdash_cli/commands/agent/session_restore.py +210 -0
- emdash_cli/commands/agent.py +7 -1321
- emdash_cli/commands/index.py +111 -13
- emdash_cli/commands/registry.py +635 -0
- emdash_cli/commands/server.py +99 -40
- emdash_cli/commands/skills.py +72 -6
- emdash_cli/design.py +328 -0
- emdash_cli/diff_renderer.py +438 -0
- emdash_cli/integrations/__init__.py +1 -0
- emdash_cli/integrations/telegram/__init__.py +15 -0
- emdash_cli/integrations/telegram/bot.py +402 -0
- emdash_cli/integrations/telegram/bridge.py +865 -0
- emdash_cli/integrations/telegram/config.py +155 -0
- emdash_cli/integrations/telegram/formatter.py +385 -0
- emdash_cli/main.py +52 -2
- emdash_cli/server_manager.py +70 -10
- emdash_cli/sse_renderer.py +659 -167
- {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.dist-info}/METADATA +2 -4
- emdash_cli-0.1.67.dist-info/RECORD +63 -0
- emdash_cli/commands/swarm.py +0 -86
- emdash_cli-0.1.35.dist-info/RECORD +0 -30
- {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.dist-info}/WHEEL +0 -0
- {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.dist-info}/entry_points.txt +0 -0
emdash_cli/client.py
CHANGED
|
@@ -259,6 +259,18 @@ class EmdashClient:
|
|
|
259
259
|
"""
|
|
260
260
|
return self._client.get(f"{self.base_url}{path}")
|
|
261
261
|
|
|
262
|
+
def post(self, path: str, json: dict | None = None) -> "httpx.Response":
|
|
263
|
+
"""Make a POST request to the API.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
path: API path (e.g., "/api/agent/chat/123/compact")
|
|
267
|
+
json: Optional JSON body
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
HTTP response
|
|
271
|
+
"""
|
|
272
|
+
return self._client.post(f"{self.base_url}{path}", json=json)
|
|
273
|
+
|
|
262
274
|
def list_sessions(self) -> list[dict]:
|
|
263
275
|
"""List active agent sessions.
|
|
264
276
|
|
|
@@ -660,31 +672,38 @@ class EmdashClient:
|
|
|
660
672
|
response.raise_for_status()
|
|
661
673
|
return response.json()
|
|
662
674
|
|
|
663
|
-
# ====================
|
|
675
|
+
# ==================== Todos ====================
|
|
664
676
|
|
|
665
|
-
def
|
|
666
|
-
|
|
667
|
-
tasks: list[str],
|
|
668
|
-
model: Optional[str] = None,
|
|
669
|
-
auto_merge: bool = False,
|
|
670
|
-
) -> Iterator[str]:
|
|
671
|
-
"""Run multi-agent swarm with SSE streaming."""
|
|
672
|
-
payload = {"tasks": tasks, "auto_merge": auto_merge}
|
|
673
|
-
if model:
|
|
674
|
-
payload["model"] = model
|
|
677
|
+
def get_todos(self, session_id: str) -> dict:
|
|
678
|
+
"""Get the current todo list for a session.
|
|
675
679
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
f"{self.base_url}/api/swarm/run",
|
|
679
|
-
json=payload,
|
|
680
|
-
) as response:
|
|
681
|
-
response.raise_for_status()
|
|
682
|
-
for line in response.iter_lines():
|
|
683
|
-
yield line
|
|
680
|
+
Args:
|
|
681
|
+
session_id: Session ID
|
|
684
682
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
683
|
+
Returns:
|
|
684
|
+
Dict with todos list and summary
|
|
685
|
+
"""
|
|
686
|
+
response = self._client.get(
|
|
687
|
+
f"{self.base_url}/api/agent/chat/{session_id}/todos"
|
|
688
|
+
)
|
|
689
|
+
response.raise_for_status()
|
|
690
|
+
return response.json()
|
|
691
|
+
|
|
692
|
+
def add_todo(self, session_id: str, title: str, description: str = "") -> dict:
|
|
693
|
+
"""Add a new todo item to the agent's task list.
|
|
694
|
+
|
|
695
|
+
Args:
|
|
696
|
+
session_id: Session ID
|
|
697
|
+
title: Todo title
|
|
698
|
+
description: Optional description
|
|
699
|
+
|
|
700
|
+
Returns:
|
|
701
|
+
Dict with created task info
|
|
702
|
+
"""
|
|
703
|
+
response = self._client.post(
|
|
704
|
+
f"{self.base_url}/api/agent/chat/{session_id}/todos",
|
|
705
|
+
params={"title": title, "description": description},
|
|
706
|
+
)
|
|
688
707
|
response.raise_for_status()
|
|
689
708
|
return response.json()
|
|
690
709
|
|
emdash_cli/clipboard.py
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
|
-
"""Clipboard utilities for image handling.
|
|
1
|
+
"""Clipboard utilities for image handling.
|
|
2
|
+
|
|
3
|
+
Uses platform-native clipboard access (no Pillow dependency).
|
|
4
|
+
"""
|
|
2
5
|
|
|
3
6
|
import base64
|
|
4
|
-
import io
|
|
5
7
|
from typing import Optional, Tuple
|
|
6
8
|
|
|
9
|
+
from emdash_core.utils.image import (
|
|
10
|
+
read_clipboard_image,
|
|
11
|
+
is_clipboard_image_available,
|
|
12
|
+
get_image_info,
|
|
13
|
+
ClipboardImageError,
|
|
14
|
+
)
|
|
15
|
+
|
|
7
16
|
|
|
8
17
|
def get_clipboard_image() -> Optional[Tuple[str, str]]:
|
|
9
18
|
"""Get image from clipboard if available.
|
|
@@ -12,57 +21,28 @@ def get_clipboard_image() -> Optional[Tuple[str, str]]:
|
|
|
12
21
|
Tuple of (base64_data, format) if image found, None otherwise.
|
|
13
22
|
"""
|
|
14
23
|
try:
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
# Try to grab image from clipboard
|
|
18
|
-
image = ImageGrab.grabclipboard()
|
|
24
|
+
if not is_clipboard_image_available():
|
|
25
|
+
return None
|
|
19
26
|
|
|
20
|
-
|
|
27
|
+
image_data = read_clipboard_image()
|
|
28
|
+
if image_data is None:
|
|
21
29
|
return None
|
|
22
30
|
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
image = Image.open(image[0])
|
|
29
|
-
except Exception:
|
|
30
|
-
return None
|
|
31
|
-
else:
|
|
32
|
-
return None
|
|
33
|
-
|
|
34
|
-
# Convert to PNG bytes
|
|
35
|
-
if isinstance(image, Image.Image):
|
|
36
|
-
buffer = io.BytesIO()
|
|
37
|
-
# Convert to RGB if necessary (for RGBA images)
|
|
38
|
-
if image.mode in ('RGBA', 'LA') or (image.mode == 'P' and 'transparency' in image.info):
|
|
39
|
-
# Keep as PNG to preserve transparency
|
|
40
|
-
image.save(buffer, format='PNG')
|
|
41
|
-
img_format = 'png'
|
|
42
|
-
else:
|
|
43
|
-
# Convert to JPEG for smaller size
|
|
44
|
-
if image.mode != 'RGB':
|
|
45
|
-
image = image.convert('RGB')
|
|
46
|
-
image.save(buffer, format='JPEG', quality=85)
|
|
47
|
-
img_format = 'jpeg'
|
|
48
|
-
|
|
49
|
-
buffer.seek(0)
|
|
50
|
-
base64_data = base64.b64encode(buffer.read()).decode('utf-8')
|
|
51
|
-
return base64_data, img_format
|
|
52
|
-
|
|
53
|
-
except ImportError:
|
|
54
|
-
# PIL not available
|
|
31
|
+
# Encode to base64
|
|
32
|
+
base64_data = base64.b64encode(image_data).decode('utf-8')
|
|
33
|
+
return base64_data, 'png'
|
|
34
|
+
|
|
35
|
+
except ClipboardImageError:
|
|
55
36
|
return None
|
|
56
37
|
except Exception:
|
|
57
|
-
# Any other error (no clipboard access, etc.)
|
|
58
38
|
return None
|
|
59
39
|
|
|
60
|
-
return None
|
|
61
|
-
|
|
62
40
|
|
|
63
41
|
def get_image_from_path(path: str) -> Optional[Tuple[str, str]]:
|
|
64
42
|
"""Load image from file path.
|
|
65
43
|
|
|
44
|
+
Only PNG files are fully supported. Other formats will be read as raw bytes.
|
|
45
|
+
|
|
66
46
|
Args:
|
|
67
47
|
path: Path to image file
|
|
68
48
|
|
|
@@ -70,34 +50,23 @@ def get_image_from_path(path: str) -> Optional[Tuple[str, str]]:
|
|
|
70
50
|
Tuple of (base64_data, format) if successful, None otherwise.
|
|
71
51
|
"""
|
|
72
52
|
try:
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
image = Image.open(path)
|
|
76
|
-
buffer = io.BytesIO()
|
|
53
|
+
with open(path, 'rb') as f:
|
|
54
|
+
image_data = f.read()
|
|
77
55
|
|
|
78
56
|
# Determine format from file extension
|
|
79
57
|
ext = path.lower().split('.')[-1]
|
|
80
58
|
if ext in ('jpg', 'jpeg'):
|
|
81
|
-
if image.mode != 'RGB':
|
|
82
|
-
image = image.convert('RGB')
|
|
83
|
-
image.save(buffer, format='JPEG', quality=85)
|
|
84
59
|
img_format = 'jpeg'
|
|
85
60
|
elif ext == 'png':
|
|
86
|
-
image.save(buffer, format='PNG')
|
|
87
61
|
img_format = 'png'
|
|
88
62
|
elif ext == 'gif':
|
|
89
|
-
image.save(buffer, format='GIF')
|
|
90
63
|
img_format = 'gif'
|
|
91
64
|
elif ext == 'webp':
|
|
92
|
-
image.save(buffer, format='WEBP')
|
|
93
65
|
img_format = 'webp'
|
|
94
66
|
else:
|
|
95
|
-
# Default to PNG
|
|
96
|
-
image.save(buffer, format='PNG')
|
|
97
67
|
img_format = 'png'
|
|
98
68
|
|
|
99
|
-
|
|
100
|
-
base64_data = base64.b64encode(buffer.read()).decode('utf-8')
|
|
69
|
+
base64_data = base64.b64encode(image_data).decode('utf-8')
|
|
101
70
|
return base64_data, img_format
|
|
102
71
|
|
|
103
72
|
except Exception:
|
|
@@ -105,7 +74,7 @@ def get_image_from_path(path: str) -> Optional[Tuple[str, str]]:
|
|
|
105
74
|
|
|
106
75
|
|
|
107
76
|
def get_image_dimensions(base64_data: str) -> Optional[Tuple[int, int]]:
|
|
108
|
-
"""Get dimensions of base64-encoded image.
|
|
77
|
+
"""Get dimensions of base64-encoded PNG image.
|
|
109
78
|
|
|
110
79
|
Args:
|
|
111
80
|
base64_data: Base64-encoded image data
|
|
@@ -114,10 +83,10 @@ def get_image_dimensions(base64_data: str) -> Optional[Tuple[int, int]]:
|
|
|
114
83
|
Tuple of (width, height) if successful, None otherwise.
|
|
115
84
|
"""
|
|
116
85
|
try:
|
|
117
|
-
from PIL import Image
|
|
118
|
-
|
|
119
86
|
image_bytes = base64.b64decode(base64_data)
|
|
120
|
-
|
|
121
|
-
|
|
87
|
+
info = get_image_info(image_bytes)
|
|
88
|
+
if info.get("width") and info.get("height"):
|
|
89
|
+
return info["width"], info["height"]
|
|
90
|
+
return None
|
|
122
91
|
except Exception:
|
|
123
92
|
return None
|
emdash_cli/commands/__init__.py
CHANGED
|
@@ -7,12 +7,12 @@ from .analyze import analyze
|
|
|
7
7
|
from .embed import embed
|
|
8
8
|
from .index import index
|
|
9
9
|
from .plan import plan
|
|
10
|
+
from .registry import registry
|
|
10
11
|
from .rules import rules
|
|
11
12
|
from .search import search
|
|
12
13
|
from .server import server
|
|
13
14
|
from .skills import skills
|
|
14
15
|
from .team import team
|
|
15
|
-
from .swarm import swarm
|
|
16
16
|
from .projectmd import projectmd
|
|
17
17
|
from .research import research
|
|
18
18
|
from .spec import spec
|
|
@@ -26,12 +26,12 @@ __all__ = [
|
|
|
26
26
|
"embed",
|
|
27
27
|
"index",
|
|
28
28
|
"plan",
|
|
29
|
+
"registry",
|
|
29
30
|
"rules",
|
|
30
31
|
"search",
|
|
31
32
|
"server",
|
|
32
33
|
"skills",
|
|
33
34
|
"team",
|
|
34
|
-
"swarm",
|
|
35
35
|
"projectmd",
|
|
36
36
|
"research",
|
|
37
37
|
"spec",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Agent CLI commands package.
|
|
2
|
+
|
|
3
|
+
This package contains the refactored agent CLI code, split into:
|
|
4
|
+
- cli.py: Click command definitions
|
|
5
|
+
- constants.py: Enums and constants
|
|
6
|
+
- file_utils.py: File reference expansion utilities
|
|
7
|
+
- menus.py: Interactive prompt_toolkit menus
|
|
8
|
+
- interactive.py: Main REPL loop
|
|
9
|
+
- handlers/: Slash command handlers
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .cli import agent, agent_code
|
|
13
|
+
|
|
14
|
+
__all__ = ["agent", "agent_code"]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Click CLI commands for the agent."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from ...client import EmdashClient
|
|
9
|
+
from ...server_manager import get_server_manager
|
|
10
|
+
from ...sse_renderer import SSERenderer
|
|
11
|
+
from .interactive import run_interactive, run_single_task
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group()
|
|
17
|
+
def agent():
|
|
18
|
+
"""AI agent commands."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@agent.command("code")
|
|
23
|
+
@click.argument("task", required=False)
|
|
24
|
+
@click.option("--model", "-m", default=None, help="Model to use")
|
|
25
|
+
@click.option("--mode", type=click.Choice(["plan", "code"]), default="code",
|
|
26
|
+
help="Starting mode")
|
|
27
|
+
@click.option("--quiet", "-q", is_flag=True, help="Less verbose output")
|
|
28
|
+
@click.option("--max-iterations", default=int(os.getenv("EMDASH_MAX_ITERATIONS", "100")), help="Max agent iterations")
|
|
29
|
+
@click.option("--no-graph-tools", is_flag=True, help="Skip graph exploration tools")
|
|
30
|
+
@click.option("--save", is_flag=True, help="Save specs to specs/<feature>/")
|
|
31
|
+
def agent_code(
|
|
32
|
+
task: str | None,
|
|
33
|
+
model: str | None,
|
|
34
|
+
mode: str,
|
|
35
|
+
quiet: bool,
|
|
36
|
+
max_iterations: int,
|
|
37
|
+
no_graph_tools: bool,
|
|
38
|
+
save: bool,
|
|
39
|
+
):
|
|
40
|
+
"""Start the coding agent.
|
|
41
|
+
|
|
42
|
+
With TASK: Run single task and exit
|
|
43
|
+
Without TASK: Start interactive REPL mode
|
|
44
|
+
|
|
45
|
+
MODES:
|
|
46
|
+
plan - Explore codebase and create plans (read-only)
|
|
47
|
+
code - Execute code changes (default)
|
|
48
|
+
|
|
49
|
+
SLASH COMMANDS (in interactive mode):
|
|
50
|
+
/plan - Switch to plan mode
|
|
51
|
+
/code - Switch to code mode
|
|
52
|
+
/help - Show available commands
|
|
53
|
+
/reset - Reset session
|
|
54
|
+
|
|
55
|
+
Examples:
|
|
56
|
+
emdash # Interactive code mode
|
|
57
|
+
emdash agent code # Same as above
|
|
58
|
+
emdash agent code --mode plan # Start in plan mode
|
|
59
|
+
emdash agent code "Fix the login bug" # Single task
|
|
60
|
+
"""
|
|
61
|
+
# Get server URL (starts server if needed)
|
|
62
|
+
server = get_server_manager()
|
|
63
|
+
base_url = server.get_server_url()
|
|
64
|
+
|
|
65
|
+
client = EmdashClient(base_url)
|
|
66
|
+
renderer = SSERenderer(console=console, verbose=not quiet)
|
|
67
|
+
|
|
68
|
+
options = {
|
|
69
|
+
"mode": mode,
|
|
70
|
+
"no_graph_tools": no_graph_tools,
|
|
71
|
+
"save": save,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if task:
|
|
75
|
+
# Single task mode
|
|
76
|
+
run_single_task(client, renderer, task, model, max_iterations, options)
|
|
77
|
+
else:
|
|
78
|
+
# Interactive REPL mode
|
|
79
|
+
run_interactive(client, renderer, model, max_iterations, options)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@agent.command("sessions")
|
|
83
|
+
def list_sessions():
|
|
84
|
+
"""List active agent sessions."""
|
|
85
|
+
server = get_server_manager()
|
|
86
|
+
base_url = server.get_server_url()
|
|
87
|
+
|
|
88
|
+
client = EmdashClient(base_url)
|
|
89
|
+
sessions = client.list_sessions()
|
|
90
|
+
|
|
91
|
+
if not sessions:
|
|
92
|
+
console.print("[dim]No active sessions[/dim]")
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
for s in sessions:
|
|
96
|
+
console.print(
|
|
97
|
+
f" {s['session_id'][:8]}... "
|
|
98
|
+
f"[dim]({s.get('model', 'unknown')}, "
|
|
99
|
+
f"{s.get('message_count', 0)} messages)[/dim]"
|
|
100
|
+
)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Constants and enums for the agent CLI."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AgentMode(Enum):
|
|
7
|
+
"""Agent operation modes."""
|
|
8
|
+
PLAN = "plan"
|
|
9
|
+
CODE = "code"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Slash commands available in interactive mode
|
|
13
|
+
SLASH_COMMANDS = {
|
|
14
|
+
# Mode switching
|
|
15
|
+
"/plan": "Switch to plan mode (explore codebase, create plans)",
|
|
16
|
+
"/code": "Switch to code mode (execute file changes)",
|
|
17
|
+
"/mode": "Show current mode",
|
|
18
|
+
# Generation commands
|
|
19
|
+
"/pr [url]": "Review a pull request",
|
|
20
|
+
"/projectmd": "Generate PROJECT.md for the codebase",
|
|
21
|
+
"/research [goal]": "Deep research on a topic",
|
|
22
|
+
# Status commands
|
|
23
|
+
"/status": "Show index and PROJECT.md status",
|
|
24
|
+
"/diff": "Show uncommitted changes in GitHub-style diff view",
|
|
25
|
+
"/agents": "Manage agents (interactive menu, or /agents [create|show|edit|delete] <name>)",
|
|
26
|
+
# Todo management
|
|
27
|
+
"/todos": "Show current agent todo list",
|
|
28
|
+
"/todo-add [title]": "Add a todo item for the agent (e.g., /todo-add Fix tests)",
|
|
29
|
+
# Session management
|
|
30
|
+
"/session": "Save, load, or list sessions (e.g., /session save my-task)",
|
|
31
|
+
"/spec": "Show current specification",
|
|
32
|
+
"/reset": "Reset session state",
|
|
33
|
+
# Hooks
|
|
34
|
+
"/hooks": "Manage hooks (list, add, remove, toggle)",
|
|
35
|
+
# Rules
|
|
36
|
+
"/rules": "Manage rules (list, add, delete)",
|
|
37
|
+
# Skills
|
|
38
|
+
"/skills": "Manage skills (list, show, add, delete)",
|
|
39
|
+
# Index
|
|
40
|
+
"/index": "Manage codebase index (status, start, hook install/uninstall)",
|
|
41
|
+
# MCP
|
|
42
|
+
"/mcp": "Manage global MCP servers (list, edit)",
|
|
43
|
+
# Registry
|
|
44
|
+
"/registry": "Browse and install community skills, rules, agents, verifiers",
|
|
45
|
+
# Auth
|
|
46
|
+
"/auth": "GitHub authentication (login, logout, status)",
|
|
47
|
+
# Context
|
|
48
|
+
"/context": "Show current context frame (tokens, reranked items)",
|
|
49
|
+
"/compact": "Compact message history using LLM summarization",
|
|
50
|
+
# Image
|
|
51
|
+
"/paste": "Attach image from clipboard (or use Ctrl+V)",
|
|
52
|
+
# Diagnostics
|
|
53
|
+
"/doctor": "Check Python environment and diagnose issues",
|
|
54
|
+
# Verification
|
|
55
|
+
"/verify": "Run verification checks on current work",
|
|
56
|
+
"/verify-loop [task]": "Run task in loop until verifications pass",
|
|
57
|
+
# Setup wizard
|
|
58
|
+
"/setup": "Setup wizard for rules, agents, skills, and verifiers",
|
|
59
|
+
# Telegram integration
|
|
60
|
+
"/telegram": "Telegram integration (setup, connect, status, test)",
|
|
61
|
+
"/help": "Show available commands",
|
|
62
|
+
"/quit": "Exit the agent",
|
|
63
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""File utilities for the agent CLI.
|
|
2
|
+
|
|
3
|
+
Handles @file reference expansion and fuzzy file finding.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import fnmatch
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def fuzzy_find_files(query: str, limit: int = 10) -> list[Path]:
|
|
17
|
+
"""Find files matching a fuzzy query.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
query: File name or partial path to search for
|
|
21
|
+
limit: Maximum number of results
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
List of matching file paths
|
|
25
|
+
"""
|
|
26
|
+
cwd = Path.cwd()
|
|
27
|
+
matches = []
|
|
28
|
+
|
|
29
|
+
# Common directories to skip
|
|
30
|
+
skip_dirs = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', 'dist', 'build', '.emdash'}
|
|
31
|
+
|
|
32
|
+
# Walk the directory tree (more control than glob for skipping dirs)
|
|
33
|
+
for root, dirs, files in os.walk(cwd):
|
|
34
|
+
# Skip unwanted directories
|
|
35
|
+
dirs[:] = [d for d in dirs if d not in skip_dirs and not d.startswith('.')]
|
|
36
|
+
|
|
37
|
+
rel_root = Path(root).relative_to(cwd)
|
|
38
|
+
|
|
39
|
+
for file in files:
|
|
40
|
+
# Skip hidden files
|
|
41
|
+
if file.startswith('.'):
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
rel_path = rel_root / file if str(rel_root) != '.' else Path(file)
|
|
45
|
+
full_path = cwd / rel_path
|
|
46
|
+
|
|
47
|
+
# Check if query matches (case-insensitive)
|
|
48
|
+
path_str = str(rel_path).lower()
|
|
49
|
+
query_lower = query.lower()
|
|
50
|
+
|
|
51
|
+
if query_lower in path_str or fnmatch.fnmatch(path_str, f"*{query_lower}*"):
|
|
52
|
+
matches.append(full_path)
|
|
53
|
+
|
|
54
|
+
if len(matches) >= limit:
|
|
55
|
+
return matches
|
|
56
|
+
|
|
57
|
+
return matches
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def select_file_interactive(matches: list[Path], query: str) -> Path | None:
|
|
61
|
+
"""Show interactive file selection menu.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
matches: List of matching file paths
|
|
65
|
+
query: Original query string
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Selected file path or None if cancelled
|
|
69
|
+
"""
|
|
70
|
+
if not matches:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
if len(matches) == 1:
|
|
74
|
+
return matches[0]
|
|
75
|
+
|
|
76
|
+
cwd = Path.cwd()
|
|
77
|
+
|
|
78
|
+
# Print numbered list
|
|
79
|
+
console.print(f"\n[bold cyan]Select file for @{query}:[/bold cyan]\n")
|
|
80
|
+
|
|
81
|
+
for i, path in enumerate(matches):
|
|
82
|
+
try:
|
|
83
|
+
rel_path = path.relative_to(cwd)
|
|
84
|
+
except ValueError:
|
|
85
|
+
rel_path = path
|
|
86
|
+
console.print(f" [bold]{i + 1}[/bold]) {rel_path}")
|
|
87
|
+
|
|
88
|
+
console.print(f"\n[dim]Enter number (1-{len(matches)}) or press Enter to cancel:[/dim]")
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
from prompt_toolkit import PromptSession
|
|
92
|
+
selection_session = PromptSession()
|
|
93
|
+
choice = selection_session.prompt("").strip()
|
|
94
|
+
if choice.isdigit():
|
|
95
|
+
idx = int(choice) - 1
|
|
96
|
+
if 0 <= idx < len(matches):
|
|
97
|
+
return matches[idx]
|
|
98
|
+
except (KeyboardInterrupt, EOFError):
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def expand_file_references(message: str) -> tuple[str, list[str]]:
|
|
105
|
+
"""Expand @file references in a message to include file contents.
|
|
106
|
+
|
|
107
|
+
Supports:
|
|
108
|
+
- @file.txt - exact path (relative or absolute)
|
|
109
|
+
- @utils - fuzzy search for files containing "utils"
|
|
110
|
+
- @~/path/file.txt - home directory paths
|
|
111
|
+
|
|
112
|
+
Shows interactive selection if multiple files match.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
message: User message potentially containing @file references
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Tuple of (expanded_message, list_of_included_files)
|
|
119
|
+
"""
|
|
120
|
+
# Pattern to match @word (not followed by space immediately, at least 2 chars)
|
|
121
|
+
pattern = r'@([^\s@]{2,})'
|
|
122
|
+
|
|
123
|
+
files_included = []
|
|
124
|
+
file_contents = []
|
|
125
|
+
replacements = {} # Store replacements to apply after iteration
|
|
126
|
+
|
|
127
|
+
# Find all @references
|
|
128
|
+
for match in re.finditer(pattern, message):
|
|
129
|
+
file_query = match.group(1)
|
|
130
|
+
original = match.group(0)
|
|
131
|
+
|
|
132
|
+
# Skip if already processed
|
|
133
|
+
if original in replacements:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
# Expand ~ to home directory
|
|
137
|
+
if file_query.startswith("~"):
|
|
138
|
+
file_query_expanded = os.path.expanduser(file_query)
|
|
139
|
+
else:
|
|
140
|
+
file_query_expanded = file_query
|
|
141
|
+
|
|
142
|
+
# Check if it's an exact path first
|
|
143
|
+
path = Path(file_query_expanded)
|
|
144
|
+
if not path.is_absolute():
|
|
145
|
+
path = Path.cwd() / path
|
|
146
|
+
|
|
147
|
+
resolved_path = None
|
|
148
|
+
|
|
149
|
+
if path.exists() and path.is_file():
|
|
150
|
+
# Exact match
|
|
151
|
+
resolved_path = path
|
|
152
|
+
else:
|
|
153
|
+
# Fuzzy search
|
|
154
|
+
matches = fuzzy_find_files(file_query)
|
|
155
|
+
if matches:
|
|
156
|
+
resolved_path = select_file_interactive(matches, file_query)
|
|
157
|
+
|
|
158
|
+
if resolved_path:
|
|
159
|
+
try:
|
|
160
|
+
content = resolved_path.read_text()
|
|
161
|
+
files_included.append(str(resolved_path))
|
|
162
|
+
file_contents.append(f"\n\n**File: {resolved_path.name}**\n```\n{content}\n```")
|
|
163
|
+
replacements[original] = "" # Remove the @reference
|
|
164
|
+
except Exception:
|
|
165
|
+
pass # Can't read file, leave as-is
|
|
166
|
+
|
|
167
|
+
# Apply replacements
|
|
168
|
+
expanded_message = message
|
|
169
|
+
for original, replacement in replacements.items():
|
|
170
|
+
expanded_message = expanded_message.replace(original, replacement)
|
|
171
|
+
|
|
172
|
+
expanded_message = expanded_message.strip()
|
|
173
|
+
|
|
174
|
+
# Append file contents to the message
|
|
175
|
+
if file_contents:
|
|
176
|
+
expanded_message = expanded_message + "\n" + "\n".join(file_contents)
|
|
177
|
+
|
|
178
|
+
return expanded_message, files_included
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Slash command handlers for the agent CLI."""
|
|
2
|
+
|
|
3
|
+
from .agents import handle_agents
|
|
4
|
+
from .sessions import handle_session
|
|
5
|
+
from .todos import handle_todos, handle_todo_add
|
|
6
|
+
from .hooks import handle_hooks
|
|
7
|
+
from .rules import handle_rules
|
|
8
|
+
from .skills import handle_skills
|
|
9
|
+
from .index import handle_index
|
|
10
|
+
from .mcp import handle_mcp
|
|
11
|
+
from .registry import handle_registry
|
|
12
|
+
from .auth import handle_auth
|
|
13
|
+
from .doctor import handle_doctor
|
|
14
|
+
from .verify import handle_verify, handle_verify_loop
|
|
15
|
+
from .setup import handle_setup
|
|
16
|
+
from .misc import (
|
|
17
|
+
handle_status,
|
|
18
|
+
handle_pr,
|
|
19
|
+
handle_projectmd,
|
|
20
|
+
handle_research,
|
|
21
|
+
handle_context,
|
|
22
|
+
handle_compact,
|
|
23
|
+
handle_diff,
|
|
24
|
+
)
|
|
25
|
+
from .telegram import handle_telegram
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"handle_agents",
|
|
29
|
+
"handle_session",
|
|
30
|
+
"handle_todos",
|
|
31
|
+
"handle_todo_add",
|
|
32
|
+
"handle_hooks",
|
|
33
|
+
"handle_rules",
|
|
34
|
+
"handle_skills",
|
|
35
|
+
"handle_index",
|
|
36
|
+
"handle_mcp",
|
|
37
|
+
"handle_registry",
|
|
38
|
+
"handle_auth",
|
|
39
|
+
"handle_doctor",
|
|
40
|
+
"handle_verify",
|
|
41
|
+
"handle_verify_loop",
|
|
42
|
+
"handle_setup",
|
|
43
|
+
"handle_status",
|
|
44
|
+
"handle_pr",
|
|
45
|
+
"handle_projectmd",
|
|
46
|
+
"handle_research",
|
|
47
|
+
"handle_context",
|
|
48
|
+
"handle_compact",
|
|
49
|
+
"handle_diff",
|
|
50
|
+
"handle_telegram",
|
|
51
|
+
]
|