gobby 0.2.9__py3-none-any.whl → 0.2.11__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 (134) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +6 -0
  3. gobby/adapters/base.py +11 -2
  4. gobby/adapters/claude_code.py +2 -2
  5. gobby/adapters/codex_impl/adapter.py +38 -43
  6. gobby/adapters/copilot.py +324 -0
  7. gobby/adapters/cursor.py +373 -0
  8. gobby/adapters/gemini.py +2 -26
  9. gobby/adapters/windsurf.py +359 -0
  10. gobby/agents/definitions.py +162 -2
  11. gobby/agents/isolation.py +33 -1
  12. gobby/agents/pty_reader.py +192 -0
  13. gobby/agents/registry.py +10 -1
  14. gobby/agents/runner.py +24 -8
  15. gobby/agents/sandbox.py +8 -3
  16. gobby/agents/session.py +4 -0
  17. gobby/agents/spawn.py +9 -2
  18. gobby/agents/spawn_executor.py +49 -61
  19. gobby/agents/spawners/command_builder.py +4 -4
  20. gobby/app_context.py +5 -0
  21. gobby/cli/__init__.py +4 -0
  22. gobby/cli/install.py +259 -4
  23. gobby/cli/installers/__init__.py +12 -0
  24. gobby/cli/installers/copilot.py +242 -0
  25. gobby/cli/installers/cursor.py +244 -0
  26. gobby/cli/installers/shared.py +3 -0
  27. gobby/cli/installers/windsurf.py +242 -0
  28. gobby/cli/pipelines.py +639 -0
  29. gobby/cli/sessions.py +3 -1
  30. gobby/cli/skills.py +209 -0
  31. gobby/cli/tasks/crud.py +6 -5
  32. gobby/cli/tasks/search.py +1 -1
  33. gobby/cli/ui.py +116 -0
  34. gobby/cli/workflows.py +38 -17
  35. gobby/config/app.py +5 -0
  36. gobby/config/skills.py +23 -2
  37. gobby/hooks/broadcaster.py +9 -0
  38. gobby/hooks/event_handlers/_base.py +6 -1
  39. gobby/hooks/event_handlers/_session.py +44 -130
  40. gobby/hooks/events.py +48 -0
  41. gobby/hooks/hook_manager.py +25 -3
  42. gobby/install/copilot/hooks/hook_dispatcher.py +203 -0
  43. gobby/install/cursor/hooks/hook_dispatcher.py +203 -0
  44. gobby/install/gemini/hooks/hook_dispatcher.py +8 -0
  45. gobby/install/windsurf/hooks/hook_dispatcher.py +205 -0
  46. gobby/llm/__init__.py +14 -1
  47. gobby/llm/claude.py +217 -1
  48. gobby/llm/service.py +149 -0
  49. gobby/mcp_proxy/instructions.py +9 -27
  50. gobby/mcp_proxy/models.py +1 -0
  51. gobby/mcp_proxy/registries.py +56 -9
  52. gobby/mcp_proxy/server.py +6 -2
  53. gobby/mcp_proxy/services/tool_filter.py +7 -0
  54. gobby/mcp_proxy/services/tool_proxy.py +19 -1
  55. gobby/mcp_proxy/stdio.py +37 -21
  56. gobby/mcp_proxy/tools/agents.py +7 -0
  57. gobby/mcp_proxy/tools/hub.py +30 -1
  58. gobby/mcp_proxy/tools/orchestration/cleanup.py +5 -5
  59. gobby/mcp_proxy/tools/orchestration/monitor.py +1 -1
  60. gobby/mcp_proxy/tools/orchestration/orchestrate.py +8 -3
  61. gobby/mcp_proxy/tools/orchestration/review.py +17 -4
  62. gobby/mcp_proxy/tools/orchestration/wait.py +7 -7
  63. gobby/mcp_proxy/tools/pipelines/__init__.py +254 -0
  64. gobby/mcp_proxy/tools/pipelines/_discovery.py +67 -0
  65. gobby/mcp_proxy/tools/pipelines/_execution.py +281 -0
  66. gobby/mcp_proxy/tools/sessions/_crud.py +4 -4
  67. gobby/mcp_proxy/tools/sessions/_handoff.py +1 -1
  68. gobby/mcp_proxy/tools/skills/__init__.py +184 -30
  69. gobby/mcp_proxy/tools/spawn_agent.py +229 -14
  70. gobby/mcp_proxy/tools/tasks/_context.py +8 -0
  71. gobby/mcp_proxy/tools/tasks/_crud.py +27 -1
  72. gobby/mcp_proxy/tools/tasks/_helpers.py +1 -1
  73. gobby/mcp_proxy/tools/tasks/_lifecycle.py +125 -8
  74. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +2 -1
  75. gobby/mcp_proxy/tools/tasks/_search.py +1 -1
  76. gobby/mcp_proxy/tools/workflows/__init__.py +9 -2
  77. gobby/mcp_proxy/tools/workflows/_lifecycle.py +12 -1
  78. gobby/mcp_proxy/tools/workflows/_query.py +45 -26
  79. gobby/mcp_proxy/tools/workflows/_terminal.py +39 -3
  80. gobby/mcp_proxy/tools/worktrees.py +54 -15
  81. gobby/memory/context.py +5 -5
  82. gobby/runner.py +108 -6
  83. gobby/servers/http.py +7 -1
  84. gobby/servers/routes/__init__.py +2 -0
  85. gobby/servers/routes/admin.py +44 -0
  86. gobby/servers/routes/mcp/endpoints/execution.py +18 -25
  87. gobby/servers/routes/mcp/hooks.py +10 -1
  88. gobby/servers/routes/pipelines.py +227 -0
  89. gobby/servers/websocket.py +314 -1
  90. gobby/sessions/analyzer.py +87 -1
  91. gobby/sessions/manager.py +5 -5
  92. gobby/sessions/transcripts/__init__.py +3 -0
  93. gobby/sessions/transcripts/claude.py +5 -0
  94. gobby/sessions/transcripts/codex.py +5 -0
  95. gobby/sessions/transcripts/gemini.py +5 -0
  96. gobby/skills/hubs/__init__.py +25 -0
  97. gobby/skills/hubs/base.py +234 -0
  98. gobby/skills/hubs/claude_plugins.py +328 -0
  99. gobby/skills/hubs/clawdhub.py +289 -0
  100. gobby/skills/hubs/github_collection.py +465 -0
  101. gobby/skills/hubs/manager.py +263 -0
  102. gobby/skills/hubs/skillhub.py +342 -0
  103. gobby/storage/memories.py +4 -4
  104. gobby/storage/migrations.py +95 -3
  105. gobby/storage/pipelines.py +367 -0
  106. gobby/storage/sessions.py +23 -4
  107. gobby/storage/skills.py +1 -1
  108. gobby/storage/tasks/_aggregates.py +2 -2
  109. gobby/storage/tasks/_lifecycle.py +4 -4
  110. gobby/storage/tasks/_models.py +7 -1
  111. gobby/storage/tasks/_queries.py +3 -3
  112. gobby/sync/memories.py +4 -3
  113. gobby/tasks/commits.py +48 -17
  114. gobby/workflows/actions.py +75 -0
  115. gobby/workflows/context_actions.py +246 -5
  116. gobby/workflows/definitions.py +119 -1
  117. gobby/workflows/detection_helpers.py +23 -11
  118. gobby/workflows/enforcement/task_policy.py +18 -0
  119. gobby/workflows/engine.py +20 -1
  120. gobby/workflows/evaluator.py +8 -5
  121. gobby/workflows/lifecycle_evaluator.py +57 -26
  122. gobby/workflows/loader.py +567 -30
  123. gobby/workflows/lobster_compat.py +147 -0
  124. gobby/workflows/pipeline_executor.py +801 -0
  125. gobby/workflows/pipeline_state.py +172 -0
  126. gobby/workflows/pipeline_webhooks.py +206 -0
  127. gobby/workflows/premature_stop.py +5 -0
  128. gobby/worktrees/git.py +135 -20
  129. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/METADATA +56 -22
  130. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/RECORD +134 -106
  131. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/WHEEL +0 -0
  132. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/entry_points.txt +0 -0
  133. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/licenses/LICENSE.md +0 -0
  134. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/top_level.txt +0 -0
gobby/cli/install.py CHANGED
@@ -3,6 +3,7 @@ Installation commands for hooks.
3
3
  """
4
4
 
5
5
  import logging
6
+ import os
6
7
  import shutil
7
8
  import sys
8
9
  from pathlib import Path
@@ -15,12 +16,18 @@ from .installers import (
15
16
  install_antigravity,
16
17
  install_claude,
17
18
  install_codex_notify,
19
+ install_copilot,
20
+ install_cursor,
18
21
  install_default_mcp_servers,
19
22
  install_gemini,
20
23
  install_git_hooks,
24
+ install_windsurf,
21
25
  uninstall_claude,
22
26
  uninstall_codex_notify,
27
+ uninstall_copilot,
28
+ uninstall_cursor,
23
29
  uninstall_gemini,
30
+ uninstall_windsurf,
24
31
  )
25
32
  from .utils import get_install_dir
26
33
 
@@ -75,6 +82,37 @@ def _is_codex_cli_installed() -> bool:
75
82
  return shutil.which("codex") is not None
76
83
 
77
84
 
85
+ def _is_cursor_installed() -> bool:
86
+ """Check if Cursor is installed."""
87
+ # Cursor is an IDE, check for common install locations
88
+ if sys.platform == "darwin":
89
+ return Path("/Applications/Cursor.app").exists()
90
+ elif sys.platform == "win32":
91
+ return Path(os.environ.get("LOCALAPPDATA", ""), "Programs", "cursor").exists()
92
+ else:
93
+ # Linux - check common locations
94
+ return (Path.home() / ".local" / "share" / "cursor").exists() or shutil.which(
95
+ "cursor"
96
+ ) is not None
97
+
98
+
99
+ def _is_windsurf_installed() -> bool:
100
+ """Check if Windsurf (Codeium) is installed."""
101
+ # Windsurf is an IDE
102
+ if sys.platform == "darwin":
103
+ return Path("/Applications/Windsurf.app").exists()
104
+ elif sys.platform == "win32":
105
+ return Path(os.environ.get("LOCALAPPDATA", ""), "Programs", "windsurf").exists()
106
+ else:
107
+ return shutil.which("windsurf") is not None
108
+
109
+
110
+ def _is_copilot_cli_installed() -> bool:
111
+ """Check if GitHub Copilot CLI is installed."""
112
+ # Check for gh copilot extension or standalone CLI
113
+ return shutil.which("gh") is not None or shutil.which("github-copilot-cli") is not None
114
+
115
+
78
116
  @click.command("install")
79
117
  @click.option(
80
118
  "--claude",
@@ -94,6 +132,24 @@ def _is_codex_cli_installed() -> bool:
94
132
  is_flag=True,
95
133
  help="Configure Codex notify integration (interactive Codex)",
96
134
  )
135
+ @click.option(
136
+ "--cursor",
137
+ "cursor_flag",
138
+ is_flag=True,
139
+ help="Install Cursor hooks",
140
+ )
141
+ @click.option(
142
+ "--windsurf",
143
+ "windsurf_flag",
144
+ is_flag=True,
145
+ help="Install Windsurf (Cascade) hooks",
146
+ )
147
+ @click.option(
148
+ "--copilot",
149
+ "copilot_flag",
150
+ is_flag=True,
151
+ help="Install GitHub Copilot CLI hooks",
152
+ )
97
153
  @click.option(
98
154
  "--hooks",
99
155
  "--git-hooks",
@@ -118,6 +174,9 @@ def install(
118
174
  claude_flag: bool,
119
175
  gemini_flag: bool,
120
176
  codex_flag: bool,
177
+ cursor_flag: bool,
178
+ windsurf_flag: bool,
179
+ copilot_flag: bool,
121
180
  hooks_flag: bool,
122
181
  all_flag: bool,
123
182
  antigravity_flag: bool,
@@ -143,6 +202,9 @@ def install(
143
202
  not claude_flag
144
203
  and not gemini_flag
145
204
  and not codex_flag
205
+ and not cursor_flag
206
+ and not windsurf_flag
207
+ and not copilot_flag
146
208
  and not hooks_flag
147
209
  and not all_flag
148
210
  and not antigravity_flag
@@ -162,6 +224,12 @@ def install(
162
224
  clis_to_install.append("gemini")
163
225
  if codex_detected:
164
226
  clis_to_install.append("codex")
227
+ if _is_cursor_installed():
228
+ clis_to_install.append("cursor")
229
+ if _is_windsurf_installed():
230
+ clis_to_install.append("windsurf")
231
+ if _is_copilot_cli_installed():
232
+ clis_to_install.append("copilot")
165
233
 
166
234
  # Check for git
167
235
  if (project_path / ".git").exists():
@@ -174,8 +242,11 @@ def install(
174
242
  click.echo(" - Claude Code: npm install -g @anthropic-ai/claude-code")
175
243
  click.echo(" - Gemini CLI: npm install -g @google/gemini-cli")
176
244
  click.echo(" - Codex CLI: npm install -g @openai/codex")
245
+ click.echo(" - Cursor: https://cursor.com")
246
+ click.echo(" - Windsurf: https://codeium.com/windsurf")
247
+ click.echo(" - Copilot CLI: gh extension install github/gh-copilot")
177
248
  click.echo(
178
- "\nYou can still install manually with --claude, --gemini, or --codex flags."
249
+ "\nYou can still install manually with --claude, --gemini, --codex, --cursor, --windsurf, or --copilot flags."
179
250
  )
180
251
  sys.exit(1)
181
252
  else:
@@ -185,6 +256,12 @@ def install(
185
256
  clis_to_install.append("gemini")
186
257
  if codex_flag:
187
258
  clis_to_install.append("codex")
259
+ if cursor_flag:
260
+ clis_to_install.append("cursor")
261
+ if windsurf_flag:
262
+ clis_to_install.append("windsurf")
263
+ if copilot_flag:
264
+ clis_to_install.append("copilot")
188
265
  if antigravity_flag:
189
266
  clis_to_install.append("antigravity")
190
267
 
@@ -346,6 +423,66 @@ def install(
346
423
  click.echo(f"Failed: {result['error']}", err=True)
347
424
  click.echo("")
348
425
 
426
+ # Install Cursor hooks
427
+ if "cursor" in clis_to_install:
428
+ click.echo("-" * 40)
429
+ click.echo("Cursor")
430
+ click.echo("-" * 40)
431
+
432
+ result = install_cursor(project_path)
433
+ results["cursor"] = result
434
+
435
+ if result["success"]:
436
+ click.echo(f"Installed {len(result['hooks_installed'])} hooks")
437
+ for hook in result["hooks_installed"]:
438
+ click.echo(f" - {hook}")
439
+ if result.get("workflows_installed"):
440
+ click.echo(f"Installed {len(result['workflows_installed'])} workflows")
441
+ click.echo(f"Configuration: {project_path / '.cursor' / 'hooks.json'}")
442
+ else:
443
+ click.echo(f"Failed: {result['error']}", err=True)
444
+ click.echo("")
445
+
446
+ # Install Windsurf hooks
447
+ if "windsurf" in clis_to_install:
448
+ click.echo("-" * 40)
449
+ click.echo("Windsurf (Cascade)")
450
+ click.echo("-" * 40)
451
+
452
+ result = install_windsurf(project_path)
453
+ results["windsurf"] = result
454
+
455
+ if result["success"]:
456
+ click.echo(f"Installed {len(result['hooks_installed'])} hooks")
457
+ for hook in result["hooks_installed"]:
458
+ click.echo(f" - {hook}")
459
+ if result.get("workflows_installed"):
460
+ click.echo(f"Installed {len(result['workflows_installed'])} workflows")
461
+ click.echo(f"Configuration: {project_path / '.windsurf' / 'hooks.json'}")
462
+ else:
463
+ click.echo(f"Failed: {result['error']}", err=True)
464
+ click.echo("")
465
+
466
+ # Install Copilot CLI hooks
467
+ if "copilot" in clis_to_install:
468
+ click.echo("-" * 40)
469
+ click.echo("GitHub Copilot CLI")
470
+ click.echo("-" * 40)
471
+
472
+ result = install_copilot(project_path)
473
+ results["copilot"] = result
474
+
475
+ if result["success"]:
476
+ click.echo(f"Installed {len(result['hooks_installed'])} hooks")
477
+ for hook in result["hooks_installed"]:
478
+ click.echo(f" - {hook}")
479
+ if result.get("workflows_installed"):
480
+ click.echo(f"Installed {len(result['workflows_installed'])} workflows")
481
+ click.echo(f"Configuration: {project_path / '.copilot' / 'hooks.json'}")
482
+ else:
483
+ click.echo(f"Failed: {result['error']}", err=True)
484
+ click.echo("")
485
+
349
486
  # Install Git Hooks
350
487
  if hooks_flag:
351
488
  click.echo("-" * 40)
@@ -467,6 +604,24 @@ def install(
467
604
  is_flag=True,
468
605
  help="Uninstall Codex notify integration",
469
606
  )
607
+ @click.option(
608
+ "--cursor",
609
+ "cursor_flag",
610
+ is_flag=True,
611
+ help="Uninstall Cursor hooks",
612
+ )
613
+ @click.option(
614
+ "--windsurf",
615
+ "windsurf_flag",
616
+ is_flag=True,
617
+ help="Uninstall Windsurf hooks",
618
+ )
619
+ @click.option(
620
+ "--copilot",
621
+ "copilot_flag",
622
+ is_flag=True,
623
+ help="Uninstall Copilot CLI hooks",
624
+ )
470
625
  @click.option(
471
626
  "--all",
472
627
  "all_flag",
@@ -475,11 +630,19 @@ def install(
475
630
  help="Uninstall hooks from all CLIs (default behavior when no flags specified)",
476
631
  )
477
632
  @click.confirmation_option(prompt="Are you sure you want to uninstall Gobby hooks?")
478
- def uninstall(claude_flag: bool, gemini_flag: bool, codex_flag: bool, all_flag: bool) -> None:
633
+ def uninstall(
634
+ claude_flag: bool,
635
+ gemini_flag: bool,
636
+ codex_flag: bool,
637
+ cursor_flag: bool,
638
+ windsurf_flag: bool,
639
+ copilot_flag: bool,
640
+ all_flag: bool,
641
+ ) -> None:
479
642
  """Uninstall Gobby hooks from AI coding CLIs.
480
643
 
481
644
  By default (no flags), uninstalls from all CLIs that have hooks installed.
482
- Use --claude, --gemini, or --codex to uninstall only from specific CLIs.
645
+ Use --claude, --gemini, --codex, --cursor, --windsurf, or --copilot to uninstall only from specific CLIs.
483
646
 
484
647
  Uninstalls from project-level directories in current working directory.
485
648
  """
@@ -487,7 +650,15 @@ def uninstall(claude_flag: bool, gemini_flag: bool, codex_flag: bool, all_flag:
487
650
 
488
651
  # Determine which CLIs to uninstall
489
652
  # If no flags specified, act like --all
490
- if not claude_flag and not gemini_flag and not codex_flag and not all_flag:
653
+ if (
654
+ not claude_flag
655
+ and not gemini_flag
656
+ and not codex_flag
657
+ and not cursor_flag
658
+ and not windsurf_flag
659
+ and not copilot_flag
660
+ and not all_flag
661
+ ):
491
662
  all_flag = True
492
663
 
493
664
  # Build list of CLIs to uninstall
@@ -498,6 +669,9 @@ def uninstall(claude_flag: bool, gemini_flag: bool, codex_flag: bool, all_flag:
498
669
  claude_settings = project_path / ".claude" / "settings.json"
499
670
  gemini_settings = project_path / ".gemini" / "settings.json"
500
671
  codex_notify = Path.home() / ".gobby" / "hooks" / "codex" / "hook_dispatcher.py"
672
+ cursor_hooks = project_path / ".cursor" / "hooks.json"
673
+ windsurf_hooks = project_path / ".windsurf" / "hooks.json"
674
+ copilot_hooks = project_path / ".copilot" / "hooks.json"
501
675
 
502
676
  if claude_settings.exists():
503
677
  clis_to_uninstall.append("claude")
@@ -505,11 +679,20 @@ def uninstall(claude_flag: bool, gemini_flag: bool, codex_flag: bool, all_flag:
505
679
  clis_to_uninstall.append("gemini")
506
680
  if codex_notify.exists():
507
681
  clis_to_uninstall.append("codex")
682
+ if cursor_hooks.exists():
683
+ clis_to_uninstall.append("cursor")
684
+ if windsurf_hooks.exists():
685
+ clis_to_uninstall.append("windsurf")
686
+ if copilot_hooks.exists():
687
+ clis_to_uninstall.append("copilot")
508
688
 
509
689
  if not clis_to_uninstall:
510
690
  click.echo("No Gobby hooks found to uninstall.")
511
691
  click.echo(f"\nChecked: {project_path / '.claude'}")
512
692
  click.echo(f" {project_path / '.gemini'}")
693
+ click.echo(f" {project_path / '.cursor'}")
694
+ click.echo(f" {project_path / '.windsurf'}")
695
+ click.echo(f" {project_path / '.copilot'}")
513
696
  click.echo(f" {codex_notify}")
514
697
  sys.exit(0)
515
698
  else:
@@ -519,6 +702,12 @@ def uninstall(claude_flag: bool, gemini_flag: bool, codex_flag: bool, all_flag:
519
702
  clis_to_uninstall.append("gemini")
520
703
  if codex_flag:
521
704
  clis_to_uninstall.append("codex")
705
+ if cursor_flag:
706
+ clis_to_uninstall.append("cursor")
707
+ if windsurf_flag:
708
+ clis_to_uninstall.append("windsurf")
709
+ if copilot_flag:
710
+ clis_to_uninstall.append("copilot")
522
711
 
523
712
  click.echo("=" * 60)
524
713
  click.echo(" Gobby Hooks Uninstallation")
@@ -597,6 +786,72 @@ def uninstall(claude_flag: bool, gemini_flag: bool, codex_flag: bool, all_flag:
597
786
  click.echo(f"Failed: {result['error']}", err=True)
598
787
  click.echo("")
599
788
 
789
+ # Uninstall Cursor hooks
790
+ if "cursor" in clis_to_uninstall:
791
+ click.echo("-" * 40)
792
+ click.echo("Cursor")
793
+ click.echo("-" * 40)
794
+
795
+ result = uninstall_cursor(project_path)
796
+ results["cursor"] = result
797
+
798
+ if result["success"]:
799
+ if result["hooks_removed"]:
800
+ click.echo(f"Removed {len(result['hooks_removed'])} hooks from hooks.json")
801
+ for hook in result["hooks_removed"]:
802
+ click.echo(f" - {hook}")
803
+ if result["files_removed"]:
804
+ click.echo(f"Removed {len(result['files_removed'])} files")
805
+ if not result["hooks_removed"] and not result["files_removed"]:
806
+ click.echo(" (no hooks found to remove)")
807
+ else:
808
+ click.echo(f"Failed: {result['error']}", err=True)
809
+ click.echo("")
810
+
811
+ # Uninstall Windsurf hooks
812
+ if "windsurf" in clis_to_uninstall:
813
+ click.echo("-" * 40)
814
+ click.echo("Windsurf")
815
+ click.echo("-" * 40)
816
+
817
+ result = uninstall_windsurf(project_path)
818
+ results["windsurf"] = result
819
+
820
+ if result["success"]:
821
+ if result["hooks_removed"]:
822
+ click.echo(f"Removed {len(result['hooks_removed'])} hooks from hooks.json")
823
+ for hook in result["hooks_removed"]:
824
+ click.echo(f" - {hook}")
825
+ if result["files_removed"]:
826
+ click.echo(f"Removed {len(result['files_removed'])} files")
827
+ if not result["hooks_removed"] and not result["files_removed"]:
828
+ click.echo(" (no hooks found to remove)")
829
+ else:
830
+ click.echo(f"Failed: {result['error']}", err=True)
831
+ click.echo("")
832
+
833
+ # Uninstall Copilot hooks
834
+ if "copilot" in clis_to_uninstall:
835
+ click.echo("-" * 40)
836
+ click.echo("Copilot CLI")
837
+ click.echo("-" * 40)
838
+
839
+ result = uninstall_copilot(project_path)
840
+ results["copilot"] = result
841
+
842
+ if result["success"]:
843
+ if result["hooks_removed"]:
844
+ click.echo(f"Removed {len(result['hooks_removed'])} hooks from hooks.json")
845
+ for hook in result["hooks_removed"]:
846
+ click.echo(f" - {hook}")
847
+ if result["files_removed"]:
848
+ click.echo(f"Removed {len(result['files_removed'])} files")
849
+ if not result["hooks_removed"] and not result["files_removed"]:
850
+ click.echo(" (no hooks found to remove)")
851
+ else:
852
+ click.echo(f"Failed: {result['error']}", err=True)
853
+ click.echo("")
854
+
600
855
  # Summary
601
856
  click.echo("=" * 60)
602
857
  click.echo(" Summary")
@@ -8,6 +8,8 @@ using the strangler fig pattern for incremental migration.
8
8
  from .antigravity import install_antigravity
9
9
  from .claude import install_claude, uninstall_claude
10
10
  from .codex import install_codex_notify, uninstall_codex_notify
11
+ from .copilot import install_copilot, uninstall_copilot
12
+ from .cursor import install_cursor, uninstall_cursor
11
13
  from .gemini import install_gemini, uninstall_gemini
12
14
  from .git_hooks import install_git_hooks
13
15
  from .shared import (
@@ -15,6 +17,7 @@ from .shared import (
15
17
  install_default_mcp_servers,
16
18
  install_shared_content,
17
19
  )
20
+ from .windsurf import install_windsurf, uninstall_windsurf
18
21
 
19
22
  __all__ = [
20
23
  # Shared
@@ -30,6 +33,15 @@ __all__ = [
30
33
  # Codex
31
34
  "install_codex_notify",
32
35
  "uninstall_codex_notify",
36
+ # Cursor
37
+ "install_cursor",
38
+ "uninstall_cursor",
39
+ # Windsurf
40
+ "install_windsurf",
41
+ "uninstall_windsurf",
42
+ # Copilot
43
+ "install_copilot",
44
+ "uninstall_copilot",
33
45
  # Git Hooks
34
46
  "install_git_hooks",
35
47
  # Antigravity
@@ -0,0 +1,242 @@
1
+ """
2
+ GitHub Copilot CLI installation for Gobby hooks.
3
+
4
+ This module handles installing and uninstalling Gobby hooks for Copilot CLI.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import os
10
+ import tempfile
11
+ import time
12
+ from pathlib import Path
13
+ from shutil import copy2
14
+ from typing import Any
15
+
16
+ from gobby.cli.utils import get_install_dir
17
+
18
+ from .shared import install_shared_content
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def install_copilot(project_path: Path) -> dict[str, Any]:
24
+ """Install Gobby integration for Copilot CLI (hooks, workflows).
25
+
26
+ Args:
27
+ project_path: Path to the project root
28
+
29
+ Returns:
30
+ Dict with installation results including success status and installed items
31
+ """
32
+ hooks_installed: list[str] = []
33
+ result: dict[str, Any] = {
34
+ "success": False,
35
+ "hooks_installed": hooks_installed,
36
+ "workflows_installed": [],
37
+ "error": None,
38
+ }
39
+
40
+ copilot_path = project_path / ".copilot"
41
+ hooks_file = copilot_path / "hooks.json"
42
+
43
+ # Ensure .copilot subdirectories exist
44
+ copilot_path.mkdir(parents=True, exist_ok=True)
45
+ hooks_dir = copilot_path / "hooks"
46
+ hooks_dir.mkdir(parents=True, exist_ok=True)
47
+
48
+ # Get source files
49
+ install_dir = get_install_dir()
50
+ copilot_install_dir = install_dir / "copilot"
51
+ install_hooks_dir = copilot_install_dir / "hooks"
52
+
53
+ # Hook files to copy
54
+ hook_files = {
55
+ "hook_dispatcher.py": True, # Make executable
56
+ }
57
+
58
+ source_hooks_template = copilot_install_dir / "hooks-template.json"
59
+
60
+ # Verify all source files exist
61
+ missing_files = []
62
+ for filename in hook_files.keys():
63
+ source_file = install_hooks_dir / filename
64
+ if not source_file.exists():
65
+ missing_files.append(str(source_file))
66
+
67
+ if not source_hooks_template.exists():
68
+ missing_files.append(str(source_hooks_template))
69
+
70
+ if missing_files:
71
+ result["error"] = f"Missing source files: {missing_files}"
72
+ return result
73
+
74
+ # Copy hook files
75
+ try:
76
+ for filename, make_executable in hook_files.items():
77
+ source_file = install_hooks_dir / filename
78
+ target_file = hooks_dir / filename
79
+
80
+ if target_file.exists():
81
+ target_file.unlink()
82
+
83
+ copy2(source_file, target_file)
84
+ if make_executable:
85
+ target_file.chmod(0o755)
86
+ except OSError as e:
87
+ logger.error(f"Failed to copy hook files: {e}")
88
+ result["error"] = f"Failed to copy hook files: {e}"
89
+ return result
90
+
91
+ # Install shared content (workflows) to .gobby/
92
+ try:
93
+ gobby_path = project_path / ".gobby"
94
+ shared = install_shared_content(gobby_path, project_path)
95
+ result["workflows_installed"] = shared.get("workflows", [])
96
+ except Exception as e:
97
+ logger.warning(f"Failed to install shared content: {e}")
98
+ # Non-fatal - continue with hooks installation
99
+
100
+ # Backup existing hooks.json if it exists
101
+ backup_file = None
102
+ if hooks_file.exists():
103
+ timestamp = int(time.time())
104
+ backup_file = copilot_path / f"hooks.json.{timestamp}.backup"
105
+ try:
106
+ copy2(hooks_file, backup_file)
107
+ except OSError as e:
108
+ logger.error(f"Failed to create backup of hooks.json: {e}")
109
+ result["error"] = f"Failed to create backup: {e}"
110
+ return result
111
+
112
+ # Load existing hooks or create empty
113
+ existing_hooks: dict[str, Any] = {"hooks": {}}
114
+ if hooks_file.exists():
115
+ try:
116
+ with open(hooks_file) as f:
117
+ existing_hooks = json.load(f)
118
+ except json.JSONDecodeError as e:
119
+ logger.warning(f"Failed to parse hooks.json, starting fresh: {e}")
120
+ except OSError as e:
121
+ logger.error(f"Failed to read hooks.json: {e}")
122
+ result["error"] = f"Failed to read hooks.json: {e}"
123
+ return result
124
+
125
+ # Ensure structure
126
+ if "hooks" not in existing_hooks:
127
+ existing_hooks["hooks"] = {}
128
+
129
+ # Load Gobby hooks from template
130
+ try:
131
+ with open(source_hooks_template) as f:
132
+ gobby_hooks_str = f.read()
133
+ except OSError as e:
134
+ logger.error(f"Failed to read hooks template: {e}")
135
+ result["error"] = f"Failed to read hooks template: {e}"
136
+ return result
137
+
138
+ # Replace $PROJECT_PATH with absolute project path
139
+ abs_project_path = str(project_path.resolve())
140
+ gobby_hooks_str = gobby_hooks_str.replace("$PROJECT_PATH", abs_project_path)
141
+
142
+ try:
143
+ gobby_hooks = json.loads(gobby_hooks_str)
144
+ except json.JSONDecodeError as e:
145
+ logger.error(f"Failed to parse hooks template: {e}")
146
+ result["error"] = f"Failed to parse hooks template: {e}"
147
+ return result
148
+
149
+ # Merge Gobby hooks
150
+ new_hooks = gobby_hooks.get("hooks", {})
151
+ for hook_type, hook_config in new_hooks.items():
152
+ existing_hooks["hooks"][hook_type] = hook_config
153
+ hooks_installed.append(hook_type)
154
+
155
+ # Write merged hooks back using atomic write
156
+ try:
157
+ fd, temp_path = tempfile.mkstemp(dir=str(copilot_path), suffix=".tmp", prefix="hooks_")
158
+ try:
159
+ with os.fdopen(fd, "w") as f:
160
+ json.dump(existing_hooks, f, indent=2)
161
+ f.flush()
162
+ os.fsync(f.fileno())
163
+ # Atomic replace
164
+ os.replace(temp_path, hooks_file)
165
+ except Exception:
166
+ if os.path.exists(temp_path):
167
+ os.unlink(temp_path)
168
+ raise
169
+ except OSError as e:
170
+ logger.error(f"Failed to write hooks.json: {e}")
171
+ if backup_file and backup_file.exists():
172
+ try:
173
+ copy2(backup_file, hooks_file)
174
+ logger.info("Restored hooks.json from backup after write failure")
175
+ except OSError as restore_error:
176
+ logger.error(f"Failed to restore from backup: {restore_error}")
177
+ result["error"] = f"Failed to write hooks.json: {e}"
178
+ return result
179
+
180
+ result["success"] = True
181
+ return result
182
+
183
+
184
+ def uninstall_copilot(project_path: Path) -> dict[str, Any]:
185
+ """Uninstall Gobby integration from Copilot CLI.
186
+
187
+ Args:
188
+ project_path: Path to the project root
189
+
190
+ Returns:
191
+ Dict with uninstallation results
192
+ """
193
+ result: dict[str, Any] = {
194
+ "success": False,
195
+ "hooks_removed": [],
196
+ "files_removed": [],
197
+ "error": None,
198
+ }
199
+
200
+ copilot_path = project_path / ".copilot"
201
+ hooks_file = copilot_path / "hooks.json"
202
+ hooks_dir = copilot_path / "hooks"
203
+
204
+ # Remove hook dispatcher
205
+ dispatcher = hooks_dir / "hook_dispatcher.py"
206
+ if dispatcher.exists():
207
+ try:
208
+ dispatcher.unlink()
209
+ result["files_removed"].append(str(dispatcher))
210
+ except OSError as e:
211
+ logger.warning(f"Failed to remove {dispatcher}: {e}")
212
+
213
+ # Remove hooks from hooks.json
214
+ if hooks_file.exists():
215
+ try:
216
+ with open(hooks_file) as f:
217
+ hooks_config = json.load(f)
218
+
219
+ # Remove all Gobby hooks (those that reference hook_dispatcher.py)
220
+ if "hooks" in hooks_config:
221
+ hooks_to_remove = []
222
+ for hook_type, hook_list in hooks_config["hooks"].items():
223
+ if isinstance(hook_list, list):
224
+ for hook in hook_list:
225
+ cmd = hook.get("command", "")
226
+ if "hook_dispatcher.py" in cmd:
227
+ hooks_to_remove.append(hook_type)
228
+ break
229
+
230
+ for hook_type in hooks_to_remove:
231
+ del hooks_config["hooks"][hook_type]
232
+ result["hooks_removed"].append(hook_type)
233
+
234
+ # Write back
235
+ with open(hooks_file, "w") as f:
236
+ json.dump(hooks_config, f, indent=2)
237
+
238
+ except (json.JSONDecodeError, OSError) as e:
239
+ logger.warning(f"Failed to update hooks.json: {e}")
240
+
241
+ result["success"] = True
242
+ return result