claude-dev-cli 0.16.2__py3-none-any.whl → 0.18.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.

Potentially problematic release.


This version of claude-dev-cli might be problematic. Click here for more details.

@@ -0,0 +1,309 @@
1
+ """Markdown-based fallback ticket backend.
2
+
3
+ Simple file-based ticket system when external backends aren't available.
4
+ """
5
+
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Optional, List, Dict, Any
9
+ from datetime import datetime
10
+ import uuid
11
+
12
+ from claude_dev_cli.tickets.backend import TicketBackend, Ticket, Epic, Story
13
+
14
+
15
+ class MarkdownBackend(TicketBackend):
16
+ """Simple markdown-based ticket system.
17
+
18
+ Stores tickets as JSON files in .cdc-tickets/ directory.
19
+ Provides fallback when repo-tickets or other systems aren't available.
20
+ """
21
+
22
+ def __init__(self, base_dir: Optional[Path] = None):
23
+ """Initialize markdown backend.
24
+
25
+ Args:
26
+ base_dir: Base directory for tickets (default: current directory)
27
+ """
28
+ self.base_dir = base_dir or Path.cwd()
29
+ self.tickets_dir = self.base_dir / ".cdc-tickets"
30
+ self.epics_dir = self.tickets_dir / "epics"
31
+ self.stories_dir = self.tickets_dir / "stories"
32
+ self.tasks_dir = self.tickets_dir / "tasks"
33
+
34
+ def connect(self) -> bool:
35
+ """Initialize ticket directories if needed."""
36
+ try:
37
+ self.tickets_dir.mkdir(exist_ok=True)
38
+ self.epics_dir.mkdir(exist_ok=True)
39
+ self.stories_dir.mkdir(exist_ok=True)
40
+ self.tasks_dir.mkdir(exist_ok=True)
41
+ return True
42
+ except OSError:
43
+ return False
44
+
45
+ def fetch_ticket(self, ticket_id: str) -> Optional[Ticket]:
46
+ """Fetch ticket from JSON file."""
47
+ ticket_file = self.tasks_dir / f"{ticket_id}.json"
48
+
49
+ if not ticket_file.exists():
50
+ return None
51
+
52
+ try:
53
+ with open(ticket_file, 'r') as f:
54
+ data = json.load(f)
55
+
56
+ return self._dict_to_ticket(data)
57
+ except (json.JSONDecodeError, OSError):
58
+ return None
59
+
60
+ def create_epic(self, title: str, description: str = "", **kwargs) -> Epic:
61
+ """Create epic as JSON file."""
62
+ epic_id = f"EPIC-{self._generate_id()}"
63
+
64
+ epic = Epic(
65
+ id=epic_id,
66
+ title=title,
67
+ description=description,
68
+ status=kwargs.get("status", "draft"),
69
+ priority=kwargs.get("priority", "medium"),
70
+ owner=kwargs.get("owner"),
71
+ created_at=datetime.now()
72
+ )
73
+
74
+ # Save to file
75
+ epic_file = self.epics_dir / f"{epic_id}.json"
76
+ with open(epic_file, 'w') as f:
77
+ json.dump(self._epic_to_dict(epic), f, indent=2, default=str)
78
+
79
+ return epic
80
+
81
+ def create_story(self, epic_id: str, title: str, description: str = "", **kwargs) -> Story:
82
+ """Create story as JSON file."""
83
+ story_id = f"STORY-{self._generate_id()}"
84
+
85
+ story = Story(
86
+ id=story_id,
87
+ title=title,
88
+ description=description,
89
+ epic_id=epic_id,
90
+ status=kwargs.get("status", "draft"),
91
+ priority=kwargs.get("priority", "medium"),
92
+ story_points=kwargs.get("story_points")
93
+ )
94
+
95
+ # Save to file
96
+ story_file = self.stories_dir / f"{story_id}.json"
97
+ with open(story_file, 'w') as f:
98
+ json.dump(self._story_to_dict(story), f, indent=2, default=str)
99
+
100
+ # Link to epic
101
+ epic_file = self.epics_dir / f"{epic_id}.json"
102
+ if epic_file.exists():
103
+ with open(epic_file, 'r') as f:
104
+ epic_data = json.load(f)
105
+
106
+ if story_id not in epic_data.get('ticket_ids', []):
107
+ epic_data.setdefault('ticket_ids', []).append(story_id)
108
+
109
+ with open(epic_file, 'w') as f:
110
+ json.dump(epic_data, f, indent=2, default=str)
111
+
112
+ return story
113
+
114
+ def create_task(self, story_id: Optional[str], title: str, description: str = "", **kwargs) -> Ticket:
115
+ """Create task as JSON file."""
116
+ task_id = f"TASK-{self._generate_id()}"
117
+
118
+ ticket = Ticket(
119
+ id=task_id,
120
+ title=title,
121
+ description=description,
122
+ status=kwargs.get("status", "open"),
123
+ priority=kwargs.get("priority", "medium"),
124
+ ticket_type=kwargs.get("ticket_type", "feature"),
125
+ assignee=kwargs.get("assignee"),
126
+ labels=kwargs.get("labels", []),
127
+ story_id=story_id,
128
+ created_at=datetime.now()
129
+ )
130
+
131
+ # Save to file
132
+ task_file = self.tasks_dir / f"{task_id}.json"
133
+ with open(task_file, 'w') as f:
134
+ json.dump(self._ticket_to_dict(ticket), f, indent=2, default=str)
135
+
136
+ return ticket
137
+
138
+ def update_ticket(self, ticket_id: str, **kwargs) -> Ticket:
139
+ """Update ticket fields."""
140
+ ticket = self.fetch_ticket(ticket_id)
141
+ if not ticket:
142
+ raise ValueError(f"Ticket {ticket_id} not found")
143
+
144
+ # Update fields
145
+ for key, value in kwargs.items():
146
+ if hasattr(ticket, key):
147
+ setattr(ticket, key, value)
148
+
149
+ ticket.updated_at = datetime.now()
150
+
151
+ # Save updated ticket
152
+ task_file = self.tasks_dir / f"{ticket_id}.json"
153
+ with open(task_file, 'w') as f:
154
+ json.dump(self._ticket_to_dict(ticket), f, indent=2, default=str)
155
+
156
+ return ticket
157
+
158
+ def list_tickets(self, status: Optional[str] = None, epic_id: Optional[str] = None,
159
+ **filters) -> List[Ticket]:
160
+ """List all tickets with filters."""
161
+ tickets = []
162
+
163
+ for ticket_file in self.tasks_dir.glob("*.json"):
164
+ try:
165
+ with open(ticket_file, 'r') as f:
166
+ data = json.load(f)
167
+
168
+ ticket = self._dict_to_ticket(data)
169
+ if not ticket:
170
+ continue
171
+
172
+ # Apply filters
173
+ if status and ticket.status != status:
174
+ continue
175
+
176
+ if epic_id and ticket.epic_id != epic_id:
177
+ continue
178
+
179
+ tickets.append(ticket)
180
+
181
+ except (json.JSONDecodeError, OSError):
182
+ continue
183
+
184
+ return tickets
185
+
186
+ def add_comment(self, ticket_id: str, comment: str, author: str = "") -> bool:
187
+ """Add comment to ticket metadata."""
188
+ ticket = self.fetch_ticket(ticket_id)
189
+ if not ticket:
190
+ return False
191
+
192
+ if not ticket.metadata:
193
+ ticket.metadata = {}
194
+
195
+ if 'comments' not in ticket.metadata:
196
+ ticket.metadata['comments'] = []
197
+
198
+ ticket.metadata['comments'].append({
199
+ 'author': author or 'unknown',
200
+ 'text': comment,
201
+ 'timestamp': datetime.now().isoformat()
202
+ })
203
+
204
+ # Save updated ticket
205
+ task_file = self.tasks_dir / f"{ticket_id}.json"
206
+ with open(task_file, 'w') as f:
207
+ json.dump(self._ticket_to_dict(ticket), f, indent=2, default=str)
208
+
209
+ return True
210
+
211
+ def attach_file(self, ticket_id: str, file_path: str) -> bool:
212
+ """Attach file reference to ticket."""
213
+ ticket = self.fetch_ticket(ticket_id)
214
+ if not ticket:
215
+ return False
216
+
217
+ if file_path not in ticket.files:
218
+ ticket.files.append(file_path)
219
+
220
+ # Save updated ticket
221
+ task_file = self.tasks_dir / f"{ticket_id}.json"
222
+ with open(task_file, 'w') as f:
223
+ json.dump(self._ticket_to_dict(ticket), f, indent=2, default=str)
224
+
225
+ return True
226
+
227
+ def _generate_id(self) -> str:
228
+ """Generate unique ID."""
229
+ return str(uuid.uuid4())[:8].upper()
230
+
231
+ def _ticket_to_dict(self, ticket: Ticket) -> Dict[str, Any]:
232
+ """Convert Ticket to dict for JSON serialization."""
233
+ return {
234
+ 'id': ticket.id,
235
+ 'title': ticket.title,
236
+ 'description': ticket.description,
237
+ 'status': ticket.status,
238
+ 'priority': ticket.priority,
239
+ 'ticket_type': ticket.ticket_type,
240
+ 'assignee': ticket.assignee,
241
+ 'labels': ticket.labels,
242
+ 'epic_id': ticket.epic_id,
243
+ 'story_id': ticket.story_id,
244
+ 'parent_id': ticket.parent_id,
245
+ 'requirements': ticket.requirements,
246
+ 'acceptance_criteria': ticket.acceptance_criteria,
247
+ 'user_stories': ticket.user_stories,
248
+ 'files': ticket.files,
249
+ 'metadata': ticket.metadata,
250
+ 'created_at': ticket.created_at.isoformat() if ticket.created_at else None,
251
+ 'updated_at': ticket.updated_at.isoformat() if ticket.updated_at else None
252
+ }
253
+
254
+ def _dict_to_ticket(self, data: Dict[str, Any]) -> Optional[Ticket]:
255
+ """Convert dict to Ticket object."""
256
+ try:
257
+ return Ticket(
258
+ id=data['id'],
259
+ title=data['title'],
260
+ description=data.get('description', ''),
261
+ status=data.get('status', 'open'),
262
+ priority=data.get('priority', 'medium'),
263
+ ticket_type=data.get('ticket_type', 'feature'),
264
+ assignee=data.get('assignee'),
265
+ labels=data.get('labels', []),
266
+ epic_id=data.get('epic_id'),
267
+ story_id=data.get('story_id'),
268
+ parent_id=data.get('parent_id'),
269
+ requirements=data.get('requirements', []),
270
+ acceptance_criteria=data.get('acceptance_criteria', []),
271
+ user_stories=data.get('user_stories', []),
272
+ files=data.get('files', []),
273
+ metadata=data.get('metadata', {}),
274
+ created_at=datetime.fromisoformat(data['created_at']) if data.get('created_at') else None,
275
+ updated_at=datetime.fromisoformat(data['updated_at']) if data.get('updated_at') else None
276
+ )
277
+ except (KeyError, ValueError):
278
+ return None
279
+
280
+ def _epic_to_dict(self, epic: Epic) -> Dict[str, Any]:
281
+ """Convert Epic to dict."""
282
+ return {
283
+ 'id': epic.id,
284
+ 'title': epic.title,
285
+ 'description': epic.description,
286
+ 'status': epic.status,
287
+ 'priority': epic.priority,
288
+ 'owner': epic.owner,
289
+ 'ticket_ids': epic.ticket_ids,
290
+ 'goals': epic.goals,
291
+ 'created_at': epic.created_at.isoformat() if epic.created_at else None
292
+ }
293
+
294
+ def _story_to_dict(self, story: Story) -> Dict[str, Any]:
295
+ """Convert Story to dict."""
296
+ return {
297
+ 'id': story.id,
298
+ 'title': story.title,
299
+ 'description': story.description,
300
+ 'epic_id': story.epic_id,
301
+ 'status': story.status,
302
+ 'priority': story.priority,
303
+ 'story_points': story.story_points,
304
+ 'acceptance_criteria': story.acceptance_criteria
305
+ }
306
+
307
+ def get_backend_name(self) -> str:
308
+ """Return backend name."""
309
+ return "markdown"
@@ -0,0 +1,361 @@
1
+ """repo-tickets backend implementation for claude-dev-cli.
2
+
3
+ Integrates with repo-tickets CLI to manage tickets in VCS repositories.
4
+ """
5
+
6
+ import subprocess
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Optional, List, Dict, Any
10
+ from datetime import datetime
11
+
12
+ from claude_dev_cli.tickets.backend import TicketBackend, Ticket, Epic, Story
13
+
14
+
15
+ class RepoTicketsBackend(TicketBackend):
16
+ """Backend for repo-tickets integration.
17
+
18
+ Uses subprocess to call 'tickets' CLI commands.
19
+ Assumes repo-tickets is installed and initialized in current repository.
20
+ """
21
+
22
+ def __init__(self, repo_path: Optional[Path] = None):
23
+ """Initialize repo-tickets backend.
24
+
25
+ Args:
26
+ repo_path: Path to repository (default: current directory)
27
+ """
28
+ self.repo_path = repo_path or Path.cwd()
29
+ self._tickets_dir = self.repo_path / ".tickets"
30
+
31
+ def connect(self) -> bool:
32
+ """Verify repo-tickets is initialized and accessible."""
33
+ try:
34
+ # Check if .tickets directory exists
35
+ if not self._tickets_dir.exists():
36
+ return False
37
+
38
+ # Try to list tickets (will fail if not properly initialized)
39
+ result = subprocess.run(
40
+ ["tickets", "list", "--format", "json"],
41
+ cwd=self.repo_path,
42
+ capture_output=True,
43
+ text=True,
44
+ timeout=5
45
+ )
46
+ return result.returncode == 0
47
+ except (subprocess.TimeoutExpired, FileNotFoundError):
48
+ return False
49
+
50
+ def fetch_ticket(self, ticket_id: str) -> Optional[Ticket]:
51
+ """Fetch a ticket from repo-tickets."""
52
+ try:
53
+ result = subprocess.run(
54
+ ["tickets", "show", ticket_id, "--format", "json"],
55
+ cwd=self.repo_path,
56
+ capture_output=True,
57
+ text=True,
58
+ timeout=10
59
+ )
60
+
61
+ if result.returncode != 0:
62
+ return None
63
+
64
+ data = json.loads(result.stdout)
65
+ return self._convert_to_ticket(data)
66
+
67
+ except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
68
+ return None
69
+
70
+ def create_epic(self, title: str, description: str = "", **kwargs) -> Epic:
71
+ """Create an epic in repo-tickets."""
72
+ cmd = ["tickets", "epic", "create", title]
73
+
74
+ if description:
75
+ cmd.extend(["--description", description])
76
+
77
+ if "priority" in kwargs:
78
+ cmd.extend(["--priority", kwargs["priority"]])
79
+
80
+ if "owner" in kwargs:
81
+ cmd.extend(["--owner", kwargs["owner"]])
82
+
83
+ result = subprocess.run(
84
+ cmd,
85
+ cwd=self.repo_path,
86
+ capture_output=True,
87
+ text=True,
88
+ timeout=10
89
+ )
90
+
91
+ if result.returncode != 0:
92
+ raise RuntimeError(f"Failed to create epic: {result.stderr}")
93
+
94
+ # Extract epic ID from output
95
+ epic_id = self._extract_id_from_output(result.stdout)
96
+
97
+ return Epic(
98
+ id=epic_id,
99
+ title=title,
100
+ description=description,
101
+ status=kwargs.get("status", "draft"),
102
+ priority=kwargs.get("priority", "medium"),
103
+ owner=kwargs.get("owner"),
104
+ created_at=datetime.now()
105
+ )
106
+
107
+ def create_story(self, epic_id: str, title: str, description: str = "", **kwargs) -> Story:
108
+ """Create a user story within an epic."""
109
+ # repo-tickets uses backlog items for stories
110
+ cmd = ["tickets", "backlog", "add", title]
111
+
112
+ if description:
113
+ cmd.extend(["--description", description])
114
+
115
+ if "priority" in kwargs:
116
+ cmd.extend(["--priority", kwargs["priority"]])
117
+
118
+ if "story_points" in kwargs:
119
+ cmd.extend(["--effort", str(kwargs["story_points"])])
120
+
121
+ result = subprocess.run(
122
+ cmd,
123
+ cwd=self.repo_path,
124
+ capture_output=True,
125
+ text=True,
126
+ timeout=10
127
+ )
128
+
129
+ if result.returncode != 0:
130
+ raise RuntimeError(f"Failed to create story: {result.stderr}")
131
+
132
+ story_id = self._extract_id_from_output(result.stdout)
133
+
134
+ # Link story to epic
135
+ subprocess.run(
136
+ ["tickets", "epic", "add-ticket", epic_id, story_id],
137
+ cwd=self.repo_path,
138
+ timeout=10
139
+ )
140
+
141
+ return Story(
142
+ id=story_id,
143
+ title=title,
144
+ description=description,
145
+ epic_id=epic_id,
146
+ status=kwargs.get("status", "draft"),
147
+ priority=kwargs.get("priority", "medium"),
148
+ story_points=kwargs.get("story_points")
149
+ )
150
+
151
+ def create_task(self, story_id: Optional[str], title: str, description: str = "", **kwargs) -> Ticket:
152
+ """Create a task ticket."""
153
+ cmd = ["tickets", "create", title]
154
+
155
+ if description:
156
+ cmd.extend(["--description", description])
157
+
158
+ if "priority" in kwargs:
159
+ cmd.extend(["--priority", kwargs["priority"]])
160
+
161
+ if "assignee" in kwargs:
162
+ cmd.extend(["--assignee", kwargs["assignee"]])
163
+
164
+ if "labels" in kwargs and kwargs["labels"]:
165
+ for label in kwargs["labels"]:
166
+ cmd.extend(["--label", label])
167
+
168
+ result = subprocess.run(
169
+ cmd,
170
+ cwd=self.repo_path,
171
+ capture_output=True,
172
+ text=True,
173
+ timeout=10
174
+ )
175
+
176
+ if result.returncode != 0:
177
+ raise RuntimeError(f"Failed to create task: {result.stderr}")
178
+
179
+ task_id = self._extract_id_from_output(result.stdout)
180
+
181
+ # Link to story if provided
182
+ if story_id:
183
+ # In repo-tickets, we add the task to the epic that contains the story
184
+ # This is a simplification - could be enhanced
185
+ pass
186
+
187
+ return Ticket(
188
+ id=task_id,
189
+ title=title,
190
+ description=description,
191
+ status=kwargs.get("status", "open"),
192
+ priority=kwargs.get("priority", "medium"),
193
+ ticket_type=kwargs.get("ticket_type", "feature"),
194
+ assignee=kwargs.get("assignee"),
195
+ labels=kwargs.get("labels", []),
196
+ story_id=story_id,
197
+ created_at=datetime.now()
198
+ )
199
+
200
+ def update_ticket(self, ticket_id: str, **kwargs) -> Ticket:
201
+ """Update a ticket's fields."""
202
+ cmd = ["tickets", "update", ticket_id]
203
+
204
+ if "status" in kwargs:
205
+ cmd.extend(["--status", kwargs["status"]])
206
+
207
+ if "priority" in kwargs:
208
+ cmd.extend(["--priority", kwargs["priority"]])
209
+
210
+ if "assignee" in kwargs:
211
+ cmd.extend(["--assignee", kwargs["assignee"]])
212
+
213
+ if "description" in kwargs:
214
+ cmd.extend(["--description", kwargs["description"]])
215
+
216
+ result = subprocess.run(
217
+ cmd,
218
+ cwd=self.repo_path,
219
+ capture_output=True,
220
+ text=True,
221
+ timeout=10
222
+ )
223
+
224
+ if result.returncode != 0:
225
+ raise RuntimeError(f"Failed to update ticket: {result.stderr}")
226
+
227
+ # Fetch updated ticket
228
+ updated = self.fetch_ticket(ticket_id)
229
+ if not updated:
230
+ raise RuntimeError(f"Ticket {ticket_id} not found after update")
231
+
232
+ return updated
233
+
234
+ def list_tickets(self, status: Optional[str] = None, epic_id: Optional[str] = None,
235
+ **filters) -> List[Ticket]:
236
+ """List tickets with filters."""
237
+ cmd = ["tickets", "list", "--format", "json"]
238
+
239
+ if status:
240
+ cmd.extend(["--status", status])
241
+
242
+ if epic_id:
243
+ cmd.extend(["--epic", epic_id])
244
+
245
+ result = subprocess.run(
246
+ cmd,
247
+ cwd=self.repo_path,
248
+ capture_output=True,
249
+ text=True,
250
+ timeout=15
251
+ )
252
+
253
+ if result.returncode != 0:
254
+ return []
255
+
256
+ try:
257
+ data = json.loads(result.stdout)
258
+ tickets = []
259
+
260
+ if isinstance(data, list):
261
+ for item in data:
262
+ ticket = self._convert_to_ticket(item)
263
+ if ticket:
264
+ tickets.append(ticket)
265
+
266
+ return tickets
267
+ except json.JSONDecodeError:
268
+ return []
269
+
270
+ def add_comment(self, ticket_id: str, comment: str, author: str = "") -> bool:
271
+ """Add a comment to a ticket."""
272
+ try:
273
+ cmd = ["tickets", "comment", ticket_id, comment]
274
+
275
+ if author:
276
+ cmd.extend(["--author", author])
277
+
278
+ result = subprocess.run(
279
+ cmd,
280
+ cwd=self.repo_path,
281
+ capture_output=True,
282
+ text=True,
283
+ timeout=10
284
+ )
285
+
286
+ return result.returncode == 0
287
+ except subprocess.TimeoutExpired:
288
+ return False
289
+
290
+ def attach_file(self, ticket_id: str, file_path: str) -> bool:
291
+ """Attach a file reference to a ticket.
292
+
293
+ repo-tickets doesn't support file attachments directly,
294
+ so we add a comment with the file path.
295
+ """
296
+ comment = f"📎 File attached: {file_path}"
297
+ return self.add_comment(ticket_id, comment, "claude-dev-cli")
298
+
299
+ def _extract_id_from_output(self, output: str) -> str:
300
+ """Extract ticket/epic/story ID from command output.
301
+
302
+ Looks for patterns like "TICKET-123" or "EPIC-1"
303
+ """
304
+ import re
305
+
306
+ # Common patterns in repo-tickets
307
+ patterns = [
308
+ r'(TICKET-\d+)',
309
+ r'(EPIC-\d+)',
310
+ r'(BACKLOG-\d+)',
311
+ r'(STORY-\d+)',
312
+ r'(TASK-\d+)'
313
+ ]
314
+
315
+ for pattern in patterns:
316
+ match = re.search(pattern, output)
317
+ if match:
318
+ return match.group(1)
319
+
320
+ raise ValueError(f"Could not extract ID from output: {output}")
321
+
322
+ def _convert_to_ticket(self, data: Dict[str, Any]) -> Optional[Ticket]:
323
+ """Convert repo-tickets JSON to unified Ticket object."""
324
+ try:
325
+ return Ticket(
326
+ id=data.get("id", ""),
327
+ title=data.get("title", ""),
328
+ description=data.get("description", ""),
329
+ status=data.get("status", "open"),
330
+ priority=data.get("priority", "medium"),
331
+ ticket_type=data.get("type", "feature"),
332
+ assignee=data.get("assignee"),
333
+ labels=data.get("labels", []),
334
+ epic_id=data.get("epic_id"),
335
+ story_id=data.get("story_id"),
336
+ requirements=data.get("requirements", []),
337
+ acceptance_criteria=data.get("acceptance_criteria", []),
338
+ files=data.get("files", []),
339
+ metadata=data.get("metadata", {}),
340
+ created_at=self._parse_datetime(data.get("created_at")),
341
+ updated_at=self._parse_datetime(data.get("updated_at"))
342
+ )
343
+ except (KeyError, ValueError):
344
+ return None
345
+
346
+ def _parse_datetime(self, value: Any) -> Optional[datetime]:
347
+ """Parse datetime from various formats."""
348
+ if not value:
349
+ return None
350
+
351
+ if isinstance(value, datetime):
352
+ return value
353
+
354
+ try:
355
+ return datetime.fromisoformat(str(value))
356
+ except (ValueError, AttributeError):
357
+ return None
358
+
359
+ def get_backend_name(self) -> str:
360
+ """Return backend name."""
361
+ return "repo-tickets"
@@ -0,0 +1,6 @@
1
+ """Version control system integration for claude-dev-cli."""
2
+
3
+ from claude_dev_cli.vcs.manager import VCSManager
4
+ from claude_dev_cli.vcs.git import GitManager
5
+
6
+ __all__ = ["VCSManager", "GitManager"]