gobby 0.2.8__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 (168) 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 +5 -28
  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 +64 -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/utils.py +5 -17
  35. gobby/cli/workflows.py +38 -17
  36. gobby/config/app.py +5 -0
  37. gobby/config/features.py +0 -20
  38. gobby/config/skills.py +23 -2
  39. gobby/config/tasks.py +4 -0
  40. gobby/hooks/broadcaster.py +9 -0
  41. gobby/hooks/event_handlers/__init__.py +155 -0
  42. gobby/hooks/event_handlers/_agent.py +175 -0
  43. gobby/hooks/event_handlers/_base.py +92 -0
  44. gobby/hooks/event_handlers/_misc.py +66 -0
  45. gobby/hooks/event_handlers/_session.py +487 -0
  46. gobby/hooks/event_handlers/_tool.py +196 -0
  47. gobby/hooks/events.py +48 -0
  48. gobby/hooks/hook_manager.py +27 -3
  49. gobby/install/copilot/hooks/hook_dispatcher.py +203 -0
  50. gobby/install/cursor/hooks/hook_dispatcher.py +203 -0
  51. gobby/install/gemini/hooks/hook_dispatcher.py +8 -0
  52. gobby/install/windsurf/hooks/hook_dispatcher.py +205 -0
  53. gobby/llm/__init__.py +14 -1
  54. gobby/llm/claude.py +594 -43
  55. gobby/llm/service.py +149 -0
  56. gobby/mcp_proxy/importer.py +4 -41
  57. gobby/mcp_proxy/instructions.py +9 -27
  58. gobby/mcp_proxy/manager.py +13 -3
  59. gobby/mcp_proxy/models.py +1 -0
  60. gobby/mcp_proxy/registries.py +66 -5
  61. gobby/mcp_proxy/server.py +6 -2
  62. gobby/mcp_proxy/services/recommendation.py +2 -28
  63. gobby/mcp_proxy/services/tool_filter.py +7 -0
  64. gobby/mcp_proxy/services/tool_proxy.py +19 -1
  65. gobby/mcp_proxy/stdio.py +37 -21
  66. gobby/mcp_proxy/tools/agents.py +7 -0
  67. gobby/mcp_proxy/tools/artifacts.py +3 -3
  68. gobby/mcp_proxy/tools/hub.py +30 -1
  69. gobby/mcp_proxy/tools/orchestration/cleanup.py +5 -5
  70. gobby/mcp_proxy/tools/orchestration/monitor.py +1 -1
  71. gobby/mcp_proxy/tools/orchestration/orchestrate.py +8 -3
  72. gobby/mcp_proxy/tools/orchestration/review.py +17 -4
  73. gobby/mcp_proxy/tools/orchestration/wait.py +7 -7
  74. gobby/mcp_proxy/tools/pipelines/__init__.py +254 -0
  75. gobby/mcp_proxy/tools/pipelines/_discovery.py +67 -0
  76. gobby/mcp_proxy/tools/pipelines/_execution.py +281 -0
  77. gobby/mcp_proxy/tools/sessions/_crud.py +4 -4
  78. gobby/mcp_proxy/tools/sessions/_handoff.py +1 -1
  79. gobby/mcp_proxy/tools/skills/__init__.py +184 -30
  80. gobby/mcp_proxy/tools/spawn_agent.py +229 -14
  81. gobby/mcp_proxy/tools/task_readiness.py +27 -4
  82. gobby/mcp_proxy/tools/tasks/_context.py +8 -0
  83. gobby/mcp_proxy/tools/tasks/_crud.py +27 -1
  84. gobby/mcp_proxy/tools/tasks/_helpers.py +1 -1
  85. gobby/mcp_proxy/tools/tasks/_lifecycle.py +125 -8
  86. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +2 -1
  87. gobby/mcp_proxy/tools/tasks/_search.py +1 -1
  88. gobby/mcp_proxy/tools/workflows/__init__.py +273 -0
  89. gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
  90. gobby/mcp_proxy/tools/workflows/_import.py +112 -0
  91. gobby/mcp_proxy/tools/workflows/_lifecycle.py +332 -0
  92. gobby/mcp_proxy/tools/workflows/_query.py +226 -0
  93. gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
  94. gobby/mcp_proxy/tools/workflows/_terminal.py +175 -0
  95. gobby/mcp_proxy/tools/worktrees.py +54 -15
  96. gobby/memory/components/__init__.py +0 -0
  97. gobby/memory/components/ingestion.py +98 -0
  98. gobby/memory/components/search.py +108 -0
  99. gobby/memory/context.py +5 -5
  100. gobby/memory/manager.py +16 -25
  101. gobby/paths.py +51 -0
  102. gobby/prompts/loader.py +1 -35
  103. gobby/runner.py +131 -16
  104. gobby/servers/http.py +193 -150
  105. gobby/servers/routes/__init__.py +2 -0
  106. gobby/servers/routes/admin.py +56 -0
  107. gobby/servers/routes/mcp/endpoints/execution.py +33 -32
  108. gobby/servers/routes/mcp/endpoints/registry.py +8 -8
  109. gobby/servers/routes/mcp/hooks.py +10 -1
  110. gobby/servers/routes/pipelines.py +227 -0
  111. gobby/servers/websocket.py +314 -1
  112. gobby/sessions/analyzer.py +89 -3
  113. gobby/sessions/manager.py +5 -5
  114. gobby/sessions/transcripts/__init__.py +3 -0
  115. gobby/sessions/transcripts/claude.py +5 -0
  116. gobby/sessions/transcripts/codex.py +5 -0
  117. gobby/sessions/transcripts/gemini.py +5 -0
  118. gobby/skills/hubs/__init__.py +25 -0
  119. gobby/skills/hubs/base.py +234 -0
  120. gobby/skills/hubs/claude_plugins.py +328 -0
  121. gobby/skills/hubs/clawdhub.py +289 -0
  122. gobby/skills/hubs/github_collection.py +465 -0
  123. gobby/skills/hubs/manager.py +263 -0
  124. gobby/skills/hubs/skillhub.py +342 -0
  125. gobby/skills/parser.py +23 -0
  126. gobby/skills/sync.py +5 -4
  127. gobby/storage/artifacts.py +19 -0
  128. gobby/storage/memories.py +4 -4
  129. gobby/storage/migrations.py +118 -3
  130. gobby/storage/pipelines.py +367 -0
  131. gobby/storage/sessions.py +23 -4
  132. gobby/storage/skills.py +48 -8
  133. gobby/storage/tasks/_aggregates.py +2 -2
  134. gobby/storage/tasks/_lifecycle.py +4 -4
  135. gobby/storage/tasks/_models.py +7 -1
  136. gobby/storage/tasks/_queries.py +3 -3
  137. gobby/sync/memories.py +4 -3
  138. gobby/tasks/commits.py +48 -17
  139. gobby/tasks/external_validator.py +4 -17
  140. gobby/tasks/validation.py +13 -87
  141. gobby/tools/summarizer.py +18 -51
  142. gobby/utils/status.py +13 -0
  143. gobby/workflows/actions.py +80 -0
  144. gobby/workflows/context_actions.py +265 -27
  145. gobby/workflows/definitions.py +119 -1
  146. gobby/workflows/detection_helpers.py +23 -11
  147. gobby/workflows/enforcement/__init__.py +11 -1
  148. gobby/workflows/enforcement/blocking.py +96 -0
  149. gobby/workflows/enforcement/handlers.py +35 -1
  150. gobby/workflows/enforcement/task_policy.py +18 -0
  151. gobby/workflows/engine.py +26 -4
  152. gobby/workflows/evaluator.py +8 -5
  153. gobby/workflows/lifecycle_evaluator.py +59 -27
  154. gobby/workflows/loader.py +567 -30
  155. gobby/workflows/lobster_compat.py +147 -0
  156. gobby/workflows/pipeline_executor.py +801 -0
  157. gobby/workflows/pipeline_state.py +172 -0
  158. gobby/workflows/pipeline_webhooks.py +206 -0
  159. gobby/workflows/premature_stop.py +5 -0
  160. gobby/worktrees/git.py +135 -20
  161. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/METADATA +56 -22
  162. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/RECORD +166 -122
  163. gobby/hooks/event_handlers.py +0 -1008
  164. gobby/mcp_proxy/tools/workflows.py +0 -1023
  165. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/WHEEL +0 -0
  166. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/entry_points.txt +0 -0
  167. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/licenses/LICENSE.md +0 -0
  168. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/top_level.txt +0 -0
gobby/cli/skills.py CHANGED
@@ -239,11 +239,15 @@ def install(ctx: click.Context, source: str, project: bool) -> None:
239
239
  """Install a skill from a source.
240
240
 
241
241
  SOURCE can be:
242
+ - A hub reference (e.g., clawdhub:commit-message, skillhub:code-review)
242
243
  - A local directory path (e.g., ./my-skill or /path/to/skill)
243
244
  - A path to a SKILL.md file (e.g., ./SKILL.md)
244
245
  - A GitHub URL (owner/repo, github:owner/repo, https://github.com/owner/repo)
245
246
  - A ZIP archive path (e.g., ./skills.zip)
246
247
 
248
+ Use 'gobby skills hub list' to see available hubs.
249
+ Use 'gobby skills search <query>' to find skills.
250
+
247
251
  Use --project to scope the skill to the current project.
248
252
 
249
253
  Requires daemon to be running.
@@ -856,3 +860,208 @@ def disable(ctx: click.Context, name: str) -> None:
856
860
  click.echo(f"Error disabling skill: {e}", err=True)
857
861
  sys.exit(1)
858
862
  click.echo(f"Disabled skill: {name}")
863
+
864
+
865
+ @skills.command()
866
+ @click.argument("query")
867
+ @click.option("--hub", "-h", "hub_name", default=None, help="Search only in specific hub")
868
+ @click.option("--limit", "-n", default=20, help="Maximum results to show")
869
+ @click.option("--json", "json_output", is_flag=True, help="Output as JSON")
870
+ @click.pass_context
871
+ def search(
872
+ ctx: click.Context,
873
+ query: str,
874
+ hub_name: str | None,
875
+ limit: int,
876
+ json_output: bool,
877
+ ) -> None:
878
+ """Search for skills across configured hubs.
879
+
880
+ QUERY is the search term (e.g., 'commit message', 'code review').
881
+
882
+ Use --hub to search only in a specific hub.
883
+
884
+ Requires daemon to be running.
885
+ """
886
+ client = get_daemon_client(ctx)
887
+ if not check_daemon(client):
888
+ sys.exit(1)
889
+
890
+ arguments: dict[str, Any] = {"query": query, "limit": limit}
891
+ if hub_name:
892
+ arguments["hub_name"] = hub_name
893
+
894
+ result = call_skills_tool(client, "search_hub", arguments)
895
+
896
+ if result is None:
897
+ click.echo("Error: Failed to communicate with daemon", err=True)
898
+ sys.exit(1)
899
+ elif not result.get("success"):
900
+ click.echo(f"Error: {result.get('error', 'Unknown error')}", err=True)
901
+ sys.exit(1)
902
+
903
+ results_list = result.get("results", [])
904
+
905
+ if json_output:
906
+ click.echo(json.dumps(results_list, indent=2))
907
+ return
908
+
909
+ if not results_list:
910
+ click.echo("No skills found matching your query.")
911
+ return
912
+
913
+ click.echo(f"Found {len(results_list)} skill(s):\n")
914
+ for skill in results_list:
915
+ hub = skill.get("hub_name", "unknown")
916
+ slug = skill.get("slug", "unknown")
917
+ name = skill.get("display_name", slug)
918
+ desc = skill.get("description", "")[:60]
919
+ click.echo(f" [{hub}] {name}")
920
+ if desc:
921
+ click.echo(f" {desc}")
922
+ click.echo(f" Install: gobby skills install {hub}:{slug}")
923
+ click.echo("")
924
+
925
+
926
+ # Hub subcommand group
927
+ @skills.group()
928
+ def hub() -> None:
929
+ """Manage skill hubs (registries)."""
930
+ pass
931
+
932
+
933
+ @hub.command("list")
934
+ @click.option("--json", "json_output", is_flag=True, help="Output as JSON")
935
+ @click.pass_context
936
+ def hub_list(ctx: click.Context, json_output: bool) -> None:
937
+ """List configured skill hubs.
938
+
939
+ Shows all configured skill hubs with their type and status.
940
+
941
+ Requires daemon to be running.
942
+ """
943
+ client = get_daemon_client(ctx)
944
+ if not check_daemon(client):
945
+ sys.exit(1)
946
+
947
+ result = call_skills_tool(client, "list_hubs", {})
948
+
949
+ if result is None:
950
+ click.echo("Error: Failed to communicate with daemon", err=True)
951
+ sys.exit(1)
952
+ elif not result.get("success"):
953
+ click.echo(f"Error: {result.get('error', 'Unknown error')}", err=True)
954
+ sys.exit(1)
955
+
956
+ hubs_list = result.get("hubs", [])
957
+
958
+ if json_output:
959
+ click.echo(json.dumps(hubs_list, indent=2))
960
+ return
961
+
962
+ if not hubs_list:
963
+ click.echo("No hubs configured.")
964
+ click.echo("\nTo add hubs, update your config.yaml with a 'hubs' section.")
965
+ return
966
+
967
+ click.echo("Configured hubs:\n")
968
+ for h in hubs_list:
969
+ name = h.get("name", "unknown")
970
+ hub_type = h.get("type", "unknown")
971
+ base_url = h.get("base_url", "")
972
+ url_str = f" ({base_url})" if base_url else ""
973
+ click.echo(f" {name} [{hub_type}]{url_str}")
974
+
975
+
976
+ @hub.command("add")
977
+ @click.argument("name")
978
+ @click.option("--type", "hub_type", required=True, help="Hub type (clawdhub, skillhub, github)")
979
+ @click.option("--url", "base_url", default=None, help="Base URL for skillhub type")
980
+ @click.option("--repo", default=None, help="GitHub repo (owner/repo) for github type")
981
+ @click.option("--branch", default=None, help="Branch for github type (default: main)")
982
+ @click.option(
983
+ "--auth-key", "auth_key_name", default=None, help="Environment variable name for auth key"
984
+ )
985
+ @click.pass_context
986
+ def hub_add(
987
+ ctx: click.Context,
988
+ name: str,
989
+ hub_type: str,
990
+ base_url: str | None,
991
+ repo: str | None,
992
+ branch: str | None,
993
+ auth_key_name: str | None,
994
+ ) -> None:
995
+ """Add a new skill hub.
996
+
997
+ NAME is the hub name (e.g., 'my-skills', 'company-hub').
998
+
999
+ Hub types:
1000
+ - clawdhub: ClawdHub CLI-based hub
1001
+ - skillhub: REST API-based skill hub (requires --url)
1002
+ - github: GitHub repository collection (requires --repo)
1003
+
1004
+ Examples:
1005
+ gobby skills hub add my-skillhub --type skillhub --url https://skillhub.example.com
1006
+ gobby skills hub add company-skills --type github --repo myorg/skills
1007
+ """
1008
+ import yaml
1009
+
1010
+ # Validate hub type
1011
+ valid_types = ["clawdhub", "skillhub", "github"]
1012
+ if hub_type not in valid_types:
1013
+ click.echo(
1014
+ f"Error: Invalid hub type '{hub_type}'. Must be one of: {', '.join(valid_types)}",
1015
+ err=True,
1016
+ )
1017
+ sys.exit(1)
1018
+
1019
+ # Validate required options for each type
1020
+ if hub_type == "skillhub" and not base_url:
1021
+ click.echo("Error: --url is required for skillhub type", err=True)
1022
+ sys.exit(1)
1023
+
1024
+ if hub_type == "github" and not repo:
1025
+ click.echo("Error: --repo is required for github type", err=True)
1026
+ sys.exit(1)
1027
+
1028
+ # Build hub config
1029
+ hub_config: dict[str, Any] = {"type": hub_type}
1030
+ if base_url:
1031
+ hub_config["base_url"] = base_url
1032
+ if repo:
1033
+ hub_config["repo"] = repo
1034
+ if branch:
1035
+ hub_config["branch"] = branch
1036
+ if auth_key_name:
1037
+ hub_config["auth_key_name"] = auth_key_name
1038
+
1039
+ # Load existing config
1040
+ config_path = Path.home() / ".gobby" / "config.yaml"
1041
+ if config_path.exists():
1042
+ with open(config_path, encoding="utf-8") as f:
1043
+ config = yaml.safe_load(f) or {}
1044
+ else:
1045
+ config = {}
1046
+
1047
+ # Ensure hubs section exists
1048
+ if "hubs" not in config:
1049
+ config["hubs"] = {}
1050
+
1051
+ # Check if hub already exists
1052
+ if name in config["hubs"]:
1053
+ click.echo(
1054
+ f"Error: Hub '{name}' already exists. Use 'hub remove' first to replace it.", err=True
1055
+ )
1056
+ sys.exit(1)
1057
+
1058
+ # Add the hub
1059
+ config["hubs"][name] = hub_config
1060
+
1061
+ # Write config
1062
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1063
+ with open(config_path, "w", encoding="utf-8") as f:
1064
+ yaml.dump(config, f, default_flow_style=False)
1065
+
1066
+ click.echo(f"Added hub: {name} [{hub_type}]")
1067
+ click.echo("\nRestart the daemon for changes to take effect: gobby restart")
gobby/cli/tasks/crud.py CHANGED
@@ -26,7 +26,7 @@ from gobby.utils.project_context import get_project_context
26
26
  @click.option(
27
27
  "--status",
28
28
  "-s",
29
- help="Filter by status (open, in_progress, review, closed, blocked). Comma-separated for multiple.",
29
+ help="Filter by status (open, in_progress, needs_review, closed, blocked). Comma-separated for multiple.",
30
30
  )
31
31
  @click.option(
32
32
  "--active",
@@ -266,7 +266,7 @@ def task_stats(project_ref: str | None, json_format: bool) -> None:
266
266
  # Get counts by status
267
267
  all_tasks = manager.list_tasks(project_id=project_id, limit=10000)
268
268
  total = len(all_tasks)
269
- by_status = {"open": 0, "in_progress": 0, "review": 0, "closed": 0}
269
+ by_status = {"open": 0, "in_progress": 0, "needs_review": 0, "closed": 0}
270
270
  by_priority = {1: 0, 2: 0, 3: 0}
271
271
  by_type: dict[str, int] = {}
272
272
 
@@ -302,7 +302,7 @@ def task_stats(project_ref: str | None, json_format: bool) -> None:
302
302
  click.echo(f" Total: {total}")
303
303
  click.echo(f" Open: {by_status.get('open', 0)}")
304
304
  click.echo(f" In Progress: {by_status.get('in_progress', 0)}")
305
- click.echo(f" Review: {by_status.get('review', 0)}")
305
+ click.echo(f" Needs Review: {by_status.get('needs_review', 0)}")
306
306
  click.echo(f" Closed: {by_status.get('closed', 0)}")
307
307
  click.echo(f"\n Ready (no blockers): {ready_count}")
308
308
  click.echo(f" Blocked: {blocked_count}")
@@ -547,9 +547,10 @@ def reopen_task_cmd(task_id: str, reason: str | None) -> None:
547
547
  # Use standardized ref for errors
548
548
  resolved_ref = f"#{resolved.seq_num}" if resolved.seq_num else resolved.id[:8]
549
549
 
550
- if resolved.status not in ("closed", "review"):
550
+ if resolved.status not in ("closed", "needs_review"):
551
551
  click.echo(
552
- f"Task {resolved_ref} is not closed or in review (status: {resolved.status})", err=True
552
+ f"Task {resolved_ref} is not closed or in needs_review (status: {resolved.status})",
553
+ err=True,
553
554
  )
554
555
  return
555
556
 
gobby/cli/tasks/search.py CHANGED
@@ -18,7 +18,7 @@ from gobby.cli.utils import resolve_project_ref
18
18
  @click.option(
19
19
  "--status",
20
20
  "-s",
21
- help="Filter by status (open, in_progress, review, closed). Comma-separated for multiple.",
21
+ help="Filter by status (open, in_progress, needs_review, closed). Comma-separated for multiple.",
22
22
  )
23
23
  @click.option(
24
24
  "--type",
gobby/cli/ui.py ADDED
@@ -0,0 +1,116 @@
1
+ """
2
+ CLI commands for Gobby web UI development.
3
+ """
4
+
5
+ import subprocess # nosec B404 - subprocess needed for npm commands
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ # Path to web UI directory
12
+ WEB_UI_DIR = Path(__file__).parent.parent / "ui" / "web"
13
+
14
+
15
+ @click.group()
16
+ def ui() -> None:
17
+ """Web UI development commands."""
18
+ pass
19
+
20
+
21
+ @ui.command()
22
+ @click.option("--port", "-p", default=5173, help="Dev server port")
23
+ @click.option("--host", "-h", default="localhost", help="Dev server host")
24
+ def dev(port: int, host: str) -> None:
25
+ """Start the web UI development server with hot-reload."""
26
+ if not WEB_UI_DIR.exists():
27
+ click.echo(f"Error: Web UI directory not found at {WEB_UI_DIR}", err=True)
28
+ sys.exit(1)
29
+
30
+ package_json = WEB_UI_DIR / "package.json"
31
+ if not package_json.exists():
32
+ click.echo(f"Error: package.json not found at {package_json}", err=True)
33
+ sys.exit(1)
34
+
35
+ node_modules = WEB_UI_DIR / "node_modules"
36
+ if not node_modules.exists():
37
+ click.echo("Installing dependencies...")
38
+ result = subprocess.run( # nosec B603 B607
39
+ ["npm", "install"],
40
+ cwd=WEB_UI_DIR,
41
+ capture_output=False,
42
+ )
43
+ if result.returncode != 0:
44
+ click.echo("Failed to install dependencies", err=True)
45
+ sys.exit(1)
46
+
47
+ click.echo(f"Starting dev server at http://{host}:{port}")
48
+ click.echo("Press Ctrl+C to stop")
49
+ click.echo()
50
+
51
+ try:
52
+ subprocess.run( # nosec B603 B607
53
+ ["npm", "run", "dev", "--", "--host", host, "--port", str(port)],
54
+ cwd=WEB_UI_DIR,
55
+ check=True,
56
+ )
57
+ except KeyboardInterrupt:
58
+ click.echo("\nDev server stopped")
59
+ except subprocess.CalledProcessError as e:
60
+ click.echo(f"Dev server failed with code {e.returncode}", err=True)
61
+ sys.exit(e.returncode)
62
+
63
+
64
+ @ui.command()
65
+ def build() -> None:
66
+ """Build the web UI for production."""
67
+ if not WEB_UI_DIR.exists():
68
+ click.echo(f"Error: Web UI directory not found at {WEB_UI_DIR}", err=True)
69
+ sys.exit(1)
70
+
71
+ node_modules = WEB_UI_DIR / "node_modules"
72
+ if not node_modules.exists():
73
+ click.echo("Installing dependencies...")
74
+ result = subprocess.run( # nosec B603 B607
75
+ ["npm", "install"],
76
+ cwd=WEB_UI_DIR,
77
+ capture_output=False,
78
+ )
79
+ if result.returncode != 0:
80
+ click.echo("Failed to install dependencies", err=True)
81
+ sys.exit(1)
82
+
83
+ click.echo("Building web UI...")
84
+ result = subprocess.run( # nosec B603 B607
85
+ ["npm", "run", "build"],
86
+ cwd=WEB_UI_DIR,
87
+ capture_output=False,
88
+ )
89
+
90
+ if result.returncode == 0:
91
+ dist_dir = WEB_UI_DIR / "dist"
92
+ click.echo(f"Build complete: {dist_dir}")
93
+ else:
94
+ click.echo("Build failed", err=True)
95
+ sys.exit(result.returncode)
96
+
97
+
98
+ @ui.command()
99
+ def install_deps() -> None:
100
+ """Install web UI dependencies."""
101
+ if not WEB_UI_DIR.exists():
102
+ click.echo(f"Error: Web UI directory not found at {WEB_UI_DIR}", err=True)
103
+ sys.exit(1)
104
+
105
+ click.echo("Installing dependencies...")
106
+ result = subprocess.run( # nosec B603 B607
107
+ ["npm", "install"],
108
+ cwd=WEB_UI_DIR,
109
+ capture_output=False,
110
+ )
111
+
112
+ if result.returncode == 0:
113
+ click.echo("Dependencies installed")
114
+ else:
115
+ click.echo("Failed to install dependencies", err=True)
116
+ sys.exit(result.returncode)
gobby/cli/utils.py CHANGED
@@ -394,23 +394,10 @@ def get_install_dir() -> Path:
394
394
  Returns:
395
395
  Path to the install directory
396
396
  """
397
- import gobby
397
+ # Import from centralized paths module to avoid duplication
398
+ from gobby.paths import get_install_dir as _get_install_dir
398
399
 
399
- package_install_dir = Path(gobby.__file__).parent / "install"
400
-
401
- # Try to find source directory (project root)
402
- current = Path(gobby.__file__).resolve()
403
- source_install_dir = None
404
-
405
- for parent in current.parents:
406
- potential_source = parent / "src" / "gobby" / "install"
407
- if potential_source.exists():
408
- source_install_dir = potential_source
409
- break
410
-
411
- if source_install_dir and source_install_dir.exists():
412
- return source_install_dir
413
- return package_install_dir
400
+ return _get_install_dir()
414
401
 
415
402
 
416
403
  def _is_process_alive(pid: int) -> bool:
@@ -472,7 +459,8 @@ def stop_daemon(quiet: bool = False) -> bool:
472
459
  click.echo(f"Sent shutdown signal to Gobby daemon (PID {pid})")
473
460
 
474
461
  # Wait for graceful shutdown
475
- max_wait = 5
462
+ # Match daemon's uvicorn timeout_graceful_shutdown (15s) + buffer
463
+ max_wait = 20
476
464
  for _ in range(max_wait * 10):
477
465
  time.sleep(0.1)
478
466
  if not _is_process_alive(pid):
gobby/cli/workflows.py CHANGED
@@ -11,6 +11,7 @@ import yaml
11
11
 
12
12
  from gobby.cli.utils import resolve_session_id
13
13
  from gobby.storage.database import LocalDatabase
14
+ from gobby.workflows.definitions import WorkflowDefinition
14
15
  from gobby.workflows.loader import WorkflowLoader
15
16
  from gobby.workflows.state_manager import WorkflowStateManager
16
17
 
@@ -151,23 +152,34 @@ def show_workflow(ctx: click.Context, name: str, json_format: bool) -> None:
151
152
 
152
153
  if definition.steps:
153
154
  click.echo(f"\nSteps ({len(definition.steps)}):")
154
- for step in definition.steps:
155
- click.echo(f" - {step.name}")
156
- if step.description:
157
- click.echo(f" {step.description}")
158
- if step.allowed_tools:
159
- if step.allowed_tools == "all":
160
- click.echo(" Allowed tools: all")
161
- else:
162
- tools = step.allowed_tools[:5]
163
- more = (
164
- f" (+{len(step.allowed_tools) - 5})" if len(step.allowed_tools) > 5 else ""
165
- )
166
- click.echo(f" Allowed tools: {', '.join(tools)}{more}")
167
- if step.blocked_tools:
168
- click.echo(f" Blocked tools: {', '.join(step.blocked_tools[:5])}")
169
-
170
- if definition.triggers:
155
+ if isinstance(definition, WorkflowDefinition):
156
+ for step in definition.steps:
157
+ click.echo(f" - {step.name}")
158
+ if step.description:
159
+ click.echo(f" {step.description}")
160
+ if step.allowed_tools:
161
+ if step.allowed_tools == "all":
162
+ click.echo(" Allowed tools: all")
163
+ else:
164
+ tools = step.allowed_tools[:5]
165
+ more = (
166
+ f" (+{len(step.allowed_tools) - 5})"
167
+ if len(step.allowed_tools) > 5
168
+ else ""
169
+ )
170
+ click.echo(f" Allowed tools: {', '.join(tools)}{more}")
171
+ if step.blocked_tools:
172
+ click.echo(f" Blocked tools: {', '.join(step.blocked_tools[:5])}")
173
+ else:
174
+ # PipelineDefinition
175
+ for pstep in definition.steps:
176
+ click.echo(f" - {pstep.id}")
177
+ if pstep.exec:
178
+ click.echo(f" exec: {pstep.exec[:60]}...")
179
+ elif pstep.prompt:
180
+ click.echo(f" prompt: {pstep.prompt[:60]}...")
181
+
182
+ if isinstance(definition, WorkflowDefinition) and definition.triggers:
171
183
  click.echo("\nTriggers:")
172
184
  for trigger_name, actions in definition.triggers.items():
173
185
  click.echo(f" {trigger_name}: {len(actions)} action(s)")
@@ -271,6 +283,11 @@ def set_workflow(
271
283
  click.echo("Use 'gobby workflows set' only for step-based workflows.", err=True)
272
284
  raise SystemExit(1)
273
285
 
286
+ if not isinstance(definition, WorkflowDefinition):
287
+ click.echo(f"'{name}' is a pipeline, not a step-based workflow.", err=True)
288
+ click.echo("Use 'gobby pipelines run' for pipelines.", err=True)
289
+ raise SystemExit(1)
290
+
274
291
  # Get session
275
292
  try:
276
293
  session_id = resolve_session_id(session_id)
@@ -374,6 +391,10 @@ def set_step(ctx: click.Context, step_name: str, session_id: str | None, force:
374
391
  click.echo(f"Workflow '{state.workflow_name}' not found.", err=True)
375
392
  raise SystemExit(1)
376
393
 
394
+ if not isinstance(definition, WorkflowDefinition):
395
+ click.echo(f"'{state.workflow_name}' is a pipeline, not a step-based workflow.", err=True)
396
+ raise SystemExit(1)
397
+
377
398
  if not any(s.name == step_name for s in definition.steps):
378
399
  click.echo(f"Step '{step_name}' not found in workflow.", err=True)
379
400
  click.echo(f"Available steps: {', '.join(s.name for s in definition.steps)}")
gobby/config/app.py CHANGED
@@ -141,6 +141,11 @@ class DaemonConfig(BaseModel):
141
141
  default=60887,
142
142
  description="Port for daemon to listen on",
143
143
  )
144
+ bind_host: str = Field(
145
+ default="localhost",
146
+ description="Host/IP to bind servers to. Use 'localhost' for local-only access, "
147
+ "'0.0.0.0' for all interfaces, or a specific IP (e.g., Tailscale IP) for restricted access.",
148
+ )
144
149
  daemon_health_check_interval: float = Field(
145
150
  default=10.0,
146
151
  description="Daemon health check interval in seconds",
gobby/config/features.py CHANGED
@@ -23,7 +23,6 @@ __all__ = [
23
23
  "HookStageConfig",
24
24
  "HooksConfig",
25
25
  "TaskDescriptionConfig",
26
- "DEFAULT_IMPORT_MCP_SERVER_PROMPT",
27
26
  ]
28
27
 
29
28
 
@@ -139,25 +138,6 @@ class RecommendToolsConfig(BaseModel):
139
138
  )
140
139
 
141
140
 
142
- DEFAULT_IMPORT_MCP_SERVER_PROMPT = """You are an MCP server configuration extractor. Given documentation for an MCP server, extract the configuration needed to connect to it.
143
-
144
- Return ONLY a valid JSON object (no markdown, no code blocks) with these fields:
145
- - name: Server name (lowercase, no spaces, use hyphens)
146
- - transport: "http", "stdio", or "websocket"
147
- - url: Server URL (required for http/websocket transports)
148
- - command: Command to run (required for stdio, e.g., "npx", "uv", "node")
149
- - args: Array of command arguments (for stdio)
150
- - env: Object of environment variables needed (use placeholder "<YOUR_KEY_NAME>" for secrets)
151
- - headers: Object of HTTP headers needed (use placeholder "<YOUR_KEY_NAME>" for secrets)
152
- - instructions: How to obtain any required API keys or setup steps
153
-
154
- Example stdio server:
155
- {"name": "filesystem", "transport": "stdio", "command": "npx", "args": ["-y", "@anthropic-ai/filesystem-mcp"], "env": {}, "instructions": "No setup required"}
156
-
157
- Example http server with API key:
158
- {"name": "exa", "transport": "http", "url": "https://mcp.exa.ai/mcp", "headers": {"EXA_API_KEY": "<YOUR_EXA_API_KEY>"}, "instructions": "Get your API key from https://exa.ai/dashboard"}"""
159
-
160
-
161
141
  class ImportMCPServerConfig(BaseModel):
162
142
  """MCP server import configuration."""
163
143
 
gobby/config/skills.py CHANGED
@@ -16,9 +16,9 @@ class HubConfig(BaseModel):
16
16
  Configuration for a skill hub or collection.
17
17
  """
18
18
 
19
- type: Literal["clawdhub", "skillhub", "github-collection"] = Field(
19
+ type: Literal["clawdhub", "skillhub", "github-collection", "claude-plugins"] = Field(
20
20
  ...,
21
- description="Type of the hub: 'clawdhub', 'skillhub', or 'github-collection'",
21
+ description="Type of the hub: 'clawdhub', 'skillhub', 'github-collection', or 'claude-plugins'",
22
22
  )
23
23
 
24
24
  base_url: str | None = Field(
@@ -36,6 +36,11 @@ class HubConfig(BaseModel):
36
36
  description="Git branch to use",
37
37
  )
38
38
 
39
+ path: str | None = Field(
40
+ default=None,
41
+ description="Subdirectory path within the repository where skills are located",
42
+ )
43
+
39
44
  auth_key_name: str | None = Field(
40
45
  default=None,
41
46
  description="Environment variable name for auth key",
@@ -64,6 +69,22 @@ class SkillsConfig(BaseModel):
64
69
  description="Format for skill injection: 'summary' (names only), 'full' (with content), 'none' (disabled)",
65
70
  )
66
71
 
72
+ hubs: dict[str, HubConfig] = Field(
73
+ default_factory=lambda: {
74
+ "anthropic-skills": HubConfig(
75
+ type="github-collection",
76
+ repo="anthropics/skills",
77
+ branch="main",
78
+ path="skills",
79
+ ),
80
+ "claude-plugins": HubConfig(
81
+ type="claude-plugins",
82
+ base_url="https://claude-plugins.dev",
83
+ ),
84
+ },
85
+ description="Configured skill hubs keyed by hub name",
86
+ )
87
+
67
88
  @field_validator("injection_format")
68
89
  @classmethod
69
90
  def validate_injection_format(cls, v: str) -> str:
gobby/config/tasks.py CHANGED
@@ -669,6 +669,10 @@ class WorkflowConfig(BaseModel):
669
669
  default_factory=lambda: ["Edit", "Write", "Update", "NotebookEdit"],
670
670
  description="Tools that require an active task when require_task_before_edit is enabled",
671
671
  )
672
+ debug_echo_context: bool = Field(
673
+ default=False,
674
+ description="Debug: echo additionalContext to system_message for terminal visibility",
675
+ )
672
676
 
673
677
  @field_validator("timeout")
674
678
  @classmethod
@@ -117,6 +117,15 @@ class HookEventBroadcaster:
117
117
  if "permission_type" not in raw_input:
118
118
  raw_input["permission_type"] = "unknown"
119
119
 
120
+ # Ensure 'tool_name' has a default for before_tool_selection events
121
+ # These events fire before a specific tool is selected, so tool_name is not available
122
+ if (
123
+ enum_hook_type == HookType.PRE_TOOL_USE
124
+ and event.event_type.value == "before_tool_selection"
125
+ ):
126
+ if "tool_name" not in raw_input:
127
+ raw_input["tool_name"] = "(tool_selection)"
128
+
120
129
  # Validate input data structure matches Pydantic model
121
130
  # Use construct/model_validate to avoid strict validation errors if possible,
122
131
  # or just try/except. Let's rely on standard validation.