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/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/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/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:
@@ -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.
@@ -43,7 +43,11 @@ class EventHandlersBase:
43
43
  _handler_map: dict[HookEventType, Callable[[HookEvent], HookResponse]]
44
44
 
45
45
  def _auto_activate_workflow(
46
- self, workflow_name: str, session_id: str, project_path: str | None
46
+ self,
47
+ workflow_name: str,
48
+ session_id: str,
49
+ project_path: str | None,
50
+ variables: dict[str, Any] | None = None,
47
51
  ) -> None:
48
52
  """Shared method for auto-activating workflows."""
49
53
  if not self._workflow_handler:
@@ -54,6 +58,7 @@ class EventHandlersBase:
54
58
  workflow_name=workflow_name,
55
59
  session_id=session_id,
56
60
  project_path=project_path,
61
+ variables=variables,
57
62
  )
58
63
  if result.get("success"):
59
64
  self.logger.info(