emdash-cli 0.1.17__py3-none-any.whl → 0.1.30__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.
@@ -0,0 +1,337 @@
1
+ """Skills management CLI commands."""
2
+
3
+ import click
4
+ from pathlib import Path
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+
9
+ console = Console()
10
+
11
+
12
+ def _get_skills_dir() -> Path:
13
+ """Get the skills directory."""
14
+ return Path.cwd() / ".emdash" / "skills"
15
+
16
+
17
+ @click.group()
18
+ def skills():
19
+ """Manage agent skills."""
20
+ pass
21
+
22
+
23
+ @skills.command("list")
24
+ def skills_list():
25
+ """List all available skills."""
26
+ from emdash_core.agent.skills import SkillRegistry
27
+
28
+ skills_dir = _get_skills_dir()
29
+ registry = SkillRegistry.get_instance()
30
+ registry.load_skills(skills_dir)
31
+
32
+ all_skills = registry.get_all_skills()
33
+
34
+ if not all_skills:
35
+ console.print("[yellow]No skills found.[/yellow]")
36
+ console.print(f"[dim]Create skills in {skills_dir}/<skill-name>/SKILL.md[/dim]")
37
+ return
38
+
39
+ table = Table(title="Available Skills")
40
+ table.add_column("Name", style="cyan")
41
+ table.add_column("Description")
42
+ table.add_column("User Invocable", style="green")
43
+ table.add_column("Tools")
44
+
45
+ for skill in all_skills.values():
46
+ invocable = "Yes (/{})".format(skill.name) if skill.user_invocable else "No"
47
+ tools = ", ".join(skill.tools) if skill.tools else "-"
48
+ table.add_row(skill.name, skill.description, invocable, tools)
49
+
50
+ console.print(table)
51
+
52
+
53
+ @skills.command("show")
54
+ @click.argument("name")
55
+ def skills_show(name: str):
56
+ """Show details of a specific skill."""
57
+ from emdash_core.agent.skills import SkillRegistry
58
+
59
+ skills_dir = _get_skills_dir()
60
+ registry = SkillRegistry.get_instance()
61
+ registry.load_skills(skills_dir)
62
+
63
+ skill = registry.get_skill(name)
64
+
65
+ if skill is None:
66
+ console.print(f"[red]Skill '{name}' not found.[/red]")
67
+ available = registry.list_skills()
68
+ if available:
69
+ console.print(f"[dim]Available skills: {', '.join(available)}[/dim]")
70
+ return
71
+
72
+ # Show skill details
73
+ console.print(Panel(
74
+ f"[bold]Description:[/bold] {skill.description}\n\n"
75
+ f"[bold]User Invocable:[/bold] {'Yes (/' + skill.name + ')' if skill.user_invocable else 'No'}\n\n"
76
+ f"[bold]Tools:[/bold] {', '.join(skill.tools) if skill.tools else 'None'}\n\n"
77
+ f"[bold]File:[/bold] {skill.file_path}",
78
+ title=f"[cyan]{skill.name}[/cyan]",
79
+ border_style="cyan",
80
+ ))
81
+
82
+ console.print()
83
+ console.print("[bold]Instructions:[/bold]")
84
+ console.print(Panel(
85
+ skill.instructions,
86
+ border_style="dim",
87
+ ))
88
+
89
+
90
+ @skills.command("create")
91
+ @click.argument("name")
92
+ @click.option("--description", "-d", default="", help="Skill description")
93
+ @click.option("--user-invocable/--no-user-invocable", default=True, help="Can be invoked with /name")
94
+ @click.option("--tools", "-t", multiple=True, help="Tools this skill needs (can specify multiple)")
95
+ def skills_create(name: str, description: str, user_invocable: bool, tools: tuple):
96
+ """Create a new skill.
97
+
98
+ Creates a skill directory with SKILL.md template.
99
+
100
+ Example:
101
+ emdash skills create commit -d "Generate commit messages" -t execute_command -t read_file
102
+ """
103
+ # Validate name
104
+ name = name.lower().strip()
105
+ if len(name) > 64:
106
+ console.print("[red]Skill name must be 64 characters or less.[/red]")
107
+ return
108
+
109
+ if not name.replace("-", "").replace("_", "").isalnum():
110
+ console.print("[red]Skill name must contain only lowercase letters, numbers, hyphens, and underscores.[/red]")
111
+ return
112
+
113
+ skills_dir = _get_skills_dir()
114
+ skill_dir = skills_dir / name
115
+ skill_file = skill_dir / "SKILL.md"
116
+
117
+ if skill_dir.exists():
118
+ console.print(f"[red]Skill '{name}' already exists at {skill_dir}[/red]")
119
+ return
120
+
121
+ # Build content
122
+ tools_str = ", ".join(tools) if tools else ""
123
+ description = description or f"Description for {name} skill"
124
+
125
+ content = f"""---
126
+ name: {name}
127
+ description: {description}
128
+ user_invocable: {str(user_invocable).lower()}
129
+ tools: [{tools_str}]
130
+ ---
131
+
132
+ # {name.replace('-', ' ').title()}
133
+
134
+ {description}
135
+
136
+ ## Instructions
137
+
138
+ Add your skill instructions here. These will be provided to the agent when the skill is invoked.
139
+
140
+ ## Usage
141
+
142
+ Describe how this skill should be used.
143
+
144
+ ## Examples
145
+
146
+ Provide example scenarios here.
147
+ """
148
+
149
+ try:
150
+ skill_dir.mkdir(parents=True, exist_ok=True)
151
+ skill_file.write_text(content)
152
+ console.print(f"[green]Created skill '{name}' at {skill_file}[/green]")
153
+ console.print(f"[dim]Edit the SKILL.md file to customize the skill instructions.[/dim]")
154
+ except Exception as e:
155
+ console.print(f"[red]Error creating skill: {e}[/red]")
156
+
157
+
158
+ @skills.command("delete")
159
+ @click.argument("name")
160
+ @click.option("--force", "-f", is_flag=True, help="Delete without confirmation")
161
+ def skills_delete(name: str, force: bool):
162
+ """Delete a skill."""
163
+ skills_dir = _get_skills_dir()
164
+ skill_dir = skills_dir / name
165
+
166
+ if not skill_dir.exists():
167
+ console.print(f"[red]Skill '{name}' not found.[/red]")
168
+ return
169
+
170
+ if not force:
171
+ if not click.confirm(f"Are you sure you want to delete skill '{name}'?"):
172
+ console.print("[yellow]Cancelled.[/yellow]")
173
+ return
174
+
175
+ import shutil
176
+ shutil.rmtree(skill_dir)
177
+ console.print(f"[green]Deleted skill '{name}'.[/green]")
178
+
179
+
180
+ @skills.command("init")
181
+ def skills_init():
182
+ """Initialize skills directory with example skills."""
183
+ skills_dir = _get_skills_dir()
184
+
185
+ if skills_dir.exists() and list(skills_dir.iterdir()):
186
+ console.print(f"[yellow]Skills directory already exists at {skills_dir}[/yellow]")
187
+ if not click.confirm("Do you want to add example skills anyway?"):
188
+ return
189
+
190
+ skills_dir.mkdir(parents=True, exist_ok=True)
191
+
192
+ # Create example commit skill
193
+ commit_dir = skills_dir / "commit"
194
+ if not commit_dir.exists():
195
+ commit_dir.mkdir(parents=True, exist_ok=True)
196
+ (commit_dir / "SKILL.md").write_text("""---
197
+ name: commit
198
+ description: Generate commit messages following conventional commits format
199
+ user_invocable: true
200
+ tools: [execute_command, read_file]
201
+ ---
202
+
203
+ # Commit Message Generation
204
+
205
+ Generate clear, conventional commit messages based on staged changes.
206
+
207
+ ## Instructions
208
+
209
+ 1. Run `git diff --cached` to see staged changes
210
+ 2. Analyze the changes to understand what was modified
211
+ 3. Generate a commit message following conventional commits format:
212
+ - feat: A new feature
213
+ - fix: A bug fix
214
+ - docs: Documentation only changes
215
+ - style: Changes that don't affect meaning (formatting, etc)
216
+ - refactor: Code change that neither fixes a bug nor adds a feature
217
+ - test: Adding or modifying tests
218
+ - chore: Changes to build process or auxiliary tools
219
+
220
+ 4. Format: `<type>(<scope>): <description>`
221
+
222
+ ## Examples
223
+
224
+ - `feat(auth): add OAuth2 support`
225
+ - `fix(api): handle null response in user endpoint`
226
+ - `docs(readme): update installation instructions`
227
+ """)
228
+ console.print("[green]Created example skill: commit[/green]")
229
+
230
+ # Create example review-pr skill
231
+ review_dir = skills_dir / "review-pr"
232
+ if not review_dir.exists():
233
+ review_dir.mkdir(parents=True, exist_ok=True)
234
+ (review_dir / "SKILL.md").write_text("""---
235
+ name: review-pr
236
+ description: Review pull requests with code quality and security focus
237
+ user_invocable: true
238
+ tools: [read_file, semantic_search, grep]
239
+ ---
240
+
241
+ # Pull Request Review
242
+
243
+ Conduct thorough code reviews focusing on quality, security, and best practices.
244
+
245
+ ## Instructions
246
+
247
+ 1. Review the PR changes systematically
248
+ 2. Check for:
249
+ - Code quality and readability
250
+ - Security vulnerabilities (injection, XSS, etc.)
251
+ - Performance implications
252
+ - Test coverage
253
+ - Documentation updates
254
+ 3. Provide constructive feedback with specific suggestions
255
+ 4. Highlight both issues and good practices
256
+
257
+ ## Review Checklist
258
+
259
+ - [ ] Code follows project conventions
260
+ - [ ] No obvious security vulnerabilities
261
+ - [ ] Error handling is appropriate
262
+ - [ ] Tests cover new functionality
263
+ - [ ] No unnecessary complexity
264
+ - [ ] Documentation is updated if needed
265
+
266
+ ## Output Format
267
+
268
+ Provide feedback in sections:
269
+ 1. **Summary**: Overall assessment
270
+ 2. **Positives**: What's done well
271
+ 3. **Concerns**: Issues that should be addressed
272
+ 4. **Suggestions**: Optional improvements
273
+ """)
274
+ console.print("[green]Created example skill: review-pr[/green]")
275
+
276
+ # Create example security-review skill
277
+ security_dir = skills_dir / "security-review"
278
+ if not security_dir.exists():
279
+ security_dir.mkdir(parents=True, exist_ok=True)
280
+ (security_dir / "SKILL.md").write_text("""---
281
+ name: security-review
282
+ description: Security-focused code review for vulnerabilities
283
+ user_invocable: true
284
+ tools: [read_file, grep, semantic_search]
285
+ ---
286
+
287
+ # Security Review
288
+
289
+ Conduct security-focused code review to identify vulnerabilities.
290
+
291
+ ## Instructions
292
+
293
+ 1. Search for common vulnerability patterns:
294
+ - SQL injection
295
+ - XSS (Cross-Site Scripting)
296
+ - Command injection
297
+ - Path traversal
298
+ - Insecure deserialization
299
+ - Hardcoded secrets
300
+ - Improper input validation
301
+
302
+ 2. Review authentication and authorization:
303
+ - Session management
304
+ - Password handling
305
+ - Access control
306
+
307
+ 3. Check data handling:
308
+ - Sensitive data exposure
309
+ - Encryption usage
310
+ - Data validation
311
+
312
+ ## OWASP Top 10 Checklist
313
+
314
+ - [ ] A01: Broken Access Control
315
+ - [ ] A02: Cryptographic Failures
316
+ - [ ] A03: Injection
317
+ - [ ] A04: Insecure Design
318
+ - [ ] A05: Security Misconfiguration
319
+ - [ ] A06: Vulnerable Components
320
+ - [ ] A07: Authentication Failures
321
+ - [ ] A08: Software Integrity Failures
322
+ - [ ] A09: Logging Failures
323
+ - [ ] A10: SSRF
324
+
325
+ ## Output
326
+
327
+ Report findings with:
328
+ - Severity (Critical/High/Medium/Low)
329
+ - Location (file:line)
330
+ - Description
331
+ - Remediation suggestion
332
+ """)
333
+ console.print("[green]Created example skill: security-review[/green]")
334
+
335
+ console.print()
336
+ console.print(f"[cyan]Skills directory initialized at {skills_dir}[/cyan]")
337
+ console.print("[dim]Use 'emdash skills list' to see available skills.[/dim]")
emdash_cli/keyboard.py ADDED
@@ -0,0 +1,146 @@
1
+ """Non-blocking keyboard input detection for ESC interruption."""
2
+
3
+ import sys
4
+ import threading
5
+ import time
6
+ from typing import Callable, Optional
7
+
8
+
9
+ # Unix implementation
10
+ if sys.platform != 'win32':
11
+ import select
12
+ import tty
13
+ import termios
14
+
15
+ def check_key_pressed() -> Optional[str]:
16
+ """Check if a key was pressed (non-blocking).
17
+
18
+ Returns:
19
+ The key character if pressed, None otherwise
20
+ """
21
+ if select.select([sys.stdin], [], [], 0)[0]:
22
+ return sys.stdin.read(1)
23
+ return None
24
+
25
+ class KeyListener:
26
+ """Background thread that listens for ESC key press.
27
+
28
+ Usage:
29
+ interrupt_event = threading.Event()
30
+ listener = KeyListener(lambda: interrupt_event.set())
31
+ listener.start()
32
+ # ... do work, checking interrupt_event.is_set() ...
33
+ listener.stop()
34
+ """
35
+
36
+ def __init__(self, on_escape: Callable[[], None]):
37
+ """Initialize the key listener.
38
+
39
+ Args:
40
+ on_escape: Callback to invoke when ESC is pressed
41
+ """
42
+ self.on_escape = on_escape
43
+ self._running = False
44
+ self._thread: Optional[threading.Thread] = None
45
+ self._old_settings = None
46
+
47
+ def start(self) -> None:
48
+ """Start listening for keys in background thread."""
49
+ if self._running:
50
+ return
51
+
52
+ self._running = True
53
+
54
+ try:
55
+ # Save terminal settings
56
+ self._old_settings = termios.tcgetattr(sys.stdin)
57
+ # Set terminal to cbreak mode (no buffering, no echo)
58
+ tty.setcbreak(sys.stdin.fileno())
59
+ except termios.error:
60
+ # Not a TTY (e.g., piped input)
61
+ self._running = False
62
+ return
63
+
64
+ # Start listener thread
65
+ self._thread = threading.Thread(target=self._listen, daemon=True)
66
+ self._thread.start()
67
+
68
+ def stop(self) -> None:
69
+ """Stop listening and restore terminal settings."""
70
+ self._running = False
71
+
72
+ # Restore terminal settings
73
+ if self._old_settings:
74
+ try:
75
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, self._old_settings)
76
+ except termios.error:
77
+ pass # Terminal may have been closed
78
+ self._old_settings = None
79
+
80
+ # Wait for thread to finish
81
+ if self._thread:
82
+ self._thread.join(timeout=0.2)
83
+ self._thread = None
84
+
85
+ def _listen(self) -> None:
86
+ """Background listener loop."""
87
+ while self._running:
88
+ try:
89
+ key = check_key_pressed()
90
+ if key == '\x1b': # ESC key
91
+ self.on_escape()
92
+ break
93
+ except Exception:
94
+ # stdin may be closed or unavailable
95
+ break
96
+
97
+ # Small sleep to avoid busy-waiting
98
+ time.sleep(0.05)
99
+
100
+ # Windows implementation
101
+ else:
102
+ import msvcrt
103
+
104
+ class KeyListener:
105
+ """Background thread that listens for ESC key press (Windows)."""
106
+
107
+ def __init__(self, on_escape: Callable[[], None]):
108
+ """Initialize the key listener.
109
+
110
+ Args:
111
+ on_escape: Callback to invoke when ESC is pressed
112
+ """
113
+ self.on_escape = on_escape
114
+ self._running = False
115
+ self._thread: Optional[threading.Thread] = None
116
+
117
+ def start(self) -> None:
118
+ """Start listening for keys in background thread."""
119
+ if self._running:
120
+ return
121
+
122
+ self._running = True
123
+ self._thread = threading.Thread(target=self._listen, daemon=True)
124
+ self._thread.start()
125
+
126
+ def stop(self) -> None:
127
+ """Stop listening."""
128
+ self._running = False
129
+ if self._thread:
130
+ self._thread.join(timeout=0.2)
131
+ self._thread = None
132
+
133
+ def _listen(self) -> None:
134
+ """Background listener loop."""
135
+ while self._running:
136
+ try:
137
+ if msvcrt.kbhit():
138
+ key = msvcrt.getch()
139
+ if key == b'\x1b': # ESC
140
+ self.on_escape()
141
+ break
142
+ except Exception:
143
+ break
144
+
145
+ # Small sleep to avoid busy-waiting
146
+ time.sleep(0.05)
emdash_cli/main.py CHANGED
@@ -1,5 +1,7 @@
1
1
  """Main CLI entry point for emdash-cli."""
2
2
 
3
+ import os
4
+
3
5
  import click
4
6
 
5
7
  from .commands import (
@@ -13,6 +15,7 @@ from .commands import (
13
15
  rules,
14
16
  search,
15
17
  server,
18
+ skills,
16
19
  team,
17
20
  swarm,
18
21
  projectmd,
@@ -42,6 +45,7 @@ cli.add_command(index)
42
45
  cli.add_command(plan)
43
46
  cli.add_command(rules)
44
47
  cli.add_command(server)
48
+ cli.add_command(skills)
45
49
  cli.add_command(team)
46
50
  cli.add_command(swarm)
47
51
 
@@ -64,7 +68,7 @@ cli.add_command(server_killall, name="killall")
64
68
  @click.option("--mode", type=click.Choice(["plan", "tasks", "code"]), default="code",
65
69
  help="Starting mode")
66
70
  @click.option("--quiet", "-q", is_flag=True, help="Less verbose output")
67
- @click.option("--max-iterations", default=20, help="Max agent iterations")
71
+ @click.option("--max-iterations", default=int(os.getenv("EMDASH_MAX_ITERATIONS", "100")), help="Max agent iterations")
68
72
  @click.option("--no-graph-tools", is_flag=True, help="Skip graph exploration tools")
69
73
  @click.option("--save", is_flag=True, help="Save specs to specs/<feature>/")
70
74
  def start_coding_agent(