steerdev 0.4.27__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 (57) hide show
  1. steerdev-0.4.27.dist-info/METADATA +224 -0
  2. steerdev-0.4.27.dist-info/RECORD +57 -0
  3. steerdev-0.4.27.dist-info/WHEEL +4 -0
  4. steerdev-0.4.27.dist-info/entry_points.txt +2 -0
  5. steerdev_agent/__init__.py +10 -0
  6. steerdev_agent/api/__init__.py +32 -0
  7. steerdev_agent/api/activity.py +278 -0
  8. steerdev_agent/api/agents.py +145 -0
  9. steerdev_agent/api/client.py +158 -0
  10. steerdev_agent/api/commands.py +399 -0
  11. steerdev_agent/api/configs.py +238 -0
  12. steerdev_agent/api/context.py +306 -0
  13. steerdev_agent/api/events.py +294 -0
  14. steerdev_agent/api/hooks.py +178 -0
  15. steerdev_agent/api/implementation_plan.py +408 -0
  16. steerdev_agent/api/messages.py +231 -0
  17. steerdev_agent/api/prd.py +281 -0
  18. steerdev_agent/api/runs.py +526 -0
  19. steerdev_agent/api/sessions.py +403 -0
  20. steerdev_agent/api/specs.py +321 -0
  21. steerdev_agent/api/tasks.py +659 -0
  22. steerdev_agent/api/workflow_runs.py +351 -0
  23. steerdev_agent/api/workflows.py +191 -0
  24. steerdev_agent/cli.py +2254 -0
  25. steerdev_agent/config/__init__.py +19 -0
  26. steerdev_agent/config/models.py +236 -0
  27. steerdev_agent/config/platform.py +272 -0
  28. steerdev_agent/config/settings.py +62 -0
  29. steerdev_agent/daemon.py +675 -0
  30. steerdev_agent/executor/__init__.py +64 -0
  31. steerdev_agent/executor/base.py +121 -0
  32. steerdev_agent/executor/claude.py +328 -0
  33. steerdev_agent/executor/stream.py +163 -0
  34. steerdev_agent/git/__init__.py +1 -0
  35. steerdev_agent/handlers/__init__.py +5 -0
  36. steerdev_agent/handlers/prd.py +533 -0
  37. steerdev_agent/integration.py +334 -0
  38. steerdev_agent/prompt/__init__.py +10 -0
  39. steerdev_agent/prompt/builder.py +263 -0
  40. steerdev_agent/prompt/templates.py +422 -0
  41. steerdev_agent/py.typed +0 -0
  42. steerdev_agent/runner.py +829 -0
  43. steerdev_agent/setup/__init__.py +5 -0
  44. steerdev_agent/setup/claude_setup.py +560 -0
  45. steerdev_agent/setup/templates/claude_md_section.md +140 -0
  46. steerdev_agent/setup/templates/settings.json +69 -0
  47. steerdev_agent/setup/templates/skills/activity/SKILL.md +160 -0
  48. steerdev_agent/setup/templates/skills/context/SKILL.md +122 -0
  49. steerdev_agent/setup/templates/skills/git-workflow/SKILL.md +218 -0
  50. steerdev_agent/setup/templates/skills/progress-logging/SKILL.md +211 -0
  51. steerdev_agent/setup/templates/skills/specs-management/SKILL.md +161 -0
  52. steerdev_agent/setup/templates/skills/task-management/SKILL.md +343 -0
  53. steerdev_agent/setup/templates/steerdev.yaml +51 -0
  54. steerdev_agent/version.py +149 -0
  55. steerdev_agent/workflow/__init__.py +10 -0
  56. steerdev_agent/workflow/executor.py +494 -0
  57. steerdev_agent/workflow/memory.py +185 -0
steerdev_agent/cli.py ADDED
@@ -0,0 +1,2254 @@
1
+ """CLI interface for steerdev."""
2
+
3
+ import asyncio
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import httpx
9
+ import typer
10
+ from dotenv import load_dotenv
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+
14
+ from steerdev_agent.config.models import SteerDevConfig
15
+ from steerdev_agent.version import get_version
16
+
17
+ console = Console()
18
+
19
+ # Load environment variables from .env file in current working directory
20
+ load_dotenv(Path.cwd() / ".env")
21
+
22
+ app = typer.Typer(
23
+ name="steerdev",
24
+ help="Backend task runner for steerdev.com - orchestrates CLI coding agents",
25
+ no_args_is_help=True,
26
+ )
27
+
28
+ # ============================================================================
29
+ # Tasks Command Group
30
+ # ============================================================================
31
+ tasks_app = typer.Typer(
32
+ name="tasks",
33
+ help="Task management commands for steerdev.com",
34
+ no_args_is_help=True,
35
+ )
36
+ app.add_typer(tasks_app)
37
+
38
+
39
+ @tasks_app.command("next")
40
+ def tasks_next(
41
+ project_id: Annotated[
42
+ str | None,
43
+ typer.Option("--project-id", "-p", help="Filter by project ID (UUID)"),
44
+ ] = None,
45
+ waves: Annotated[
46
+ bool,
47
+ typer.Option(
48
+ "--waves/--no-waves", help="Enable wave-aware task scheduling (default: enabled)"
49
+ ),
50
+ ] = True,
51
+ ) -> None:
52
+ """Get the next task to work on.
53
+
54
+ By default, returns the next task with full wave context (if waves exist).
55
+ Use --no-waves to get a single task without wave context.
56
+
57
+ Returns the highest priority task in order:
58
+ 1. In Progress tasks (to continue work)
59
+ 2. Todo tasks (to start new work)
60
+ 3. Backlog tasks (if nothing else available)
61
+ """
62
+ from steerdev_agent.api.tasks import TasksClient, display_task, display_wave_context
63
+
64
+ with TasksClient() as client:
65
+ if not client.check_api_key():
66
+ raise typer.Exit(1)
67
+
68
+ try:
69
+ # Try wave-aware fetch first (if enabled)
70
+ if waves:
71
+ wave_response = client.get_next_wave(project_id=project_id)
72
+ if wave_response is not None:
73
+ display_wave_context(wave_response)
74
+ return
75
+
76
+ # Fall back to single-task
77
+ task = client.get_next_task(project_id=project_id)
78
+ if task is None:
79
+ console.print("[yellow]No tasks available[/yellow]")
80
+ raise typer.Exit(0)
81
+
82
+ display_task(task, title="Next Task")
83
+
84
+ except httpx.TimeoutException:
85
+ console.print("[red]Error: Request timed out[/red]")
86
+ raise typer.Exit(1) from None
87
+ except httpx.HTTPError as e:
88
+ console.print(f"[red]HTTP Error: {e}[/red]")
89
+ raise typer.Exit(1) from None
90
+
91
+
92
+ @tasks_app.command("list")
93
+ def tasks_list(
94
+ status: Annotated[
95
+ str | None,
96
+ typer.Option(
97
+ "--status",
98
+ "-s",
99
+ help="Filter by status (backlog, unstarted, started, completed, canceled)",
100
+ ),
101
+ ] = None,
102
+ project_id: Annotated[
103
+ str | None,
104
+ typer.Option("--project-id", "-p", help="Filter by project ID (UUID)"),
105
+ ] = None,
106
+ limit: Annotated[
107
+ int,
108
+ typer.Option("--limit", "-l", help="Maximum number of tasks to return"),
109
+ ] = 20,
110
+ compact: Annotated[
111
+ bool,
112
+ typer.Option("--compact", "-c", help="Show truncated IDs (8 chars) instead of full UUIDs"),
113
+ ] = False,
114
+ ) -> None:
115
+ """List tasks with optional filters."""
116
+ from steerdev_agent.api.tasks import TasksClient, display_task_list
117
+
118
+ with TasksClient() as client:
119
+ if not client.check_api_key():
120
+ raise typer.Exit(1)
121
+
122
+ try:
123
+ tasks = client.list_tasks(status=status, project_id=project_id, limit=limit)
124
+ display_task_list(tasks, full_ids=not compact)
125
+
126
+ except httpx.TimeoutException:
127
+ console.print("[red]Error: Request timed out[/red]")
128
+ raise typer.Exit(1) from None
129
+ except httpx.HTTPError as e:
130
+ console.print(f"[red]HTTP Error: {e}[/red]")
131
+ raise typer.Exit(1) from None
132
+
133
+
134
+ @tasks_app.command("get")
135
+ def tasks_get(
136
+ task_id: Annotated[
137
+ str,
138
+ typer.Argument(help="Task ID (UUID) to fetch"),
139
+ ],
140
+ ) -> None:
141
+ """Get details of a specific task by ID."""
142
+ from steerdev_agent.api.tasks import TasksClient, display_task
143
+
144
+ with TasksClient() as client:
145
+ if not client.check_api_key():
146
+ raise typer.Exit(1)
147
+
148
+ try:
149
+ task = client.get_task(task_id)
150
+ if task:
151
+ display_task(task, title="Task Details")
152
+ else:
153
+ console.print(f"[red]Task not found: {task_id}[/red]")
154
+ raise typer.Exit(1)
155
+
156
+ except httpx.TimeoutException:
157
+ console.print("[red]Error: Request timed out[/red]")
158
+ raise typer.Exit(1) from None
159
+ except httpx.HTTPError as e:
160
+ console.print(f"[red]HTTP Error: {e}[/red]")
161
+ raise typer.Exit(1) from None
162
+
163
+
164
+ @tasks_app.command("update")
165
+ def tasks_update(
166
+ task_id: Annotated[
167
+ str,
168
+ typer.Argument(help="Task ID (UUID) to update"),
169
+ ],
170
+ status: Annotated[
171
+ str | None,
172
+ typer.Option(
173
+ "--status",
174
+ "-s",
175
+ help="New status (backlog, unstarted, started, completed, canceled)",
176
+ ),
177
+ ] = None,
178
+ title: Annotated[
179
+ str | None,
180
+ typer.Option("--title", "-t", help="Update task title"),
181
+ ] = None,
182
+ prompt: Annotated[
183
+ str | None,
184
+ typer.Option("--prompt", help="Update task prompt"),
185
+ ] = None,
186
+ priority: Annotated[
187
+ int | None,
188
+ typer.Option("--priority", "-P", help="Update task priority (0-3)"),
189
+ ] = None,
190
+ result_summary: Annotated[
191
+ str | None,
192
+ typer.Option("--result", "-r", help="Result summary (for completed tasks)"),
193
+ ] = None,
194
+ error_message: Annotated[
195
+ str | None,
196
+ typer.Option("--error", "-e", help="Error message (for failed tasks)"),
197
+ ] = None,
198
+ comment: Annotated[
199
+ str | None,
200
+ typer.Option("--comment", "-c", help="Comment text (supports markdown)"),
201
+ ] = None,
202
+ ) -> None:
203
+ """Update a task's status, title, or other fields."""
204
+ from steerdev_agent.api.tasks import (
205
+ VALID_STATUSES,
206
+ TasksClient,
207
+ display_update_success,
208
+ )
209
+
210
+ # Validate status if provided
211
+ if status and status not in VALID_STATUSES:
212
+ console.print(
213
+ f"[red]Error: Invalid status '{status}'. Must be one of: {', '.join(VALID_STATUSES)}[/red]"
214
+ )
215
+ raise typer.Exit(1)
216
+
217
+ # Validate priority if provided
218
+ if priority is not None and (priority < 0 or priority > 3):
219
+ console.print("[red]Error: Priority must be between 0 and 3[/red]")
220
+ raise typer.Exit(1)
221
+
222
+ # Check that at least one update is specified
223
+ if not any(
224
+ [status, title, prompt, priority is not None, result_summary, error_message, comment]
225
+ ):
226
+ console.print(
227
+ "[yellow]No updates specified. Use --status, --title, --prompt, --priority, "
228
+ "--result, --error, or --comment[/yellow]"
229
+ )
230
+ raise typer.Exit(1)
231
+
232
+ with TasksClient() as client:
233
+ if not client.check_api_key():
234
+ raise typer.Exit(1)
235
+
236
+ try:
237
+ success = client.update_task(
238
+ task_id=task_id,
239
+ status=status,
240
+ title=title,
241
+ prompt=prompt,
242
+ priority=priority,
243
+ result_summary=result_summary,
244
+ error_message=error_message,
245
+ comment=comment,
246
+ )
247
+
248
+ if success:
249
+ display_update_success(
250
+ task_id=task_id,
251
+ status=status,
252
+ title=title,
253
+ prompt=prompt,
254
+ priority=priority,
255
+ result_summary=result_summary,
256
+ error_message=error_message,
257
+ comment=comment,
258
+ )
259
+ else:
260
+ raise typer.Exit(1)
261
+
262
+ except httpx.TimeoutException:
263
+ console.print("[red]Error: Request timed out[/red]")
264
+ raise typer.Exit(1) from None
265
+ except httpx.HTTPError as e:
266
+ console.print(f"[red]HTTP Error: {e}[/red]")
267
+ raise typer.Exit(1) from None
268
+
269
+
270
+ @tasks_app.command("create")
271
+ def tasks_create(
272
+ title: Annotated[
273
+ str,
274
+ typer.Option("--title", "-t", help="Task title (required)"),
275
+ ],
276
+ prompt: Annotated[
277
+ str,
278
+ typer.Option("--prompt", help="Task prompt/description (required)"),
279
+ ],
280
+ project_id: Annotated[
281
+ str | None,
282
+ typer.Option("--project-id", "-p", help="Project ID (UUID)"),
283
+ ] = None,
284
+ priority: Annotated[
285
+ int,
286
+ typer.Option("--priority", help="Task priority (0=low, 1=medium, 2=high, 3=urgent)"),
287
+ ] = 1,
288
+ working_directory: Annotated[
289
+ str | None,
290
+ typer.Option("--workdir", "-w", help="Working directory for the task"),
291
+ ] = None,
292
+ spec_id: Annotated[
293
+ str | None,
294
+ typer.Option("--spec-id", "-s", help="Specification ID to link this task to"),
295
+ ] = None,
296
+ cycle_id: Annotated[
297
+ str | None,
298
+ typer.Option("--cycle-id", "-c", help="Cycle ID to link this task to"),
299
+ ] = None,
300
+ ) -> None:
301
+ """Create a new task."""
302
+ from steerdev_agent.api.tasks import TasksClient, display_task
303
+
304
+ # Validate priority
305
+ if priority < 0 or priority > 3:
306
+ console.print("[red]Error: Priority must be between 0 and 3[/red]")
307
+ raise typer.Exit(1)
308
+
309
+ with TasksClient() as client:
310
+ if not client.check_api_key():
311
+ raise typer.Exit(1)
312
+
313
+ try:
314
+ task = client.create_task(
315
+ title=title,
316
+ prompt=prompt,
317
+ project_id=project_id,
318
+ priority=priority,
319
+ working_directory=working_directory,
320
+ spec_id=spec_id,
321
+ cycle_id=cycle_id,
322
+ )
323
+
324
+ if task:
325
+ display_task(task, title="Task Created")
326
+ else:
327
+ raise typer.Exit(1)
328
+
329
+ except httpx.TimeoutException:
330
+ console.print("[red]Error: Request timed out[/red]")
331
+ raise typer.Exit(1) from None
332
+ except httpx.HTTPError as e:
333
+ console.print(f"[red]HTTP Error: {e}[/red]")
334
+ raise typer.Exit(1) from None
335
+
336
+
337
+ # ============================================================================
338
+ # Hooks Command Group
339
+ # ============================================================================
340
+ hooks_app = typer.Typer(
341
+ name="hooks",
342
+ help="Claude Code lifecycle hooks for activity reporting",
343
+ no_args_is_help=True,
344
+ )
345
+ app.add_typer(hooks_app)
346
+
347
+
348
+ @hooks_app.command("session-start")
349
+ def hooks_session_start() -> None:
350
+ """Hook called when Claude Code session starts.
351
+
352
+ Reads JSON from stdin with session_id, transcript_path, cwd, etc.
353
+ Reports session_start event to SteerDev API.
354
+ """
355
+ from steerdev_agent.api.hooks import HooksClient
356
+
357
+ client = HooksClient()
358
+ client.session_start()
359
+
360
+
361
+ @hooks_app.command("session-end")
362
+ def hooks_session_end() -> None:
363
+ """Hook called when Claude Code session ends.
364
+
365
+ Reads JSON from stdin with session_id, reason, etc.
366
+ Reports session_end event to SteerDev API.
367
+ """
368
+ from steerdev_agent.api.hooks import HooksClient
369
+
370
+ client = HooksClient()
371
+ client.session_end()
372
+
373
+
374
+ @hooks_app.command("agent-stop")
375
+ def hooks_agent_stop() -> None:
376
+ """Hook called when main Claude Code agent stops.
377
+
378
+ Reads JSON from stdin with session_id, stop_hook_active, etc.
379
+ Reports agent_stopped event to SteerDev API.
380
+ """
381
+ from steerdev_agent.api.hooks import HooksClient
382
+
383
+ client = HooksClient()
384
+ client.agent_stop()
385
+
386
+
387
+ @hooks_app.command("subagent-stop")
388
+ def hooks_subagent_stop() -> None:
389
+ """Hook called when a Claude Code subagent stops.
390
+
391
+ Reads JSON from stdin with session_id, stop_hook_active, etc.
392
+ Reports subagent_stopped event to SteerDev API.
393
+ """
394
+ from steerdev_agent.api.hooks import HooksClient
395
+
396
+ client = HooksClient()
397
+ client.subagent_stop()
398
+
399
+
400
+ # ============================================================================
401
+ # Sessions Command Group
402
+ # ============================================================================
403
+ sessions_app = typer.Typer(
404
+ name="sessions",
405
+ help="Session management commands for tracking agent execution",
406
+ no_args_is_help=True,
407
+ )
408
+ app.add_typer(sessions_app)
409
+
410
+
411
+ @sessions_app.command("list")
412
+ def sessions_list(
413
+ project_id: Annotated[
414
+ str | None,
415
+ typer.Option("--project-id", "-p", help="Filter by project ID (UUID)"),
416
+ ] = None,
417
+ status: Annotated[
418
+ str | None,
419
+ typer.Option(
420
+ "--status",
421
+ "-s",
422
+ help="Filter by status (pending, running, completed, failed, cancelled)",
423
+ ),
424
+ ] = None,
425
+ limit: Annotated[
426
+ int,
427
+ typer.Option("--limit", "-l", help="Maximum number of sessions to return"),
428
+ ] = 20,
429
+ compact: Annotated[
430
+ bool,
431
+ typer.Option("--compact", "-c", help="Show truncated IDs instead of full UUIDs"),
432
+ ] = False,
433
+ ) -> None:
434
+ """List sessions with optional filters."""
435
+ from steerdev_agent.api.sessions import SessionsClient, display_session_list
436
+
437
+ async def _list_sessions() -> None:
438
+ async with SessionsClient() as client:
439
+ result = await client.list_sessions(
440
+ project_id=project_id,
441
+ status=status,
442
+ limit=limit,
443
+ )
444
+ if result:
445
+ display_session_list(result.sessions, full_ids=not compact)
446
+ else:
447
+ console.print("[yellow]Failed to fetch sessions[/yellow]")
448
+ raise typer.Exit(1)
449
+
450
+ try:
451
+ asyncio.run(_list_sessions())
452
+ except httpx.TimeoutException:
453
+ console.print("[red]Error: Request timed out[/red]")
454
+ raise typer.Exit(1) from None
455
+ except httpx.HTTPError as e:
456
+ console.print(f"[red]HTTP Error: {e}[/red]")
457
+ raise typer.Exit(1) from None
458
+
459
+
460
+ @sessions_app.command("get")
461
+ def sessions_get(
462
+ session_id: Annotated[
463
+ str,
464
+ typer.Argument(help="Session ID (UUID) to fetch"),
465
+ ],
466
+ ) -> None:
467
+ """Get details of a specific session by ID."""
468
+ from steerdev_agent.api.sessions import SessionsClient, display_session
469
+
470
+ async def _get_session() -> None:
471
+ async with SessionsClient() as client:
472
+ session = await client.get_session(session_id)
473
+ if session:
474
+ display_session(session, title="Session Details")
475
+ else:
476
+ console.print(f"[red]Session not found: {session_id}[/red]")
477
+ raise typer.Exit(1)
478
+
479
+ try:
480
+ asyncio.run(_get_session())
481
+ except httpx.TimeoutException:
482
+ console.print("[red]Error: Request timed out[/red]")
483
+ raise typer.Exit(1) from None
484
+ except httpx.HTTPError as e:
485
+ console.print(f"[red]HTTP Error: {e}[/red]")
486
+ raise typer.Exit(1) from None
487
+
488
+
489
+ # ============================================================================
490
+ # Runs Command Group (kept for backwards compatibility)
491
+ # ============================================================================
492
+ runs_app = typer.Typer(
493
+ name="runs",
494
+ help="Run management commands for tracking agent execution sessions",
495
+ no_args_is_help=True,
496
+ )
497
+ app.add_typer(runs_app)
498
+
499
+
500
+ @runs_app.command("list")
501
+ def runs_list(
502
+ status: Annotated[
503
+ str | None,
504
+ typer.Option(
505
+ "--status",
506
+ "-s",
507
+ help="Filter by status (pending, running, completed, failed, cancelled)",
508
+ ),
509
+ ] = None,
510
+ limit: Annotated[
511
+ int,
512
+ typer.Option("--limit", "-l", help="Maximum number of runs to return"),
513
+ ] = 20,
514
+ compact: Annotated[
515
+ bool,
516
+ typer.Option("--compact", "-c", help="Show truncated IDs instead of full UUIDs"),
517
+ ] = False,
518
+ ) -> None:
519
+ """List runs with optional filters."""
520
+ from steerdev_agent.api.runs import RunsClient, display_run_list
521
+
522
+ async def _list_runs() -> None:
523
+ async with RunsClient() as client:
524
+ result = await client.list_runs(status=status, limit=limit)
525
+ if result:
526
+ display_run_list(result.runs, full_ids=not compact)
527
+ else:
528
+ console.print("[yellow]Failed to fetch runs[/yellow]")
529
+ raise typer.Exit(1)
530
+
531
+ try:
532
+ asyncio.run(_list_runs())
533
+ except httpx.TimeoutException:
534
+ console.print("[red]Error: Request timed out[/red]")
535
+ raise typer.Exit(1) from None
536
+ except httpx.HTTPError as e:
537
+ console.print(f"[red]HTTP Error: {e}[/red]")
538
+ raise typer.Exit(1) from None
539
+
540
+
541
+ @runs_app.command("get")
542
+ def runs_get(
543
+ run_id: Annotated[
544
+ str,
545
+ typer.Argument(help="Run ID (UUID) to fetch"),
546
+ ],
547
+ ) -> None:
548
+ """Get details of a specific run by ID."""
549
+ from steerdev_agent.api.runs import RunsClient, display_run
550
+
551
+ async def _get_run() -> None:
552
+ async with RunsClient() as client:
553
+ run = await client.get_run(run_id)
554
+ if run:
555
+ display_run(run, title="Run Details")
556
+ else:
557
+ console.print(f"[red]Run not found: {run_id}[/red]")
558
+ raise typer.Exit(1)
559
+
560
+ try:
561
+ asyncio.run(_get_run())
562
+ except httpx.TimeoutException:
563
+ console.print("[red]Error: Request timed out[/red]")
564
+ raise typer.Exit(1) from None
565
+ except httpx.HTTPError as e:
566
+ console.print(f"[red]HTTP Error: {e}[/red]")
567
+ raise typer.Exit(1) from None
568
+
569
+
570
+ @runs_app.command("active")
571
+ def runs_active(
572
+ compact: Annotated[
573
+ bool,
574
+ typer.Option("--compact", "-c", help="Show truncated IDs instead of full UUIDs"),
575
+ ] = False,
576
+ ) -> None:
577
+ """List currently active runs (pending or running)."""
578
+ from steerdev_agent.api.runs import RunsClient, display_run_list
579
+
580
+ async def _get_active_runs() -> None:
581
+ async with RunsClient() as client:
582
+ result = await client.get_active_runs()
583
+ if result:
584
+ if result.runs:
585
+ display_run_list(result.runs, full_ids=not compact)
586
+ console.print(f"\n[dim]Active runs: {result.count}[/dim]")
587
+ else:
588
+ console.print("[yellow]No active runs[/yellow]")
589
+ else:
590
+ console.print("[yellow]Failed to fetch active runs[/yellow]")
591
+ raise typer.Exit(1)
592
+
593
+ try:
594
+ asyncio.run(_get_active_runs())
595
+ except httpx.TimeoutException:
596
+ console.print("[red]Error: Request timed out[/red]")
597
+ raise typer.Exit(1) from None
598
+ except httpx.HTTPError as e:
599
+ console.print(f"[red]HTTP Error: {e}[/red]")
600
+ raise typer.Exit(1) from None
601
+
602
+
603
+ # ============================================================================
604
+ # Specs Command Group
605
+ # ============================================================================
606
+ specs_app = typer.Typer(
607
+ name="specs",
608
+ help="Specification document management commands for steerdev.com",
609
+ no_args_is_help=True,
610
+ )
611
+ app.add_typer(specs_app)
612
+
613
+
614
+ @specs_app.command("list")
615
+ def specs_list(
616
+ project_id: Annotated[
617
+ str | None,
618
+ typer.Option("--project-id", "-p", help="Filter by project ID (UUID)"),
619
+ ] = None,
620
+ status: Annotated[
621
+ str | None,
622
+ typer.Option(
623
+ "--status",
624
+ "-s",
625
+ help="Filter by status (draft, analyzing, clarifying, planning, ready, completed)",
626
+ ),
627
+ ] = None,
628
+ limit: Annotated[
629
+ int,
630
+ typer.Option("--limit", "-l", help="Maximum number of specs to return"),
631
+ ] = 20,
632
+ compact: Annotated[
633
+ bool,
634
+ typer.Option("--compact", "-c", help="Show truncated IDs instead of full UUIDs"),
635
+ ] = False,
636
+ ) -> None:
637
+ """List specification documents with optional filters."""
638
+ from steerdev_agent.api.specs import SpecsClient, display_spec_list
639
+
640
+ with SpecsClient() as client:
641
+ if not client.check_api_key():
642
+ raise typer.Exit(1)
643
+
644
+ try:
645
+ specs = client.list_specs(project_id=project_id, status=status, limit=limit)
646
+ display_spec_list(specs, full_ids=not compact)
647
+
648
+ except httpx.TimeoutException:
649
+ console.print("[red]Error: Request timed out[/red]")
650
+ raise typer.Exit(1) from None
651
+ except httpx.HTTPError as e:
652
+ console.print(f"[red]HTTP Error: {e}[/red]")
653
+ raise typer.Exit(1) from None
654
+
655
+
656
+ @specs_app.command("get")
657
+ def specs_get(
658
+ spec_id: Annotated[
659
+ str,
660
+ typer.Argument(help="Spec ID (UUID) to fetch"),
661
+ ],
662
+ ) -> None:
663
+ """Get details of a specific specification document by ID."""
664
+ from steerdev_agent.api.specs import SpecsClient, display_spec
665
+
666
+ with SpecsClient() as client:
667
+ if not client.check_api_key():
668
+ raise typer.Exit(1)
669
+
670
+ try:
671
+ spec = client.get_spec(spec_id)
672
+ if spec:
673
+ display_spec(spec, title="Spec Details")
674
+ else:
675
+ console.print(f"[red]Spec not found: {spec_id}[/red]")
676
+ raise typer.Exit(1)
677
+
678
+ except httpx.TimeoutException:
679
+ console.print("[red]Error: Request timed out[/red]")
680
+ raise typer.Exit(1) from None
681
+ except httpx.HTTPError as e:
682
+ console.print(f"[red]HTTP Error: {e}[/red]")
683
+ raise typer.Exit(1) from None
684
+
685
+
686
+ @specs_app.command("update")
687
+ def specs_update(
688
+ spec_id: Annotated[
689
+ str,
690
+ typer.Argument(help="Spec ID (UUID) to update"),
691
+ ],
692
+ content: Annotated[
693
+ str | None,
694
+ typer.Option("--content", help="New content (markdown)"),
695
+ ] = None,
696
+ status: Annotated[
697
+ str | None,
698
+ typer.Option(
699
+ "--status",
700
+ "-s",
701
+ help="New status (draft, analyzing, clarifying, planning, ready, completed)",
702
+ ),
703
+ ] = None,
704
+ title: Annotated[
705
+ str | None,
706
+ typer.Option("--title", "-t", help="New title"),
707
+ ] = None,
708
+ ) -> None:
709
+ """Update a specification document's content, status, or title."""
710
+ from steerdev_agent.api.specs import (
711
+ VALID_SPEC_STATUSES,
712
+ SpecsClient,
713
+ display_spec_update_success,
714
+ )
715
+
716
+ # Validate status if provided
717
+ if status and status not in VALID_SPEC_STATUSES:
718
+ console.print(
719
+ f"[red]Error: Invalid status '{status}'. "
720
+ f"Must be one of: {', '.join(VALID_SPEC_STATUSES)}[/red]"
721
+ )
722
+ raise typer.Exit(1)
723
+
724
+ # Check that at least one update is specified
725
+ if not any([content, status, title]):
726
+ console.print("[yellow]No updates specified. Use --content, --status, or --title[/yellow]")
727
+ raise typer.Exit(1)
728
+
729
+ with SpecsClient() as client:
730
+ if not client.check_api_key():
731
+ raise typer.Exit(1)
732
+
733
+ try:
734
+ success = client.update_spec(
735
+ spec_id=spec_id,
736
+ content=content,
737
+ status=status,
738
+ title=title,
739
+ )
740
+
741
+ if success:
742
+ display_spec_update_success(spec_id, content, status, title)
743
+ else:
744
+ raise typer.Exit(1)
745
+
746
+ except httpx.TimeoutException:
747
+ console.print("[red]Error: Request timed out[/red]")
748
+ raise typer.Exit(1) from None
749
+ except httpx.HTTPError as e:
750
+ console.print(f"[red]HTTP Error: {e}[/red]")
751
+ raise typer.Exit(1) from None
752
+
753
+
754
+ @specs_app.command("create")
755
+ def specs_create(
756
+ title: Annotated[
757
+ str,
758
+ typer.Option("--title", "-t", help="Spec title (required)"),
759
+ ],
760
+ content: Annotated[
761
+ str,
762
+ typer.Option("--content", help="Spec content in markdown (required)"),
763
+ ],
764
+ project_id: Annotated[
765
+ str | None,
766
+ typer.Option("--project-id", "-p", help="Project ID (UUID)"),
767
+ ] = None,
768
+ source: Annotated[
769
+ str,
770
+ typer.Option("--source", "-s", help="Source of the spec"),
771
+ ] = "agent",
772
+ ) -> None:
773
+ """Create a new specification document."""
774
+ from steerdev_agent.api.specs import SpecsClient, display_spec
775
+
776
+ with SpecsClient() as client:
777
+ if not client.check_api_key():
778
+ raise typer.Exit(1)
779
+
780
+ try:
781
+ spec = client.create_spec(
782
+ title=title,
783
+ content=content,
784
+ project_id=project_id,
785
+ source=source,
786
+ )
787
+
788
+ if spec:
789
+ display_spec(spec, title="Spec Created")
790
+ else:
791
+ raise typer.Exit(1)
792
+
793
+ except httpx.TimeoutException:
794
+ console.print("[red]Error: Request timed out[/red]")
795
+ raise typer.Exit(1) from None
796
+ except httpx.HTTPError as e:
797
+ console.print(f"[red]HTTP Error: {e}[/red]")
798
+ raise typer.Exit(1) from None
799
+
800
+
801
+ # ============================================================================
802
+ # Context Command Group
803
+ # ============================================================================
804
+ context_app = typer.Typer(
805
+ name="context",
806
+ help="Project context commands for steerdev.com",
807
+ no_args_is_help=True,
808
+ )
809
+ app.add_typer(context_app)
810
+
811
+
812
+ @context_app.command("get")
813
+ def context_get(
814
+ project_id: Annotated[
815
+ str | None,
816
+ typer.Option("--project-id", "-p", help="Project ID (UUID)"),
817
+ ] = None,
818
+ format: Annotated[
819
+ str,
820
+ typer.Option(
821
+ "--format",
822
+ "-f",
823
+ help="Output format: json (structured) or markdown (agent-ready)",
824
+ ),
825
+ ] = "json",
826
+ ) -> None:
827
+ """Get project context (tech stack, patterns, structure).
828
+
829
+ Returns codebase context including:
830
+ - Tech stack (framework, database, auth, styling)
831
+ - Code patterns and conventions
832
+ - Directory structure
833
+ - AI-generated summary
834
+
835
+ Use --format markdown to get agent-ready context.
836
+ """
837
+ from steerdev_agent.api.context import ContextClient, display_context
838
+
839
+ with ContextClient() as client:
840
+ if not client.check_api_key():
841
+ raise typer.Exit(1)
842
+
843
+ try:
844
+ context = client.get_context(project_id=project_id, format=format)
845
+ if context:
846
+ if format == "markdown":
847
+ # Print raw markdown for agent consumption
848
+ console.print(context)
849
+ else:
850
+ display_context(context)
851
+ else:
852
+ raise typer.Exit(1)
853
+
854
+ except httpx.TimeoutException:
855
+ console.print("[red]Error: Request timed out[/red]")
856
+ raise typer.Exit(1) from None
857
+ except httpx.HTTPError as e:
858
+ console.print(f"[red]HTTP Error: {e}[/red]")
859
+ raise typer.Exit(1) from None
860
+
861
+
862
+ @context_app.command("refresh")
863
+ def context_refresh(
864
+ project_id: Annotated[
865
+ str | None,
866
+ typer.Option("--project-id", "-p", help="Project ID (UUID)"),
867
+ ] = None,
868
+ force: Annotated[
869
+ bool,
870
+ typer.Option("--force", "-F", help="Force re-analysis even if context is up to date"),
871
+ ] = False,
872
+ ) -> None:
873
+ """Force refresh of cached project context.
874
+
875
+ Triggers a re-analysis of the project's GitHub repositories
876
+ to update the cached codebase context.
877
+
878
+ This analyzes:
879
+ - package.json/pyproject.toml for dependencies
880
+ - Directory structure for patterns
881
+ - Config files for tech stack detection
882
+ """
883
+ from steerdev_agent.api.context import (
884
+ ContextClient,
885
+ display_context_refresh_success,
886
+ )
887
+
888
+ with ContextClient() as client:
889
+ if not client.check_api_key():
890
+ raise typer.Exit(1)
891
+
892
+ try:
893
+ result = client.refresh_context(project_id=project_id, force=force)
894
+ if result:
895
+ # Show analysis results
896
+ analyzed = result.get("analyzed", [])
897
+ skipped = result.get("skipped", [])
898
+
899
+ if analyzed:
900
+ console.print("[green]Analyzed:[/green]")
901
+ for repo in analyzed:
902
+ status = repo.get("status", "unknown")
903
+ name = repo.get("full_name", repo.get("repo_name", "unknown"))
904
+ if status == "completed":
905
+ console.print(f" [green]✓[/green] {name}")
906
+ else:
907
+ error = repo.get("error", "")
908
+ console.print(f" [red]✗[/red] {name}: {error}")
909
+
910
+ if skipped:
911
+ console.print("[yellow]Skipped (up to date):[/yellow]")
912
+ for repo in skipped:
913
+ name = repo.get("full_name", repo.get("repo_name", "unknown"))
914
+ console.print(f" [dim]○[/dim] {name}")
915
+
916
+ pid = result.get("project_id") or project_id or "unknown"
917
+ display_context_refresh_success(pid)
918
+ else:
919
+ raise typer.Exit(1)
920
+
921
+ except httpx.TimeoutException:
922
+ console.print("[red]Error: Request timed out[/red]")
923
+ raise typer.Exit(1) from None
924
+ except httpx.HTTPError as e:
925
+ console.print(f"[red]HTTP Error: {e}[/red]")
926
+ raise typer.Exit(1) from None
927
+
928
+
929
+ # ============================================================================
930
+ # Activity Command Group
931
+ # ============================================================================
932
+ activity_app = typer.Typer(
933
+ name="activity",
934
+ help="Activity reporting commands for self-reporting progress and querying history",
935
+ no_args_is_help=True,
936
+ )
937
+ app.add_typer(activity_app)
938
+
939
+
940
+ @activity_app.command("report")
941
+ def activity_report(
942
+ event_type: Annotated[
943
+ str,
944
+ typer.Option(
945
+ "--type",
946
+ "-t",
947
+ help="Event type (progress, blocker, question, milestone, error, warning, info)",
948
+ ),
949
+ ],
950
+ message: Annotated[
951
+ str,
952
+ typer.Option("--message", "-m", help="Human-readable message describing the event"),
953
+ ],
954
+ metadata: Annotated[
955
+ str | None,
956
+ typer.Option(
957
+ "--metadata",
958
+ help='Additional metadata as JSON string (e.g., \'{"key": "value"}\')',
959
+ ),
960
+ ] = None,
961
+ run_id: Annotated[
962
+ str | None,
963
+ typer.Option("--run-id", "-r", help="Run ID to associate with"),
964
+ ] = None,
965
+ session_name: Annotated[
966
+ str | None,
967
+ typer.Option("--session", "-s", help="Session name"),
968
+ ] = None,
969
+ ) -> None:
970
+ """Self-report progress, blockers, or other activity events.
971
+
972
+ Use this to report your current status to the platform:
973
+ - progress: Report progress on a task
974
+ - blocker: Report something blocking progress
975
+ - question: Report a question or need for clarification
976
+ - milestone: Report reaching a milestone
977
+ - error: Report an error encountered
978
+ - warning: Report a warning or heads-up
979
+ - info: Report general information
980
+ """
981
+ import json as json_module
982
+
983
+ from steerdev_agent.api.activity import (
984
+ VALID_EVENT_TYPES,
985
+ ActivityClient,
986
+ display_report_success,
987
+ )
988
+
989
+ # Validate event type
990
+ if event_type not in VALID_EVENT_TYPES:
991
+ console.print(
992
+ f"[red]Error: Invalid event type '{event_type}'. "
993
+ f"Must be one of: {', '.join(VALID_EVENT_TYPES)}[/red]"
994
+ )
995
+ raise typer.Exit(1)
996
+
997
+ # Parse metadata if provided
998
+ metadata_dict = None
999
+ if metadata:
1000
+ try:
1001
+ metadata_dict = json_module.loads(metadata)
1002
+ except json_module.JSONDecodeError as e:
1003
+ console.print(f"[red]Error: Invalid JSON in metadata: {e}[/red]")
1004
+ raise typer.Exit(1) from None
1005
+
1006
+ with ActivityClient() as client:
1007
+ if not client.check_api_key():
1008
+ raise typer.Exit(1)
1009
+
1010
+ try:
1011
+ success = client.report(
1012
+ event_type=event_type,
1013
+ message=message,
1014
+ metadata=metadata_dict,
1015
+ run_id=run_id,
1016
+ session_name=session_name,
1017
+ )
1018
+
1019
+ if success:
1020
+ display_report_success(event_type, message)
1021
+ else:
1022
+ raise typer.Exit(1)
1023
+
1024
+ except httpx.TimeoutException:
1025
+ console.print("[red]Error: Request timed out[/red]")
1026
+ raise typer.Exit(1) from None
1027
+ except httpx.HTTPError as e:
1028
+ console.print(f"[red]HTTP Error: {e}[/red]")
1029
+ raise typer.Exit(1) from None
1030
+
1031
+
1032
+ @activity_app.command("query")
1033
+ def activity_query(
1034
+ run_id: Annotated[
1035
+ str | None,
1036
+ typer.Option("--run-id", "-r", help="Filter by run ID"),
1037
+ ] = None,
1038
+ event_type: Annotated[
1039
+ str | None,
1040
+ typer.Option("--type", "-t", help="Filter by event type"),
1041
+ ] = None,
1042
+ limit: Annotated[
1043
+ int,
1044
+ typer.Option("--limit", "-l", help="Maximum number of events to return"),
1045
+ ] = 20,
1046
+ ) -> None:
1047
+ """Query activity history with optional filters.
1048
+
1049
+ Returns recent activity events, optionally filtered by run ID or event type.
1050
+ """
1051
+ from steerdev_agent.api.activity import ActivityClient, display_activity_list
1052
+
1053
+ with ActivityClient() as client:
1054
+ if not client.check_api_key():
1055
+ raise typer.Exit(1)
1056
+
1057
+ try:
1058
+ events = client.query(
1059
+ run_id=run_id,
1060
+ event_type=event_type,
1061
+ limit=limit,
1062
+ )
1063
+ display_activity_list(events)
1064
+
1065
+ except httpx.TimeoutException:
1066
+ console.print("[red]Error: Request timed out[/red]")
1067
+ raise typer.Exit(1) from None
1068
+ except httpx.HTTPError as e:
1069
+ console.print(f"[red]HTTP Error: {e}[/red]")
1070
+ raise typer.Exit(1) from None
1071
+
1072
+
1073
+ # ============================================================================
1074
+ # Git Workflow Command Group
1075
+ # ============================================================================
1076
+ git_app = typer.Typer(
1077
+ name="git",
1078
+ help="Git workflow commands with steerdev conventions",
1079
+ no_args_is_help=True,
1080
+ )
1081
+ app.add_typer(git_app)
1082
+
1083
+
1084
+ @git_app.command("branch")
1085
+ def git_branch(
1086
+ task_id: Annotated[
1087
+ str | None,
1088
+ typer.Argument(help="Task ID to create branch for (uses first 8 chars)"),
1089
+ ] = None,
1090
+ name: Annotated[
1091
+ str | None,
1092
+ typer.Option("--name", "-n", help="Custom branch name suffix"),
1093
+ ] = None,
1094
+ ) -> None:
1095
+ """Create a task branch with steerdev naming convention.
1096
+
1097
+ Creates a branch named `task/<task-id-short>` or `task/<task-id-short>-<name>`.
1098
+ Uses the first 8 characters of the task ID for the branch name.
1099
+
1100
+ Example:
1101
+ steerdev git branch abc12345-...
1102
+ # Creates: task/abc12345
1103
+
1104
+ steerdev git branch abc12345-... --name auth-flow
1105
+ # Creates: task/abc12345-auth-flow
1106
+ """
1107
+ import subprocess
1108
+
1109
+ if not task_id:
1110
+ console.print("[red]Error: Task ID is required[/red]")
1111
+ raise typer.Exit(1)
1112
+
1113
+ # Use first 8 chars of task ID
1114
+ short_id = task_id[:8]
1115
+ branch_name = f"task/{short_id}"
1116
+ if name:
1117
+ branch_name = f"task/{short_id}-{name}"
1118
+
1119
+ try:
1120
+ # Check if branch already exists
1121
+ result = subprocess.run(
1122
+ ["git", "rev-parse", "--verify", branch_name],
1123
+ capture_output=True,
1124
+ text=True,
1125
+ )
1126
+
1127
+ if result.returncode == 0:
1128
+ console.print(f"[yellow]Branch '{branch_name}' already exists[/yellow]")
1129
+ # Switch to the branch
1130
+ subprocess.run(["git", "checkout", branch_name], check=True)
1131
+ console.print(f"[green]Switched to branch '{branch_name}'[/green]")
1132
+ else:
1133
+ # Create and switch to new branch
1134
+ subprocess.run(["git", "checkout", "-b", branch_name], check=True)
1135
+ console.print(
1136
+ Panel(
1137
+ f"[bold green]Branch created[/bold green]\n\n"
1138
+ f"Branch: {branch_name}\n"
1139
+ f"Task ID: {task_id}",
1140
+ title="Success",
1141
+ border_style="green",
1142
+ )
1143
+ )
1144
+
1145
+ except subprocess.CalledProcessError as e:
1146
+ console.print(f"[red]Git error: {e}[/red]")
1147
+ raise typer.Exit(1) from None
1148
+ except FileNotFoundError:
1149
+ console.print("[red]Error: git not found. Make sure git is installed.[/red]")
1150
+ raise typer.Exit(1) from None
1151
+
1152
+
1153
+ @git_app.command("pr")
1154
+ def git_pr(
1155
+ title: Annotated[
1156
+ str,
1157
+ typer.Option("--title", "-t", help="PR title"),
1158
+ ],
1159
+ body: Annotated[
1160
+ str | None,
1161
+ typer.Option("--body", "-b", help="PR body/description"),
1162
+ ] = None,
1163
+ task_id: Annotated[
1164
+ str | None,
1165
+ typer.Option("--task-id", help="Task ID to reference in PR"),
1166
+ ] = None,
1167
+ draft: Annotated[
1168
+ bool,
1169
+ typer.Option("--draft", "-d", help="Create as draft PR"),
1170
+ ] = False,
1171
+ ) -> None:
1172
+ """Create a pull request with steerdev conventions.
1173
+
1174
+ Uses GitHub CLI (gh) to create a PR. Automatically includes
1175
+ task reference if task_id is provided.
1176
+
1177
+ Example:
1178
+ steerdev git pr --title "Add auth" --body "Implements JWT auth"
1179
+ steerdev git pr --title "Add auth" --task-id abc123...
1180
+ """
1181
+ import subprocess
1182
+
1183
+ # Build PR body
1184
+ pr_body = body or ""
1185
+ if task_id:
1186
+ task_ref = f"\n\n## Task Reference\nTask ID: {task_id}"
1187
+ pr_body += task_ref
1188
+
1189
+ # Add steerdev signature
1190
+ pr_body += "\n\n---\nCreated with [steerdev](https://steerdev.com)"
1191
+
1192
+ try:
1193
+ # Build the gh pr create command
1194
+ cmd = ["gh", "pr", "create", "--title", title, "--body", pr_body]
1195
+ if draft:
1196
+ cmd.append("--draft")
1197
+
1198
+ result = subprocess.run(cmd, capture_output=True, text=True)
1199
+
1200
+ if result.returncode != 0:
1201
+ console.print(f"[red]Error creating PR: {result.stderr}[/red]")
1202
+ raise typer.Exit(1)
1203
+
1204
+ # Extract PR URL from output
1205
+ pr_url = result.stdout.strip()
1206
+ console.print(
1207
+ Panel(
1208
+ f"[bold green]Pull request created[/bold green]\n\nTitle: {title}\nURL: {pr_url}",
1209
+ title="Success",
1210
+ border_style="green",
1211
+ )
1212
+ )
1213
+
1214
+ except FileNotFoundError:
1215
+ console.print("[red]Error: gh not found. Install GitHub CLI: https://cli.github.com[/red]")
1216
+ raise typer.Exit(1) from None
1217
+
1218
+
1219
+ @git_app.command("status")
1220
+ def git_status() -> None:
1221
+ """Show current branch and task context.
1222
+
1223
+ Displays the current git branch and extracts task ID if the branch
1224
+ follows the task/<id> naming convention.
1225
+ """
1226
+ import subprocess
1227
+
1228
+ try:
1229
+ # Get current branch
1230
+ result = subprocess.run(
1231
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
1232
+ capture_output=True,
1233
+ text=True,
1234
+ check=True,
1235
+ )
1236
+ current_branch = result.stdout.strip()
1237
+
1238
+ # Get status summary
1239
+ status_result = subprocess.run(
1240
+ ["git", "status", "--short"],
1241
+ capture_output=True,
1242
+ text=True,
1243
+ )
1244
+ status_lines = (
1245
+ status_result.stdout.strip().split("\n") if status_result.stdout.strip() else []
1246
+ )
1247
+ changes_count = len(status_lines)
1248
+
1249
+ # Extract task ID if branch follows convention
1250
+ task_id = None
1251
+ if current_branch.startswith("task/"):
1252
+ # Extract the task ID part (everything after "task/")
1253
+ task_part = current_branch[5:] # Remove "task/" prefix
1254
+ # Task ID is the first part before any hyphen (if name suffix was used)
1255
+ task_id = task_part.split("-")[0] if "-" in task_part else task_part
1256
+
1257
+ # Build info display
1258
+ info_lines = [
1259
+ f"[bold cyan]Branch:[/bold cyan] {current_branch}",
1260
+ ]
1261
+
1262
+ if task_id:
1263
+ info_lines.append(f"[bold cyan]Task ID:[/bold cyan] {task_id}")
1264
+
1265
+ if changes_count > 0:
1266
+ info_lines.append(
1267
+ f"[bold cyan]Uncommitted changes:[/bold cyan] {changes_count} file(s)"
1268
+ )
1269
+ else:
1270
+ info_lines.append("[bold cyan]Uncommitted changes:[/bold cyan] None")
1271
+
1272
+ # Check if branch is pushed
1273
+ tracking_result = subprocess.run(
1274
+ ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
1275
+ capture_output=True,
1276
+ text=True,
1277
+ )
1278
+
1279
+ if tracking_result.returncode == 0:
1280
+ upstream = tracking_result.stdout.strip()
1281
+ info_lines.append(f"[bold cyan]Tracking:[/bold cyan] {upstream}")
1282
+
1283
+ # Check ahead/behind
1284
+ ahead_behind = subprocess.run(
1285
+ ["git", "rev-list", "--left-right", "--count", f"{upstream}...HEAD"],
1286
+ capture_output=True,
1287
+ text=True,
1288
+ )
1289
+ if ahead_behind.returncode == 0:
1290
+ parts = ahead_behind.stdout.strip().split()
1291
+ if len(parts) == 2:
1292
+ behind, ahead = parts
1293
+ if int(ahead) > 0 or int(behind) > 0:
1294
+ info_lines.append(
1295
+ f"[bold cyan]Status:[/bold cyan] {ahead} ahead, {behind} behind"
1296
+ )
1297
+ else:
1298
+ info_lines.append("[bold cyan]Tracking:[/bold cyan] Not pushed to remote")
1299
+
1300
+ console.print(
1301
+ Panel(
1302
+ "\n".join(info_lines),
1303
+ title="Git Status",
1304
+ border_style="blue",
1305
+ )
1306
+ )
1307
+
1308
+ except subprocess.CalledProcessError as e:
1309
+ console.print(f"[red]Git error: {e}[/red]")
1310
+ raise typer.Exit(1) from None
1311
+ except FileNotFoundError:
1312
+ console.print("[red]Error: git not found. Make sure git is installed.[/red]")
1313
+ raise typer.Exit(1) from None
1314
+
1315
+
1316
+ def version_callback(value: bool) -> None:
1317
+ """Print version and exit."""
1318
+ if value:
1319
+ console.print(f"steerdev version {get_version()}")
1320
+ raise typer.Exit()
1321
+
1322
+
1323
+ @app.callback()
1324
+ def main(
1325
+ ctx: typer.Context,
1326
+ version: Annotated[
1327
+ bool,
1328
+ typer.Option(
1329
+ "--version",
1330
+ "-v",
1331
+ help="Show version and exit",
1332
+ callback=version_callback,
1333
+ is_eager=True,
1334
+ ),
1335
+ ] = False,
1336
+ config_file: Annotated[
1337
+ Path | None,
1338
+ typer.Option(
1339
+ "--config",
1340
+ "-C",
1341
+ help="Path to config file (default: steerdev.yaml)",
1342
+ ),
1343
+ ] = None,
1344
+ ) -> None:
1345
+ """SteerDev Agent - orchestrates CLI coding agents with activity reporting."""
1346
+ # Load config file
1347
+ config_path = config_file or Path.cwd() / "steerdev.yaml"
1348
+ if config_path.exists():
1349
+ try:
1350
+ ctx.obj = SteerDevConfig.from_yaml(config_path)
1351
+ except Exception as e:
1352
+ console.print(f"[yellow]Warning: Failed to load config {config_path}: {e}[/yellow]")
1353
+ ctx.obj = SteerDevConfig()
1354
+ else:
1355
+ ctx.obj = SteerDevConfig()
1356
+
1357
+
1358
+ @app.command()
1359
+ def run(
1360
+ ctx: typer.Context,
1361
+ project_id: Annotated[
1362
+ str | None,
1363
+ typer.Option(
1364
+ "--project-id",
1365
+ "-p",
1366
+ help="SteerDev project ID",
1367
+ envvar="STEERDEV_PROJECT_ID",
1368
+ ),
1369
+ ] = None,
1370
+ task_id: Annotated[
1371
+ str | None,
1372
+ typer.Option(
1373
+ "--task-id",
1374
+ "-t",
1375
+ help="Specific task ID to run (optional, fetches next task if not provided)",
1376
+ ),
1377
+ ] = None,
1378
+ working_dir: Annotated[
1379
+ Path | None,
1380
+ typer.Option(
1381
+ "--workdir",
1382
+ "-w",
1383
+ help="Working directory for the agent (defaults to current directory)",
1384
+ exists=True,
1385
+ file_okay=False,
1386
+ dir_okay=True,
1387
+ ),
1388
+ ] = None,
1389
+ agent_name: Annotated[
1390
+ str | None,
1391
+ typer.Option(
1392
+ "--agent-name",
1393
+ "-n",
1394
+ help="Agent name for session tracking (reads from STEERDEV_AGENT_NAME env var)",
1395
+ envvar="STEERDEV_AGENT_NAME",
1396
+ ),
1397
+ ] = None,
1398
+ model: Annotated[
1399
+ str | None,
1400
+ typer.Option(
1401
+ "--model",
1402
+ "-m",
1403
+ help="Model to use (e.g., claude-sonnet-4-20250514)",
1404
+ ),
1405
+ ] = None,
1406
+ max_turns: Annotated[
1407
+ int | None,
1408
+ typer.Option(
1409
+ "--max-turns",
1410
+ help="Maximum number of agent turns per task",
1411
+ ),
1412
+ ] = None,
1413
+ max_tasks: Annotated[
1414
+ int,
1415
+ typer.Option(
1416
+ "--max-tasks",
1417
+ help="Maximum number of tasks to process (default: 1, use 0 for unlimited)",
1418
+ ),
1419
+ ] = 1,
1420
+ timeout: Annotated[
1421
+ int | None,
1422
+ typer.Option(
1423
+ "--timeout",
1424
+ help="Timeout in seconds (default: from config or 3600)",
1425
+ ),
1426
+ ] = None,
1427
+ api_key: Annotated[
1428
+ str | None,
1429
+ typer.Option(
1430
+ "--key",
1431
+ "-k",
1432
+ help="API key for steerdev.com (overrides STEERDEV_API_KEY env var)",
1433
+ envvar="STEERDEV_API_KEY",
1434
+ ),
1435
+ ] = None,
1436
+ worktrees: Annotated[
1437
+ bool | None,
1438
+ typer.Option(
1439
+ "--worktrees/--no-worktrees",
1440
+ "-W",
1441
+ help="Enable git worktree isolation (default: from config or disabled)",
1442
+ ),
1443
+ ] = None,
1444
+ dry_run: Annotated[
1445
+ bool,
1446
+ typer.Option(
1447
+ "--dry",
1448
+ help="Print the command that would be run without executing it",
1449
+ ),
1450
+ ] = False,
1451
+ workflow_id: Annotated[
1452
+ str | None,
1453
+ typer.Option(
1454
+ "--workflow-id",
1455
+ help="Workflow ID for multi-phase execution (overrides config)",
1456
+ ),
1457
+ ] = None,
1458
+ ) -> None:
1459
+ """Run the agent for a project!
1460
+
1461
+ Fetches the next available task (or a specific task if --task-id is provided)
1462
+ and executes it using Claude Code.
1463
+
1464
+ Configuration values are loaded from steerdev.yaml (if present) and can
1465
+ be overridden by CLI options. Priority: CLI > env var > config file > default.
1466
+
1467
+ Example:
1468
+ steerdev run --project-id abc123
1469
+ steerdev run --project-id abc123 --task-id def456
1470
+ steerdev run --project-id abc123 --worktrees
1471
+ steerdev run --project-id abc123 --workflow-id wf-abc123
1472
+ steerdev run --project-id abc123 --agent-name my-dev-agent
1473
+ steerdev run --config custom.yaml --project-id abc123
1474
+ """
1475
+ from steerdev_agent.runner import run_agent
1476
+
1477
+ # Get config from context (loaded in app callback)
1478
+ config: SteerDevConfig = ctx.obj or SteerDevConfig()
1479
+
1480
+ # Resolve project_id: CLI > env (handled by typer) > config env var
1481
+ resolved_project_id = project_id
1482
+ if not resolved_project_id:
1483
+ # Try config's specified env var
1484
+ resolved_project_id = os.environ.get(config.api.project_id_env)
1485
+ if not resolved_project_id:
1486
+ console.print("[red]Error: --project-id required (or set via env/config)[/red]")
1487
+ raise typer.Exit(1)
1488
+
1489
+ # Resolve other options: CLI > config > hardcoded default
1490
+ resolved_model = model if model is not None else config.agent.model
1491
+ resolved_max_turns = max_turns if max_turns is not None else config.agent.max_turns
1492
+ resolved_timeout = timeout if timeout is not None else config.agent.timeout_seconds
1493
+ resolved_workflow_id = workflow_id if workflow_id is not None else config.agent.workflow_id
1494
+ resolved_worktrees = worktrees if worktrees is not None else config.worktrees.enabled
1495
+
1496
+ # API key: CLI > env (via envvar) > config env var
1497
+ resolved_api_key = api_key
1498
+ if not resolved_api_key:
1499
+ resolved_api_key = os.environ.get(config.api.api_key_env)
1500
+
1501
+ worktree_status = "enabled" if resolved_worktrees else "disabled"
1502
+ dry_run_status = "enabled" if dry_run else "disabled"
1503
+ max_tasks_display = "unlimited" if max_tasks == 0 else str(max_tasks)
1504
+ workflow_status = resolved_workflow_id or "single-phase"
1505
+ agent_name_display = agent_name or "(auto from env)"
1506
+
1507
+ console.print(
1508
+ Panel(
1509
+ f"[bold blue]SteerDev Agent[/bold blue]\n"
1510
+ f"Project ID: {resolved_project_id}\n"
1511
+ f"Agent Name: {agent_name_display}\n"
1512
+ f"Task ID: {task_id or 'auto (next available)'}\n"
1513
+ f"Working Directory: {working_dir or 'current'}\n"
1514
+ f"Model: {resolved_model or 'default'}\n"
1515
+ f"Max Tasks: {max_tasks_display}\n"
1516
+ f"Timeout: {resolved_timeout}s\n"
1517
+ f"Workflow: {workflow_status}\n"
1518
+ f"Worktrees: {worktree_status}\n"
1519
+ f"Dry Run: {dry_run_status}",
1520
+ title="Starting",
1521
+ )
1522
+ )
1523
+
1524
+ # Check if shared settings are stale
1525
+ _check_sync_staleness(working_dir or Path.cwd())
1526
+
1527
+ try:
1528
+ result = asyncio.run(
1529
+ run_agent(
1530
+ project_id=resolved_project_id,
1531
+ task_id=task_id,
1532
+ working_directory=str(working_dir) if working_dir else None,
1533
+ api_key=resolved_api_key,
1534
+ agent_name=agent_name,
1535
+ model=resolved_model,
1536
+ max_turns=resolved_max_turns,
1537
+ max_tasks=max_tasks,
1538
+ timeout_seconds=resolved_timeout,
1539
+ enable_worktrees=resolved_worktrees,
1540
+ workflow_id=resolved_workflow_id,
1541
+ dry_run=dry_run,
1542
+ )
1543
+ )
1544
+
1545
+ console.print(
1546
+ Panel(
1547
+ f"[bold green]Run completed[/bold green]\n"
1548
+ f"Run ID: {result['run_id']}\n"
1549
+ f"Duration: {result.get('duration_seconds', 0):.1f}s\n"
1550
+ f"Tasks Executed: {result.get('tasks_executed', 0)}\n"
1551
+ f"Succeeded: {result.get('tasks_succeeded', 0)}\n"
1552
+ f"Failed: {result.get('tasks_failed', 0)}\n"
1553
+ f"Events Sent: {result.get('events_sent', 0)}",
1554
+ title="Complete",
1555
+ )
1556
+ )
1557
+
1558
+ except KeyboardInterrupt:
1559
+ console.print("\n[yellow]Interrupted by user[/yellow]")
1560
+ raise typer.Exit(130) from None
1561
+ except Exception as e:
1562
+ console.print(f"\n[red]Error: {e}[/red]")
1563
+ raise typer.Exit(1) from e
1564
+
1565
+
1566
+ @app.command()
1567
+ def daemon(
1568
+ ctx: typer.Context,
1569
+ project_id: Annotated[
1570
+ str | None,
1571
+ typer.Option(
1572
+ "--project-id",
1573
+ "-p",
1574
+ help="SteerDev project ID",
1575
+ envvar="STEERDEV_PROJECT_ID",
1576
+ ),
1577
+ ] = None,
1578
+ agent_name: Annotated[
1579
+ str | None,
1580
+ typer.Option(
1581
+ "--agent-name",
1582
+ "-n",
1583
+ help="Agent name (required, reads from STEERDEV_AGENT_NAME env var)",
1584
+ envvar="STEERDEV_AGENT_NAME",
1585
+ ),
1586
+ ] = None,
1587
+ working_dir: Annotated[
1588
+ Path | None,
1589
+ typer.Option(
1590
+ "--workdir",
1591
+ "-w",
1592
+ help="Working directory for the agent (defaults to current directory)",
1593
+ exists=True,
1594
+ file_okay=False,
1595
+ dir_okay=True,
1596
+ ),
1597
+ ] = None,
1598
+ model: Annotated[
1599
+ str | None,
1600
+ typer.Option(
1601
+ "--model",
1602
+ "-m",
1603
+ help="Model to use (e.g., claude-sonnet-4-20250514)",
1604
+ ),
1605
+ ] = None,
1606
+ max_turns: Annotated[
1607
+ int | None,
1608
+ typer.Option(
1609
+ "--max-turns",
1610
+ help="Maximum number of agent turns per command/task",
1611
+ ),
1612
+ ] = None,
1613
+ poll_interval: Annotated[
1614
+ float | None,
1615
+ typer.Option(
1616
+ "--poll-interval",
1617
+ help="Seconds between command queue polls (default: from config or 5.0)",
1618
+ ),
1619
+ ] = None,
1620
+ auto_fetch_tasks: Annotated[
1621
+ bool | None,
1622
+ typer.Option(
1623
+ "--auto-fetch-tasks/--no-auto-fetch-tasks",
1624
+ help="Fall back to task queue when command queue is empty",
1625
+ ),
1626
+ ] = None,
1627
+ api_key: Annotated[
1628
+ str | None,
1629
+ typer.Option(
1630
+ "--key",
1631
+ "-k",
1632
+ help="API key for steerdev.com (overrides STEERDEV_API_KEY env var)",
1633
+ envvar="STEERDEV_API_KEY",
1634
+ ),
1635
+ ] = None,
1636
+ ) -> None:
1637
+ """Run the agent in persistent daemon mode.
1638
+
1639
+ The daemon runs indefinitely, polling for commands from the dashboard/API.
1640
+ When the command queue is empty and --auto-fetch-tasks is enabled (default),
1641
+ it falls back to fetching the next task from the task queue.
1642
+
1643
+ Each command/task execution creates a session with full event streaming.
1644
+ Send a 'shutdown' control command or press Ctrl+C to stop.
1645
+
1646
+ Example:
1647
+ steerdev daemon --project-id abc123 --agent-name my-agent
1648
+ steerdev daemon --project-id abc123 --agent-name my-agent --no-auto-fetch-tasks
1649
+ steerdev daemon --project-id abc123 --agent-name my-agent --poll-interval 10
1650
+ """
1651
+ from steerdev_agent.daemon import run_daemon
1652
+
1653
+ # Get config from context (loaded in app callback)
1654
+ config: SteerDevConfig = ctx.obj or SteerDevConfig()
1655
+
1656
+ # Resolve project_id: CLI > env (handled by typer) > config env var
1657
+ resolved_project_id = project_id
1658
+ if not resolved_project_id:
1659
+ resolved_project_id = os.environ.get(config.api.project_id_env)
1660
+ if not resolved_project_id:
1661
+ console.print("[red]Error: --project-id required (or set via env/config)[/red]")
1662
+ raise typer.Exit(1)
1663
+
1664
+ # Agent name is required for daemon mode
1665
+ if not agent_name:
1666
+ console.print(
1667
+ "[red]Error: --agent-name required (or set STEERDEV_AGENT_NAME env var)[/red]"
1668
+ )
1669
+ raise typer.Exit(1)
1670
+
1671
+ # Resolve options: CLI > config > default
1672
+ resolved_model = model if model is not None else config.agent.model
1673
+ resolved_max_turns = max_turns if max_turns is not None else config.agent.max_turns
1674
+
1675
+ # API key: CLI > env (via envvar) > config env var
1676
+ resolved_api_key = api_key
1677
+ if not resolved_api_key:
1678
+ resolved_api_key = os.environ.get(config.api.api_key_env)
1679
+
1680
+ # Build daemon config with CLI overrides
1681
+ daemon_config = config.daemon.model_copy()
1682
+ if poll_interval is not None:
1683
+ daemon_config.poll_interval_seconds = poll_interval
1684
+ if auto_fetch_tasks is not None:
1685
+ daemon_config.auto_fetch_tasks = auto_fetch_tasks
1686
+
1687
+ try:
1688
+ asyncio.run(
1689
+ run_daemon(
1690
+ project_id=resolved_project_id,
1691
+ agent_name=agent_name,
1692
+ working_directory=str(working_dir) if working_dir else None,
1693
+ api_key=resolved_api_key,
1694
+ model=resolved_model,
1695
+ max_turns=resolved_max_turns,
1696
+ daemon_config=daemon_config,
1697
+ executor_config=config.executor,
1698
+ )
1699
+ )
1700
+ except KeyboardInterrupt:
1701
+ console.print("\n[yellow]Daemon stopped[/yellow]")
1702
+ raise typer.Exit(0) from None
1703
+ except Exception as e:
1704
+ console.print(f"\n[red]Daemon error: {e}[/red]")
1705
+ raise typer.Exit(1) from e
1706
+
1707
+
1708
+ @app.command()
1709
+ def resume(
1710
+ ctx: typer.Context,
1711
+ session_id: Annotated[
1712
+ str,
1713
+ typer.Option(
1714
+ "--session-id",
1715
+ "-s",
1716
+ help="Session ID to resume (required)",
1717
+ ),
1718
+ ],
1719
+ message: Annotated[
1720
+ str,
1721
+ typer.Option(
1722
+ "--message",
1723
+ "-m",
1724
+ help="Message to continue the conversation (required)",
1725
+ ),
1726
+ ],
1727
+ api_key: Annotated[
1728
+ str | None,
1729
+ typer.Option(
1730
+ "--key",
1731
+ "-k",
1732
+ help="API key for steerdev.com",
1733
+ envvar="STEERDEV_API_KEY",
1734
+ ),
1735
+ ] = None,
1736
+ dry_run: Annotated[
1737
+ bool,
1738
+ typer.Option(
1739
+ "--dry",
1740
+ help="Print the command that would be run without executing it",
1741
+ ),
1742
+ ] = False,
1743
+ ) -> None:
1744
+ """Resume an existing session with a new message.
1745
+
1746
+ Example:
1747
+ steerdev resume --session-id abc123 --message "Continue working..."
1748
+ """
1749
+ from steerdev_agent.runner import resume_session
1750
+
1751
+ # Get config from context (loaded in app callback)
1752
+ config: SteerDevConfig = ctx.obj or SteerDevConfig()
1753
+
1754
+ # Resolve API key: CLI > env (via envvar) > config env var
1755
+ resolved_api_key = api_key
1756
+ if not resolved_api_key:
1757
+ resolved_api_key = os.environ.get(config.api.api_key_env)
1758
+
1759
+ dry_run_status = "enabled" if dry_run else "disabled"
1760
+
1761
+ console.print(
1762
+ Panel(
1763
+ f"[bold blue]Resuming Session[/bold blue]\n"
1764
+ f"Session ID: {session_id}\n"
1765
+ f"Message: {message[:100]}{'...' if len(message) > 100 else ''}\n"
1766
+ f"Dry Run: {dry_run_status}",
1767
+ title="Resume",
1768
+ )
1769
+ )
1770
+
1771
+ try:
1772
+ result = asyncio.run(
1773
+ resume_session(
1774
+ session_id=session_id,
1775
+ message=message,
1776
+ api_key=resolved_api_key,
1777
+ dry_run=dry_run,
1778
+ )
1779
+ )
1780
+
1781
+ if result.get("success"):
1782
+ console.print(
1783
+ Panel(
1784
+ f"[bold green]Session resumed successfully[/bold green]\n"
1785
+ f"Events Sent: {result.get('events_sent', 0)}",
1786
+ title="Complete",
1787
+ )
1788
+ )
1789
+ else:
1790
+ console.print(f"[red]Resume failed: {result.get('error', 'Unknown')}[/red]")
1791
+ raise typer.Exit(1)
1792
+
1793
+ except KeyboardInterrupt:
1794
+ console.print("\n[yellow]Interrupted by user[/yellow]")
1795
+ raise typer.Exit(130) from None
1796
+ except Exception as e:
1797
+ console.print(f"\n[red]Error: {e}[/red]")
1798
+ raise typer.Exit(1) from e
1799
+
1800
+
1801
+ def _prompt_install_target() -> str:
1802
+ """Prompt the user to choose where to install Claude configs."""
1803
+ choices = {
1804
+ "1": ("project", ".claude/ in the project directory (project-specific)"),
1805
+ "2": ("user", "~/.claude/ in your home directory (shared across projects)"),
1806
+ }
1807
+
1808
+ console.print("\n[bold]Where should steerdev agent configs be installed?[/bold]\n")
1809
+ for key, (_, description) in choices.items():
1810
+ console.print(f" [cyan]{key}[/cyan]) {description}")
1811
+ console.print()
1812
+
1813
+ while True:
1814
+ choice = typer.prompt("Select install target", default="1")
1815
+ if choice in choices:
1816
+ selected = choices[choice][0]
1817
+ console.print(f"\n[dim]Selected: {selected}[/dim]\n")
1818
+ return selected
1819
+ console.print(f"[red]Invalid choice '{choice}'. Enter 1 or 2.[/red]")
1820
+
1821
+
1822
+ @app.command()
1823
+ def setup(
1824
+ project_dir: Annotated[
1825
+ Path | None,
1826
+ typer.Option(
1827
+ "--dir",
1828
+ "-d",
1829
+ help="Target project directory (defaults to current directory)",
1830
+ exists=True,
1831
+ file_okay=False,
1832
+ dir_okay=True,
1833
+ ),
1834
+ ] = None,
1835
+ project_id: Annotated[
1836
+ str | None,
1837
+ typer.Option(
1838
+ "--project-id",
1839
+ "-p",
1840
+ help="SteerDev project ID to configure",
1841
+ ),
1842
+ ] = None,
1843
+ api_key: Annotated[
1844
+ str | None,
1845
+ typer.Option(
1846
+ "--api-key",
1847
+ "-k",
1848
+ help="SteerDev API key to configure",
1849
+ ),
1850
+ ] = None,
1851
+ agent_name: Annotated[
1852
+ str | None,
1853
+ typer.Option(
1854
+ "--agent-name",
1855
+ "-n",
1856
+ help="Agent name for session tracking (generates a random name if not provided)",
1857
+ ),
1858
+ ] = None,
1859
+ install_target: Annotated[
1860
+ str | None,
1861
+ typer.Option(
1862
+ "--install-target",
1863
+ "-i",
1864
+ help="Where to install configs: 'project' (.claude/) or 'user' (~/.claude/)",
1865
+ ),
1866
+ ] = None,
1867
+ force: Annotated[
1868
+ bool,
1869
+ typer.Option(
1870
+ "--force",
1871
+ "-f",
1872
+ help="Overwrite existing files",
1873
+ ),
1874
+ ] = False,
1875
+ skip_claude_md: Annotated[
1876
+ bool,
1877
+ typer.Option(
1878
+ "--skip-claude-md",
1879
+ help="Skip updating CLAUDE.md",
1880
+ ),
1881
+ ] = False,
1882
+ ) -> None:
1883
+ """Set up Claude Code integration for steerdev.com task management.
1884
+
1885
+ This command configures a project with:
1886
+ - .claude/settings.json with permissions for CLI commands and hooks
1887
+ - .claude/skills/task-management/ skill for autonomous task management
1888
+ - .env file with steerdev configuration variables (including agent name)
1889
+ - CLAUDE.md section with task management instructions
1890
+
1891
+ Use --install-target to choose where configs are installed:
1892
+ - project: .claude/ in the project directory (default for team/project-specific configs)
1893
+ - user: ~/.claude/ in your home directory (shared across all projects)
1894
+ """
1895
+ from steerdev_agent.setup import ClaudeSetup
1896
+
1897
+ target_dir = project_dir or Path.cwd()
1898
+
1899
+ # Prompt for install target if not provided
1900
+ if install_target is None:
1901
+ install_target = _prompt_install_target()
1902
+
1903
+ # Validate install target
1904
+ if install_target not in ("project", "user"):
1905
+ console.print(
1906
+ f"[red]Error: Invalid install target '{install_target}'. "
1907
+ f"Must be 'project' or 'user'.[/red]"
1908
+ )
1909
+ raise typer.Exit(1)
1910
+
1911
+ setup_instance = ClaudeSetup(target_dir, install_target=install_target)
1912
+
1913
+ claude_dir_display = "~/.claude/" if install_target == "user" else f"{target_dir}/.claude/"
1914
+
1915
+ console.print(
1916
+ Panel(
1917
+ f"[bold blue]Setting up Claude Code integration[/bold blue]\n"
1918
+ f"Target directory: {target_dir}\n"
1919
+ f"Install target: {install_target} ({claude_dir_display})",
1920
+ title="SteerDev Setup",
1921
+ )
1922
+ )
1923
+
1924
+ def _display_path(p: Path) -> str:
1925
+ """Display a path relative to project dir, or using ~ for home."""
1926
+ try:
1927
+ return str(p.relative_to(target_dir))
1928
+ except ValueError:
1929
+ # Path is outside project dir (e.g., ~/.claude/)
1930
+ home = Path.home()
1931
+ try:
1932
+ return "~/" + str(p.relative_to(home))
1933
+ except ValueError:
1934
+ return str(p)
1935
+
1936
+ try:
1937
+ # Setup settings (with hooks config)
1938
+ settings_path = setup_instance.setup_settings(force=force, include_hooks=True)
1939
+ console.print(f"[green]✓[/green] Settings: {_display_path(settings_path)}")
1940
+
1941
+ # Setup skills
1942
+ skills_path = setup_instance.setup_skills(force=force)
1943
+ console.print(f"[green]✓[/green] Skills: {_display_path(skills_path)}")
1944
+
1945
+ # Setup .env file (generates agent name if not provided)
1946
+ env_path, env_updated = setup_instance.setup_env(
1947
+ project_id=project_id,
1948
+ api_key=api_key,
1949
+ agent_name=agent_name,
1950
+ )
1951
+ if env_updated:
1952
+ console.print(f"[green]✓[/green] .env: {_display_path(env_path)}")
1953
+ else:
1954
+ console.print(f"[dim]○[/dim] .env already configured: {_display_path(env_path)}")
1955
+
1956
+ # Setup CLAUDE.md
1957
+ if not skip_claude_md:
1958
+ updated = setup_instance.update_claude_md(force=force)
1959
+ if updated:
1960
+ console.print("[green]✓[/green] CLAUDE.md updated with task management section")
1961
+ else:
1962
+ console.print("[dim]○[/dim] CLAUDE.md already has task management section")
1963
+ else:
1964
+ console.print("[dim]○[/dim] Skipped CLAUDE.md update")
1965
+
1966
+ # Add steerdev.yaml config
1967
+ config_path, config_created = setup_instance.setup_steerdev_config(force=force)
1968
+ if config_created:
1969
+ console.print(f"[green]✓[/green] Config: {_display_path(config_path)}")
1970
+ else:
1971
+ console.print(f"[dim]○[/dim] Config already exists: {_display_path(config_path)}")
1972
+
1973
+ # Sync shared agent settings if credentials are available
1974
+ if project_id and api_key:
1975
+ try:
1976
+ console.print("\n[blue]Syncing shared agent settings...[/blue]")
1977
+ platform_config = asyncio.run(_sync_configs(project_id))
1978
+ if platform_config is None:
1979
+ raise RuntimeError("Failed to fetch platform configs")
1980
+ counts = setup_instance.apply_platform_configs(platform_config)
1981
+ _save_synced_at(target_dir, platform_config.synced_at)
1982
+ total = sum(counts.values())
1983
+ if total > 0:
1984
+ console.print(f"[green]✓[/green] Synced {total} shared setting(s)")
1985
+ else:
1986
+ console.print("[dim]○[/dim] No shared settings configured")
1987
+ except Exception as e:
1988
+ console.print(f"[yellow]⚠[/yellow] Could not sync settings: {e}")
1989
+
1990
+ # Show different messages based on whether credentials were provided
1991
+ if project_id and api_key:
1992
+ next_steps = (
1993
+ "[bold green]Setup complete![/bold green]\n\n"
1994
+ "Your project is configured and ready to use.\n\n"
1995
+ "Run the agent:\n"
1996
+ " steerdev run\n\n"
1997
+ "Commands available:\n"
1998
+ " steerdev run --help\n"
1999
+ " steerdev resume --help\n"
2000
+ " steerdev tasks --help\n"
2001
+ " steerdev sessions --help"
2002
+ )
2003
+ else:
2004
+ next_steps = (
2005
+ "[bold green]Setup complete![/bold green]\n\n"
2006
+ "Next steps:\n"
2007
+ "1. Edit .env and set your STEERDEV_API_KEY and STEERDEV_PROJECT_ID\n"
2008
+ "2. Run: steerdev run\n\n"
2009
+ "Commands available:\n"
2010
+ " steerdev run --help\n"
2011
+ " steerdev resume --help\n"
2012
+ " steerdev tasks --help\n"
2013
+ " steerdev sessions --help"
2014
+ )
2015
+
2016
+ console.print(
2017
+ Panel(
2018
+ next_steps,
2019
+ title="Done",
2020
+ border_style="green",
2021
+ )
2022
+ )
2023
+
2024
+ except Exception as e:
2025
+ console.print(f"[red]Error during setup: {e}[/red]")
2026
+ raise typer.Exit(1) from e
2027
+
2028
+
2029
+ @app.command()
2030
+ def sync(
2031
+ project_dir: Annotated[
2032
+ Path | None,
2033
+ typer.Option(
2034
+ "--dir",
2035
+ "-d",
2036
+ help="Target project directory (defaults to current directory)",
2037
+ exists=True,
2038
+ file_okay=False,
2039
+ dir_okay=True,
2040
+ ),
2041
+ ] = None,
2042
+ project_id: Annotated[
2043
+ str | None,
2044
+ typer.Option(
2045
+ "--project-id",
2046
+ "-p",
2047
+ help="SteerDev project ID",
2048
+ envvar="STEERDEV_PROJECT_ID",
2049
+ ),
2050
+ ] = None,
2051
+ install_target: Annotated[
2052
+ str,
2053
+ typer.Option(
2054
+ "--install-target",
2055
+ "-i",
2056
+ help="Where configs are installed: 'project' (.claude/) or 'user' (~/.claude/)",
2057
+ ),
2058
+ ] = "project",
2059
+ ) -> None:
2060
+ """Sync shared agent settings from steerdev.com.
2061
+
2062
+ Pulls the latest shared configurations (system prompts, skills, MCP servers,
2063
+ env variables) from the platform and applies them to the local project.
2064
+
2065
+ This updates:
2066
+ - CLAUDE.md with shared system prompts
2067
+ - .claude/skills/ with synced skills
2068
+ - .claude/settings.json with MCP server configurations
2069
+ - .env with shared environment variables
2070
+ """
2071
+ import yaml
2072
+
2073
+ target_dir = project_dir or Path.cwd()
2074
+
2075
+ # Resolve project_id from env/config if not provided
2076
+ resolved_project_id = project_id
2077
+ if not resolved_project_id:
2078
+ resolved_project_id = os.environ.get("STEERDEV_PROJECT_ID")
2079
+ if not resolved_project_id:
2080
+ # Try from steerdev.yaml
2081
+ config_path = target_dir / "steerdev.yaml"
2082
+ if config_path.exists():
2083
+ config_data = yaml.safe_load(config_path.read_text())
2084
+ env_name = config_data.get("api", {}).get("project_id_env", "STEERDEV_PROJECT_ID")
2085
+ resolved_project_id = os.environ.get(env_name)
2086
+
2087
+ if not resolved_project_id:
2088
+ console.print("[red]Error: --project-id required (or set STEERDEV_PROJECT_ID)[/red]")
2089
+ raise typer.Exit(1)
2090
+
2091
+ console.print(
2092
+ Panel(
2093
+ f"[bold blue]Syncing shared agent settings[/bold blue]\n"
2094
+ f"Project: {resolved_project_id}\n"
2095
+ f"Directory: {target_dir}",
2096
+ title="SteerDev Sync",
2097
+ )
2098
+ )
2099
+
2100
+ try:
2101
+ from steerdev_agent.setup import ClaudeSetup
2102
+
2103
+ # Fetch configs from API
2104
+ platform_config = asyncio.run(_sync_configs(resolved_project_id))
2105
+ if platform_config is None:
2106
+ console.print("[yellow]⚠[/yellow] No configs returned from API")
2107
+ raise typer.Exit(1)
2108
+
2109
+ # Apply to local project
2110
+ setup_instance = ClaudeSetup(target_dir, install_target=install_target)
2111
+ counts = setup_instance.apply_platform_configs(platform_config)
2112
+
2113
+ # Save synced_at timestamp to steerdev.yaml
2114
+ _save_synced_at(target_dir, platform_config.synced_at)
2115
+
2116
+ # Display results
2117
+ total = sum(counts.values())
2118
+ if total > 0:
2119
+ details = []
2120
+ if counts["system_prompts"]:
2121
+ details.append(f"System prompts: {counts['system_prompts']}")
2122
+ if counts["skills"]:
2123
+ details.append(f"Skills: {counts['skills']}")
2124
+ if counts["mcps"]:
2125
+ details.append(f"MCP servers: {counts['mcps']}")
2126
+ if counts["env_vars"]:
2127
+ details.append(f"Env variables: {counts['env_vars']}")
2128
+ console.print(
2129
+ Panel(
2130
+ "[bold green]Sync complete[/bold green]\n" + "\n".join(details),
2131
+ title="Done",
2132
+ border_style="green",
2133
+ )
2134
+ )
2135
+ else:
2136
+ console.print("[yellow]No shared settings configured for this project.[/yellow]")
2137
+
2138
+ except Exception as e:
2139
+ console.print(f"[red]Error during sync: {e}[/red]")
2140
+ raise typer.Exit(1) from e
2141
+
2142
+
2143
+ async def _sync_configs(project_id: str):
2144
+ """Fetch platform configs via async API client."""
2145
+ from steerdev_agent.api.configs import ConfigsClient
2146
+
2147
+ async with ConfigsClient() as client:
2148
+ return await client.sync_configs(project_id)
2149
+
2150
+
2151
+ def _save_synced_at(target_dir: Path, synced_at: str) -> None:
2152
+ """Save the synced_at timestamp to steerdev.yaml."""
2153
+ import yaml
2154
+
2155
+ config_path = target_dir / "steerdev.yaml"
2156
+ config_data = yaml.safe_load(config_path.read_text()) or {} if config_path.exists() else {}
2157
+ config_data["synced_at"] = synced_at
2158
+ config_path.write_text(yaml.dump(config_data, default_flow_style=False))
2159
+
2160
+
2161
+ def _check_sync_staleness(target_dir: Path) -> None:
2162
+ """Check if synced configs are stale and print a warning."""
2163
+ from datetime import UTC, datetime, timedelta
2164
+
2165
+ import yaml
2166
+
2167
+ config_path = target_dir / "steerdev.yaml"
2168
+ if not config_path.exists():
2169
+ return
2170
+
2171
+ try:
2172
+ config_data = yaml.safe_load(config_path.read_text()) or {}
2173
+ synced_at_str = config_data.get("synced_at")
2174
+ if not synced_at_str:
2175
+ return
2176
+
2177
+ synced_at = datetime.fromisoformat(synced_at_str.replace("Z", "+00:00"))
2178
+ now = datetime.now(UTC)
2179
+ if now - synced_at > timedelta(hours=1):
2180
+ age_hours = (now - synced_at).total_seconds() / 3600
2181
+ console.print(
2182
+ f"[yellow]Warning: Shared agent settings may be out of date "
2183
+ f"(last synced {age_hours:.0f}h ago). "
2184
+ f"Run `steerdev sync` to update.[/yellow]"
2185
+ )
2186
+ except Exception:
2187
+ pass # Don't block execution on staleness check errors
2188
+
2189
+
2190
+ @app.command("test")
2191
+ def integration_test(
2192
+ project_id: Annotated[
2193
+ str,
2194
+ typer.Option(
2195
+ "--project-id",
2196
+ "-p",
2197
+ help="SteerDev project ID to add tasks to",
2198
+ ),
2199
+ ],
2200
+ project_dir: Annotated[
2201
+ Path,
2202
+ typer.Option(
2203
+ "--dir",
2204
+ "-d",
2205
+ help="Directory to create the test project in",
2206
+ ),
2207
+ ],
2208
+ tasks_file: Annotated[
2209
+ Path | None,
2210
+ typer.Option(
2211
+ "--tasks-file",
2212
+ "-t",
2213
+ help="JSON file with custom tasks (uses default todo-api tasks if not provided)",
2214
+ ),
2215
+ ] = None,
2216
+ skip_tasks: Annotated[
2217
+ bool,
2218
+ typer.Option(
2219
+ "--skip-tasks",
2220
+ help="Skip adding tasks to the project",
2221
+ ),
2222
+ ] = False,
2223
+ auto_start: Annotated[
2224
+ bool,
2225
+ typer.Option(
2226
+ "--auto-start",
2227
+ "-y",
2228
+ help="Start the agent without confirmation",
2229
+ ),
2230
+ ] = False,
2231
+ ) -> None:
2232
+ """Run an integration test with a fresh todo-list API project.
2233
+
2234
+ Creates a new Python project using `uv init` and adds tasks for building
2235
+ a todo-list API with FastAPI and SQLite. Useful for testing the agent
2236
+ end-to-end in a controlled environment.
2237
+
2238
+ Example:
2239
+ steerdev integration-test -p YOUR_PROJECT_UUID
2240
+ steerdev integration-test -p YOUR_PROJECT_UUID -y # auto-start
2241
+ """
2242
+ from steerdev_agent.integration import run_integration_test
2243
+
2244
+ run_integration_test(
2245
+ project_id=project_id,
2246
+ project_dir=project_dir,
2247
+ tasks_file=tasks_file,
2248
+ skip_tasks=skip_tasks,
2249
+ auto_start=auto_start,
2250
+ )
2251
+
2252
+
2253
+ if __name__ == "__main__":
2254
+ app()