gobby 0.2.5__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.
- gobby/__init__.py +3 -0
- gobby/adapters/__init__.py +30 -0
- gobby/adapters/base.py +93 -0
- gobby/adapters/claude_code.py +276 -0
- gobby/adapters/codex.py +1292 -0
- gobby/adapters/gemini.py +343 -0
- gobby/agents/__init__.py +37 -0
- gobby/agents/codex_session.py +120 -0
- gobby/agents/constants.py +112 -0
- gobby/agents/context.py +362 -0
- gobby/agents/definitions.py +133 -0
- gobby/agents/gemini_session.py +111 -0
- gobby/agents/registry.py +618 -0
- gobby/agents/runner.py +968 -0
- gobby/agents/session.py +259 -0
- gobby/agents/spawn.py +916 -0
- gobby/agents/spawners/__init__.py +77 -0
- gobby/agents/spawners/base.py +142 -0
- gobby/agents/spawners/cross_platform.py +266 -0
- gobby/agents/spawners/embedded.py +225 -0
- gobby/agents/spawners/headless.py +226 -0
- gobby/agents/spawners/linux.py +125 -0
- gobby/agents/spawners/macos.py +277 -0
- gobby/agents/spawners/windows.py +308 -0
- gobby/agents/tty_config.py +319 -0
- gobby/autonomous/__init__.py +32 -0
- gobby/autonomous/progress_tracker.py +447 -0
- gobby/autonomous/stop_registry.py +269 -0
- gobby/autonomous/stuck_detector.py +383 -0
- gobby/cli/__init__.py +67 -0
- gobby/cli/__main__.py +8 -0
- gobby/cli/agents.py +529 -0
- gobby/cli/artifacts.py +266 -0
- gobby/cli/daemon.py +329 -0
- gobby/cli/extensions.py +526 -0
- gobby/cli/github.py +263 -0
- gobby/cli/init.py +53 -0
- gobby/cli/install.py +614 -0
- gobby/cli/installers/__init__.py +37 -0
- gobby/cli/installers/antigravity.py +65 -0
- gobby/cli/installers/claude.py +363 -0
- gobby/cli/installers/codex.py +192 -0
- gobby/cli/installers/gemini.py +294 -0
- gobby/cli/installers/git_hooks.py +377 -0
- gobby/cli/installers/shared.py +737 -0
- gobby/cli/linear.py +250 -0
- gobby/cli/mcp.py +30 -0
- gobby/cli/mcp_proxy.py +698 -0
- gobby/cli/memory.py +304 -0
- gobby/cli/merge.py +384 -0
- gobby/cli/projects.py +79 -0
- gobby/cli/sessions.py +622 -0
- gobby/cli/tasks/__init__.py +30 -0
- gobby/cli/tasks/_utils.py +658 -0
- gobby/cli/tasks/ai.py +1025 -0
- gobby/cli/tasks/commits.py +169 -0
- gobby/cli/tasks/crud.py +685 -0
- gobby/cli/tasks/deps.py +135 -0
- gobby/cli/tasks/labels.py +63 -0
- gobby/cli/tasks/main.py +273 -0
- gobby/cli/tasks/search.py +178 -0
- gobby/cli/tui.py +34 -0
- gobby/cli/utils.py +513 -0
- gobby/cli/workflows.py +927 -0
- gobby/cli/worktrees.py +481 -0
- gobby/config/__init__.py +129 -0
- gobby/config/app.py +551 -0
- gobby/config/extensions.py +167 -0
- gobby/config/features.py +472 -0
- gobby/config/llm_providers.py +98 -0
- gobby/config/logging.py +66 -0
- gobby/config/mcp.py +346 -0
- gobby/config/persistence.py +247 -0
- gobby/config/servers.py +141 -0
- gobby/config/sessions.py +250 -0
- gobby/config/tasks.py +784 -0
- gobby/hooks/__init__.py +104 -0
- gobby/hooks/artifact_capture.py +213 -0
- gobby/hooks/broadcaster.py +243 -0
- gobby/hooks/event_handlers.py +723 -0
- gobby/hooks/events.py +218 -0
- gobby/hooks/git.py +169 -0
- gobby/hooks/health_monitor.py +171 -0
- gobby/hooks/hook_manager.py +856 -0
- gobby/hooks/hook_types.py +575 -0
- gobby/hooks/plugins.py +813 -0
- gobby/hooks/session_coordinator.py +396 -0
- gobby/hooks/verification_runner.py +268 -0
- gobby/hooks/webhooks.py +339 -0
- gobby/install/claude/commands/gobby/bug.md +51 -0
- gobby/install/claude/commands/gobby/chore.md +51 -0
- gobby/install/claude/commands/gobby/epic.md +52 -0
- gobby/install/claude/commands/gobby/eval.md +235 -0
- gobby/install/claude/commands/gobby/feat.md +49 -0
- gobby/install/claude/commands/gobby/nit.md +52 -0
- gobby/install/claude/commands/gobby/ref.md +52 -0
- gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
- gobby/install/claude/hooks/hook_dispatcher.py +364 -0
- gobby/install/claude/hooks/validate_settings.py +102 -0
- gobby/install/claude/hooks-template.json +118 -0
- gobby/install/codex/hooks/hook_dispatcher.py +153 -0
- gobby/install/codex/prompts/forget.md +7 -0
- gobby/install/codex/prompts/memories.md +7 -0
- gobby/install/codex/prompts/recall.md +7 -0
- gobby/install/codex/prompts/remember.md +13 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
- gobby/install/gemini/hooks-template.json +138 -0
- gobby/install/shared/plugins/code_guardian.py +456 -0
- gobby/install/shared/plugins/example_notify.py +331 -0
- gobby/integrations/__init__.py +10 -0
- gobby/integrations/github.py +145 -0
- gobby/integrations/linear.py +145 -0
- gobby/llm/__init__.py +40 -0
- gobby/llm/base.py +120 -0
- gobby/llm/claude.py +578 -0
- gobby/llm/claude_executor.py +503 -0
- gobby/llm/codex.py +322 -0
- gobby/llm/codex_executor.py +513 -0
- gobby/llm/executor.py +316 -0
- gobby/llm/factory.py +34 -0
- gobby/llm/gemini.py +258 -0
- gobby/llm/gemini_executor.py +339 -0
- gobby/llm/litellm.py +287 -0
- gobby/llm/litellm_executor.py +303 -0
- gobby/llm/resolver.py +499 -0
- gobby/llm/service.py +236 -0
- gobby/mcp_proxy/__init__.py +29 -0
- gobby/mcp_proxy/actions.py +175 -0
- gobby/mcp_proxy/daemon_control.py +198 -0
- gobby/mcp_proxy/importer.py +436 -0
- gobby/mcp_proxy/lazy.py +325 -0
- gobby/mcp_proxy/manager.py +798 -0
- gobby/mcp_proxy/metrics.py +609 -0
- gobby/mcp_proxy/models.py +139 -0
- gobby/mcp_proxy/registries.py +215 -0
- gobby/mcp_proxy/schema_hash.py +381 -0
- gobby/mcp_proxy/semantic_search.py +706 -0
- gobby/mcp_proxy/server.py +549 -0
- gobby/mcp_proxy/services/__init__.py +0 -0
- gobby/mcp_proxy/services/fallback.py +306 -0
- gobby/mcp_proxy/services/recommendation.py +224 -0
- gobby/mcp_proxy/services/server_mgmt.py +214 -0
- gobby/mcp_proxy/services/system.py +72 -0
- gobby/mcp_proxy/services/tool_filter.py +231 -0
- gobby/mcp_proxy/services/tool_proxy.py +309 -0
- gobby/mcp_proxy/stdio.py +565 -0
- gobby/mcp_proxy/tools/__init__.py +27 -0
- gobby/mcp_proxy/tools/agents.py +1103 -0
- gobby/mcp_proxy/tools/artifacts.py +207 -0
- gobby/mcp_proxy/tools/hub.py +335 -0
- gobby/mcp_proxy/tools/internal.py +337 -0
- gobby/mcp_proxy/tools/memory.py +543 -0
- gobby/mcp_proxy/tools/merge.py +422 -0
- gobby/mcp_proxy/tools/metrics.py +283 -0
- gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
- gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
- gobby/mcp_proxy/tools/orchestration/review.py +736 -0
- gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
- gobby/mcp_proxy/tools/session_messages.py +1056 -0
- gobby/mcp_proxy/tools/task_dependencies.py +219 -0
- gobby/mcp_proxy/tools/task_expansion.py +591 -0
- gobby/mcp_proxy/tools/task_github.py +393 -0
- gobby/mcp_proxy/tools/task_linear.py +379 -0
- gobby/mcp_proxy/tools/task_orchestration.py +77 -0
- gobby/mcp_proxy/tools/task_readiness.py +522 -0
- gobby/mcp_proxy/tools/task_sync.py +351 -0
- gobby/mcp_proxy/tools/task_validation.py +843 -0
- gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
- gobby/mcp_proxy/tools/tasks/_context.py +112 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
- gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
- gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
- gobby/mcp_proxy/tools/tasks/_search.py +215 -0
- gobby/mcp_proxy/tools/tasks/_session.py +125 -0
- gobby/mcp_proxy/tools/workflows.py +973 -0
- gobby/mcp_proxy/tools/worktrees.py +1264 -0
- gobby/mcp_proxy/transports/__init__.py +0 -0
- gobby/mcp_proxy/transports/base.py +95 -0
- gobby/mcp_proxy/transports/factory.py +44 -0
- gobby/mcp_proxy/transports/http.py +139 -0
- gobby/mcp_proxy/transports/stdio.py +213 -0
- gobby/mcp_proxy/transports/websocket.py +136 -0
- gobby/memory/backends/__init__.py +116 -0
- gobby/memory/backends/mem0.py +408 -0
- gobby/memory/backends/memu.py +485 -0
- gobby/memory/backends/null.py +111 -0
- gobby/memory/backends/openmemory.py +537 -0
- gobby/memory/backends/sqlite.py +304 -0
- gobby/memory/context.py +87 -0
- gobby/memory/manager.py +1001 -0
- gobby/memory/protocol.py +451 -0
- gobby/memory/search/__init__.py +66 -0
- gobby/memory/search/text.py +127 -0
- gobby/memory/viz.py +258 -0
- gobby/prompts/__init__.py +13 -0
- gobby/prompts/defaults/expansion/system.md +119 -0
- gobby/prompts/defaults/expansion/user.md +48 -0
- gobby/prompts/defaults/external_validation/agent.md +72 -0
- gobby/prompts/defaults/external_validation/external.md +63 -0
- gobby/prompts/defaults/external_validation/spawn.md +83 -0
- gobby/prompts/defaults/external_validation/system.md +6 -0
- gobby/prompts/defaults/features/import_mcp.md +22 -0
- gobby/prompts/defaults/features/import_mcp_github.md +17 -0
- gobby/prompts/defaults/features/import_mcp_search.md +16 -0
- gobby/prompts/defaults/features/recommend_tools.md +32 -0
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
- gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
- gobby/prompts/defaults/features/server_description.md +20 -0
- gobby/prompts/defaults/features/server_description_system.md +6 -0
- gobby/prompts/defaults/features/task_description.md +31 -0
- gobby/prompts/defaults/features/task_description_system.md +6 -0
- gobby/prompts/defaults/features/tool_summary.md +17 -0
- gobby/prompts/defaults/features/tool_summary_system.md +6 -0
- gobby/prompts/defaults/research/step.md +58 -0
- gobby/prompts/defaults/validation/criteria.md +47 -0
- gobby/prompts/defaults/validation/validate.md +38 -0
- gobby/prompts/loader.py +346 -0
- gobby/prompts/models.py +113 -0
- gobby/py.typed +0 -0
- gobby/runner.py +488 -0
- gobby/search/__init__.py +23 -0
- gobby/search/protocol.py +104 -0
- gobby/search/tfidf.py +232 -0
- gobby/servers/__init__.py +7 -0
- gobby/servers/http.py +636 -0
- gobby/servers/models.py +31 -0
- gobby/servers/routes/__init__.py +23 -0
- gobby/servers/routes/admin.py +416 -0
- gobby/servers/routes/dependencies.py +118 -0
- gobby/servers/routes/mcp/__init__.py +24 -0
- gobby/servers/routes/mcp/hooks.py +135 -0
- gobby/servers/routes/mcp/plugins.py +121 -0
- gobby/servers/routes/mcp/tools.py +1337 -0
- gobby/servers/routes/mcp/webhooks.py +159 -0
- gobby/servers/routes/sessions.py +582 -0
- gobby/servers/websocket.py +766 -0
- gobby/sessions/__init__.py +13 -0
- gobby/sessions/analyzer.py +322 -0
- gobby/sessions/lifecycle.py +240 -0
- gobby/sessions/manager.py +563 -0
- gobby/sessions/processor.py +225 -0
- gobby/sessions/summary.py +532 -0
- gobby/sessions/transcripts/__init__.py +41 -0
- gobby/sessions/transcripts/base.py +125 -0
- gobby/sessions/transcripts/claude.py +386 -0
- gobby/sessions/transcripts/codex.py +143 -0
- gobby/sessions/transcripts/gemini.py +195 -0
- gobby/storage/__init__.py +21 -0
- gobby/storage/agents.py +409 -0
- gobby/storage/artifact_classifier.py +341 -0
- gobby/storage/artifacts.py +285 -0
- gobby/storage/compaction.py +67 -0
- gobby/storage/database.py +357 -0
- gobby/storage/inter_session_messages.py +194 -0
- gobby/storage/mcp.py +680 -0
- gobby/storage/memories.py +562 -0
- gobby/storage/merge_resolutions.py +550 -0
- gobby/storage/migrations.py +860 -0
- gobby/storage/migrations_legacy.py +1359 -0
- gobby/storage/projects.py +166 -0
- gobby/storage/session_messages.py +251 -0
- gobby/storage/session_tasks.py +97 -0
- gobby/storage/sessions.py +817 -0
- gobby/storage/task_dependencies.py +223 -0
- gobby/storage/tasks/__init__.py +42 -0
- gobby/storage/tasks/_aggregates.py +180 -0
- gobby/storage/tasks/_crud.py +449 -0
- gobby/storage/tasks/_id.py +104 -0
- gobby/storage/tasks/_lifecycle.py +311 -0
- gobby/storage/tasks/_manager.py +889 -0
- gobby/storage/tasks/_models.py +300 -0
- gobby/storage/tasks/_ordering.py +119 -0
- gobby/storage/tasks/_path_cache.py +110 -0
- gobby/storage/tasks/_queries.py +343 -0
- gobby/storage/tasks/_search.py +143 -0
- gobby/storage/workflow_audit.py +393 -0
- gobby/storage/worktrees.py +547 -0
- gobby/sync/__init__.py +29 -0
- gobby/sync/github.py +333 -0
- gobby/sync/linear.py +304 -0
- gobby/sync/memories.py +284 -0
- gobby/sync/tasks.py +641 -0
- gobby/tasks/__init__.py +8 -0
- gobby/tasks/build_verification.py +193 -0
- gobby/tasks/commits.py +633 -0
- gobby/tasks/context.py +747 -0
- gobby/tasks/criteria.py +342 -0
- gobby/tasks/enhanced_validator.py +226 -0
- gobby/tasks/escalation.py +263 -0
- gobby/tasks/expansion.py +626 -0
- gobby/tasks/external_validator.py +764 -0
- gobby/tasks/issue_extraction.py +171 -0
- gobby/tasks/prompts/expand.py +327 -0
- gobby/tasks/research.py +421 -0
- gobby/tasks/tdd.py +352 -0
- gobby/tasks/tree_builder.py +263 -0
- gobby/tasks/validation.py +712 -0
- gobby/tasks/validation_history.py +357 -0
- gobby/tasks/validation_models.py +89 -0
- gobby/tools/__init__.py +0 -0
- gobby/tools/summarizer.py +170 -0
- gobby/tui/__init__.py +5 -0
- gobby/tui/api_client.py +281 -0
- gobby/tui/app.py +327 -0
- gobby/tui/screens/__init__.py +25 -0
- gobby/tui/screens/agents.py +333 -0
- gobby/tui/screens/chat.py +450 -0
- gobby/tui/screens/dashboard.py +377 -0
- gobby/tui/screens/memory.py +305 -0
- gobby/tui/screens/metrics.py +231 -0
- gobby/tui/screens/orchestrator.py +904 -0
- gobby/tui/screens/sessions.py +412 -0
- gobby/tui/screens/tasks.py +442 -0
- gobby/tui/screens/workflows.py +289 -0
- gobby/tui/screens/worktrees.py +174 -0
- gobby/tui/widgets/__init__.py +21 -0
- gobby/tui/widgets/chat.py +210 -0
- gobby/tui/widgets/conductor.py +104 -0
- gobby/tui/widgets/menu.py +132 -0
- gobby/tui/widgets/message_panel.py +160 -0
- gobby/tui/widgets/review_gate.py +224 -0
- gobby/tui/widgets/task_tree.py +99 -0
- gobby/tui/widgets/token_budget.py +166 -0
- gobby/tui/ws_client.py +258 -0
- gobby/utils/__init__.py +3 -0
- gobby/utils/daemon_client.py +235 -0
- gobby/utils/git.py +222 -0
- gobby/utils/id.py +38 -0
- gobby/utils/json_helpers.py +161 -0
- gobby/utils/logging.py +376 -0
- gobby/utils/machine_id.py +135 -0
- gobby/utils/metrics.py +589 -0
- gobby/utils/project_context.py +182 -0
- gobby/utils/project_init.py +263 -0
- gobby/utils/status.py +256 -0
- gobby/utils/validation.py +80 -0
- gobby/utils/version.py +23 -0
- gobby/workflows/__init__.py +4 -0
- gobby/workflows/actions.py +1310 -0
- gobby/workflows/approval_flow.py +138 -0
- gobby/workflows/artifact_actions.py +103 -0
- gobby/workflows/audit_helpers.py +110 -0
- gobby/workflows/autonomous_actions.py +286 -0
- gobby/workflows/context_actions.py +394 -0
- gobby/workflows/definitions.py +130 -0
- gobby/workflows/detection_helpers.py +208 -0
- gobby/workflows/engine.py +485 -0
- gobby/workflows/evaluator.py +669 -0
- gobby/workflows/git_utils.py +96 -0
- gobby/workflows/hooks.py +169 -0
- gobby/workflows/lifecycle_evaluator.py +613 -0
- gobby/workflows/llm_actions.py +70 -0
- gobby/workflows/loader.py +333 -0
- gobby/workflows/mcp_actions.py +60 -0
- gobby/workflows/memory_actions.py +272 -0
- gobby/workflows/premature_stop.py +164 -0
- gobby/workflows/session_actions.py +139 -0
- gobby/workflows/state_actions.py +123 -0
- gobby/workflows/state_manager.py +104 -0
- gobby/workflows/stop_signal_actions.py +163 -0
- gobby/workflows/summary_actions.py +344 -0
- gobby/workflows/task_actions.py +249 -0
- gobby/workflows/task_enforcement_actions.py +901 -0
- gobby/workflows/templates.py +52 -0
- gobby/workflows/todo_actions.py +84 -0
- gobby/workflows/webhook.py +223 -0
- gobby/workflows/webhook_executor.py +399 -0
- gobby/worktrees/__init__.py +5 -0
- gobby/worktrees/git.py +690 -0
- gobby/worktrees/merge/__init__.py +20 -0
- gobby/worktrees/merge/conflict_parser.py +177 -0
- gobby/worktrees/merge/resolver.py +485 -0
- gobby-0.2.5.dist-info/METADATA +351 -0
- gobby-0.2.5.dist-info/RECORD +383 -0
- gobby-0.2.5.dist-info/WHEEL +5 -0
- gobby-0.2.5.dist-info/entry_points.txt +2 -0
- gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
- gobby-0.2.5.dist-info/top_level.txt +1 -0
gobby/hooks/plugins.py
ADDED
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Python plugin system for hook handlers.
|
|
3
|
+
|
|
4
|
+
This module provides infrastructure for dynamically loading Python plugins
|
|
5
|
+
that can intercept and modify hook behavior.
|
|
6
|
+
|
|
7
|
+
Security Note: Plugins run with full daemon privileges. Only enable plugins
|
|
8
|
+
you trust. The plugin system is disabled by default.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import importlib.util
|
|
14
|
+
import inspect
|
|
15
|
+
import logging
|
|
16
|
+
import sys
|
|
17
|
+
from abc import ABC
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
from gobby.hooks.events import HookEvent, HookEventType, HookResponse
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from gobby.config.app import PluginsConfig
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# =============================================================================
|
|
32
|
+
# Plugin Action Registration
|
|
33
|
+
# =============================================================================
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class PluginAction:
|
|
38
|
+
"""A registered workflow action from a plugin.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
name: Action name (without plugin prefix).
|
|
42
|
+
handler: Async callable matching ActionHandler protocol.
|
|
43
|
+
schema: JSON Schema dict describing the action's input parameters.
|
|
44
|
+
plugin_name: Name of the plugin that registered this action.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
name: str
|
|
48
|
+
handler: Callable[..., Any]
|
|
49
|
+
schema: dict[str, Any]
|
|
50
|
+
plugin_name: str
|
|
51
|
+
|
|
52
|
+
def validate_input(self, kwargs: dict[str, Any]) -> tuple[bool, str | None]:
|
|
53
|
+
"""Validate input arguments against the action's schema.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
kwargs: Input arguments to validate.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Tuple of (is_valid, error_message).
|
|
60
|
+
If valid, error_message is None.
|
|
61
|
+
"""
|
|
62
|
+
if not self.schema:
|
|
63
|
+
return True, None # No schema means no validation
|
|
64
|
+
|
|
65
|
+
# Basic JSON Schema validation (properties + required)
|
|
66
|
+
properties = self.schema.get("properties", {})
|
|
67
|
+
required = self.schema.get("required", [])
|
|
68
|
+
|
|
69
|
+
# Check required fields
|
|
70
|
+
for field_name in required:
|
|
71
|
+
if field_name not in kwargs:
|
|
72
|
+
return False, f"Missing required field: {field_name}"
|
|
73
|
+
|
|
74
|
+
# Check property types if specified
|
|
75
|
+
for prop_name, prop_schema in properties.items():
|
|
76
|
+
if prop_name not in kwargs:
|
|
77
|
+
continue # Optional field not provided
|
|
78
|
+
|
|
79
|
+
value = kwargs[prop_name]
|
|
80
|
+
prop_type = prop_schema.get("type")
|
|
81
|
+
|
|
82
|
+
if prop_type and not _check_type(value, prop_type):
|
|
83
|
+
return False, f"Field '{prop_name}' has invalid type: expected {prop_type}"
|
|
84
|
+
|
|
85
|
+
return True, None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _check_type(value: Any, expected_type: str) -> bool:
|
|
89
|
+
"""Check if a value matches a JSON Schema type."""
|
|
90
|
+
# Explicitly reject bool for numeric types since bool is a subclass of int
|
|
91
|
+
if expected_type in ("integer", "number") and isinstance(value, bool):
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
type_map = {
|
|
95
|
+
"string": str,
|
|
96
|
+
"number": (int, float),
|
|
97
|
+
"integer": int,
|
|
98
|
+
"boolean": bool,
|
|
99
|
+
"array": list,
|
|
100
|
+
"object": dict,
|
|
101
|
+
"null": type(None),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
expected = type_map.get(expected_type)
|
|
105
|
+
if expected is None:
|
|
106
|
+
return True # Unknown type, skip validation
|
|
107
|
+
|
|
108
|
+
return isinstance(value, expected) # type: ignore[arg-type]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# =============================================================================
|
|
112
|
+
# Decorator
|
|
113
|
+
# =============================================================================
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def hook_handler(
|
|
117
|
+
event_type: HookEventType,
|
|
118
|
+
priority: int = 50,
|
|
119
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
120
|
+
"""
|
|
121
|
+
Decorator to mark a method as a hook handler.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
event_type: The HookEventType this handler responds to.
|
|
125
|
+
priority: Execution priority (lower = earlier).
|
|
126
|
+
- Priority < 50: Pre-handlers (run before core, can block)
|
|
127
|
+
- Priority >= 50: Post-handlers (run after core, observe only)
|
|
128
|
+
|
|
129
|
+
Handler Signatures:
|
|
130
|
+
Pre-handlers (priority < 50):
|
|
131
|
+
def handler(self, event: HookEvent) -> HookResponse | None
|
|
132
|
+
- Receives only the event
|
|
133
|
+
- Return HookResponse with decision="deny" or "block" to block
|
|
134
|
+
- Return None to continue to next handler
|
|
135
|
+
|
|
136
|
+
Post-handlers (priority >= 50):
|
|
137
|
+
def handler(self, event: HookEvent, core_response: HookResponse | None) -> None
|
|
138
|
+
- Receives event AND the core handler's response
|
|
139
|
+
- Cannot block; return value is ignored
|
|
140
|
+
- IMPORTANT: Must accept two arguments or a TypeError will be raised
|
|
141
|
+
|
|
142
|
+
Examples:
|
|
143
|
+
class MyPlugin(HookPlugin):
|
|
144
|
+
name = "my-plugin"
|
|
145
|
+
|
|
146
|
+
# Pre-handler: can block dangerous tools
|
|
147
|
+
@hook_handler(HookEventType.BEFORE_TOOL, priority=10)
|
|
148
|
+
def check_tool(self, event: HookEvent) -> HookResponse | None:
|
|
149
|
+
if "dangerous" in event.data.get("tool_name", ""):
|
|
150
|
+
return HookResponse(decision="deny", reason="Blocked")
|
|
151
|
+
return None # Continue to next handler
|
|
152
|
+
|
|
153
|
+
# Post-handler: observe and log after core processing
|
|
154
|
+
@hook_handler(HookEventType.AFTER_TOOL, priority=60)
|
|
155
|
+
def log_tool_result(
|
|
156
|
+
self, event: HookEvent, core_response: HookResponse | None
|
|
157
|
+
) -> None:
|
|
158
|
+
tool = event.data.get("tool_name", "unknown")
|
|
159
|
+
status = core_response.decision if core_response else "no-response"
|
|
160
|
+
logger.info(f"Tool {tool} completed with status: {status}")
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
164
|
+
# Store metadata on the function
|
|
165
|
+
func._hook_event_type = event_type # type: ignore[attr-defined]
|
|
166
|
+
func._hook_priority = priority # type: ignore[attr-defined]
|
|
167
|
+
return func
|
|
168
|
+
|
|
169
|
+
return decorator
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# =============================================================================
|
|
173
|
+
# Base Class
|
|
174
|
+
# =============================================================================
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class HookPlugin(ABC):
|
|
178
|
+
"""
|
|
179
|
+
Base class for hook plugins.
|
|
180
|
+
|
|
181
|
+
Subclass this to create a plugin. At minimum, set the `name` class attribute
|
|
182
|
+
and implement handler methods decorated with @hook_handler.
|
|
183
|
+
|
|
184
|
+
Attributes:
|
|
185
|
+
name: Unique plugin identifier (required).
|
|
186
|
+
version: Plugin version string (default: "1.0.0").
|
|
187
|
+
description: Human-readable description.
|
|
188
|
+
|
|
189
|
+
Example:
|
|
190
|
+
class MyPlugin(HookPlugin):
|
|
191
|
+
name = "my-plugin"
|
|
192
|
+
version = "1.0.0"
|
|
193
|
+
description = "Blocks dangerous commands"
|
|
194
|
+
|
|
195
|
+
def on_load(self, config: dict) -> None:
|
|
196
|
+
self.blocked_patterns = config.get("blocked", [])
|
|
197
|
+
|
|
198
|
+
@hook_handler(HookEventType.BEFORE_TOOL, priority=10)
|
|
199
|
+
def check_tool(self, event: HookEvent) -> HookResponse | None:
|
|
200
|
+
# Return HookResponse to block, None to continue
|
|
201
|
+
return None
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
name: str
|
|
205
|
+
version: str = "1.0.0"
|
|
206
|
+
description: str = ""
|
|
207
|
+
|
|
208
|
+
def __init__(self) -> None:
|
|
209
|
+
"""Initialize plugin instance."""
|
|
210
|
+
# Containers for registered workflow extensions
|
|
211
|
+
self._actions: dict[str, PluginAction] = {}
|
|
212
|
+
self._conditions: dict[str, Callable[..., Any]] = {}
|
|
213
|
+
self.logger = logging.getLogger(f"gobby.plugins.{self.name}")
|
|
214
|
+
|
|
215
|
+
def on_load(self, config: dict[str, Any]) -> None: # noqa: B027
|
|
216
|
+
"""
|
|
217
|
+
Called when plugin is loaded.
|
|
218
|
+
|
|
219
|
+
Override to initialize plugin state with configuration.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
config: Plugin-specific configuration from PluginItemConfig.config
|
|
223
|
+
"""
|
|
224
|
+
# Optional lifecycle hook - subclasses may override
|
|
225
|
+
|
|
226
|
+
def on_unload(self) -> None: # noqa: B027
|
|
227
|
+
"""
|
|
228
|
+
Called when plugin is unloaded.
|
|
229
|
+
|
|
230
|
+
Override to cleanup resources.
|
|
231
|
+
"""
|
|
232
|
+
# Optional lifecycle hook - subclasses may override
|
|
233
|
+
|
|
234
|
+
def register_action(self, name: str, handler: Callable[..., Any]) -> None:
|
|
235
|
+
"""
|
|
236
|
+
Register a workflow action (simple form without schema).
|
|
237
|
+
|
|
238
|
+
Actions registered here can be used in workflow YAML files.
|
|
239
|
+
They will be available as `plugin:<plugin-name>:<action-name>`.
|
|
240
|
+
|
|
241
|
+
For actions that require input validation, use register_workflow_action().
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
name: Action name (without plugin prefix).
|
|
245
|
+
handler: Async callable matching ActionHandler protocol.
|
|
246
|
+
"""
|
|
247
|
+
action = PluginAction(
|
|
248
|
+
name=name,
|
|
249
|
+
handler=handler,
|
|
250
|
+
schema={},
|
|
251
|
+
plugin_name=self.name,
|
|
252
|
+
)
|
|
253
|
+
self._actions[name] = action
|
|
254
|
+
self.logger.debug(f"Registered action: {name}")
|
|
255
|
+
|
|
256
|
+
def register_workflow_action(
|
|
257
|
+
self,
|
|
258
|
+
action_type: str,
|
|
259
|
+
schema: dict[str, Any],
|
|
260
|
+
executor_fn: Callable[..., Any],
|
|
261
|
+
) -> None:
|
|
262
|
+
"""
|
|
263
|
+
Register a workflow action with schema validation.
|
|
264
|
+
|
|
265
|
+
Actions registered here can be used in workflow YAML files.
|
|
266
|
+
They will be available as `plugin:<plugin-name>:<action-type>`.
|
|
267
|
+
Input arguments will be validated against the schema before execution.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
action_type: Action name (without plugin prefix).
|
|
271
|
+
schema: JSON Schema dict for input validation. Should contain:
|
|
272
|
+
- properties: Dict of property names to their schemas
|
|
273
|
+
- required: List of required property names
|
|
274
|
+
Example:
|
|
275
|
+
{
|
|
276
|
+
"properties": {
|
|
277
|
+
"message": {"type": "string"},
|
|
278
|
+
"channel": {"type": "string"}
|
|
279
|
+
},
|
|
280
|
+
"required": ["message"]
|
|
281
|
+
}
|
|
282
|
+
executor_fn: Async callable matching ActionHandler protocol:
|
|
283
|
+
async def handler(context: ActionContext, **kwargs) -> dict | None
|
|
284
|
+
|
|
285
|
+
Raises:
|
|
286
|
+
ValueError: If action_type is already registered.
|
|
287
|
+
"""
|
|
288
|
+
if action_type in self._actions:
|
|
289
|
+
raise ValueError(
|
|
290
|
+
f"Action type '{action_type}' is already registered for plugin '{self.name}'"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
action = PluginAction(
|
|
294
|
+
name=action_type,
|
|
295
|
+
handler=executor_fn,
|
|
296
|
+
schema=schema,
|
|
297
|
+
plugin_name=self.name,
|
|
298
|
+
)
|
|
299
|
+
self._actions[action_type] = action
|
|
300
|
+
self.logger.debug(f"Registered workflow action: {action_type} with schema")
|
|
301
|
+
|
|
302
|
+
def get_action(self, name: str) -> PluginAction | None:
|
|
303
|
+
"""
|
|
304
|
+
Get a registered action by name.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
name: Action name (without plugin prefix).
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
PluginAction if found, None otherwise.
|
|
311
|
+
"""
|
|
312
|
+
return self._actions.get(name)
|
|
313
|
+
|
|
314
|
+
def register_condition(self, name: str, evaluator: Callable[..., Any]) -> None:
|
|
315
|
+
"""
|
|
316
|
+
Register a workflow condition.
|
|
317
|
+
|
|
318
|
+
Conditions registered here can be used in workflow `when` clauses.
|
|
319
|
+
They will be available as `plugin:<plugin-name>:<condition-name>`.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
name: Condition name (without plugin prefix).
|
|
323
|
+
evaluator: Callable that returns bool given context dict.
|
|
324
|
+
"""
|
|
325
|
+
self._conditions[name] = evaluator
|
|
326
|
+
self.logger.debug(f"Registered condition: {name}")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# =============================================================================
|
|
330
|
+
# Handler Registration
|
|
331
|
+
# =============================================================================
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@dataclass
|
|
335
|
+
class RegisteredHandler:
|
|
336
|
+
"""A registered hook handler with metadata."""
|
|
337
|
+
|
|
338
|
+
plugin: HookPlugin
|
|
339
|
+
method: Callable[..., Any]
|
|
340
|
+
event_type: HookEventType
|
|
341
|
+
priority: int
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@dataclass
|
|
345
|
+
class PluginRegistry:
|
|
346
|
+
"""
|
|
347
|
+
Manages loaded plugins and their handlers.
|
|
348
|
+
|
|
349
|
+
Maintains a registry of plugins and their hook handlers, providing
|
|
350
|
+
priority-sorted handler retrieval.
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
_plugins: dict[str, HookPlugin] = field(default_factory=dict)
|
|
354
|
+
_handlers: dict[HookEventType, list[RegisteredHandler]] = field(default_factory=dict)
|
|
355
|
+
|
|
356
|
+
def register_plugin(self, plugin: HookPlugin) -> None:
|
|
357
|
+
"""
|
|
358
|
+
Register a plugin and its handlers.
|
|
359
|
+
|
|
360
|
+
Scans the plugin for methods decorated with @hook_handler and
|
|
361
|
+
registers them in priority order.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
plugin: The plugin instance to register.
|
|
365
|
+
|
|
366
|
+
Raises:
|
|
367
|
+
ValueError: If a plugin with the same name is already registered.
|
|
368
|
+
"""
|
|
369
|
+
if plugin.name in self._plugins:
|
|
370
|
+
raise ValueError(f"Plugin already registered: {plugin.name}")
|
|
371
|
+
|
|
372
|
+
self._plugins[plugin.name] = plugin
|
|
373
|
+
|
|
374
|
+
# Find and register all @hook_handler decorated methods
|
|
375
|
+
for name, method in inspect.getmembers(plugin, predicate=inspect.ismethod):
|
|
376
|
+
if hasattr(method, "_hook_event_type"):
|
|
377
|
+
event_type = method._hook_event_type
|
|
378
|
+
priority = getattr(method, "_hook_priority", 50)
|
|
379
|
+
|
|
380
|
+
handler = RegisteredHandler(
|
|
381
|
+
plugin=plugin,
|
|
382
|
+
method=method,
|
|
383
|
+
event_type=event_type,
|
|
384
|
+
priority=priority,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
if event_type not in self._handlers:
|
|
388
|
+
self._handlers[event_type] = []
|
|
389
|
+
|
|
390
|
+
self._handlers[event_type].append(handler)
|
|
391
|
+
# Keep sorted by priority
|
|
392
|
+
self._handlers[event_type].sort(key=lambda h: h.priority)
|
|
393
|
+
|
|
394
|
+
logger.debug(
|
|
395
|
+
f"Registered handler: {plugin.name}.{name} for {event_type.value} "
|
|
396
|
+
f"(priority={priority})"
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def unregister_plugin(self, name: str) -> None:
|
|
400
|
+
"""
|
|
401
|
+
Unregister a plugin and remove its handlers.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
name: The plugin name to unregister.
|
|
405
|
+
"""
|
|
406
|
+
if name not in self._plugins:
|
|
407
|
+
logger.warning(f"Plugin not registered: {name}")
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
plugin = self._plugins.pop(name)
|
|
411
|
+
|
|
412
|
+
# Remove handlers for this plugin
|
|
413
|
+
for event_type in list(self._handlers.keys()):
|
|
414
|
+
self._handlers[event_type] = [
|
|
415
|
+
h for h in self._handlers[event_type] if h.plugin is not plugin
|
|
416
|
+
]
|
|
417
|
+
if not self._handlers[event_type]:
|
|
418
|
+
del self._handlers[event_type]
|
|
419
|
+
|
|
420
|
+
logger.info(f"Unregistered plugin: {name}")
|
|
421
|
+
|
|
422
|
+
def get_handlers(
|
|
423
|
+
self,
|
|
424
|
+
event_type: HookEventType,
|
|
425
|
+
pre_only: bool = False,
|
|
426
|
+
post_only: bool = False,
|
|
427
|
+
) -> list[RegisteredHandler]:
|
|
428
|
+
"""
|
|
429
|
+
Get handlers for an event type, optionally filtered by priority.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
event_type: The event type to get handlers for.
|
|
433
|
+
pre_only: If True, only return handlers with priority < 50.
|
|
434
|
+
post_only: If True, only return handlers with priority >= 50.
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
List of RegisteredHandler sorted by priority.
|
|
438
|
+
"""
|
|
439
|
+
handlers = self._handlers.get(event_type, [])
|
|
440
|
+
|
|
441
|
+
if pre_only:
|
|
442
|
+
return [h for h in handlers if h.priority < 50]
|
|
443
|
+
if post_only:
|
|
444
|
+
return [h for h in handlers if h.priority >= 50]
|
|
445
|
+
|
|
446
|
+
return handlers
|
|
447
|
+
|
|
448
|
+
def get_plugin(self, name: str) -> HookPlugin | None:
|
|
449
|
+
"""Get a plugin by name."""
|
|
450
|
+
return self._plugins.get(name)
|
|
451
|
+
|
|
452
|
+
def list_plugins(self) -> list[dict[str, Any]]:
|
|
453
|
+
"""List all registered plugins with metadata."""
|
|
454
|
+
return [
|
|
455
|
+
{
|
|
456
|
+
"name": p.name,
|
|
457
|
+
"version": p.version,
|
|
458
|
+
"description": p.description,
|
|
459
|
+
"handlers": [
|
|
460
|
+
{"event": h.event_type.value, "priority": h.priority}
|
|
461
|
+
for handlers in self._handlers.values()
|
|
462
|
+
for h in handlers
|
|
463
|
+
if h.plugin is p
|
|
464
|
+
],
|
|
465
|
+
"actions": [
|
|
466
|
+
{
|
|
467
|
+
"name": action.name,
|
|
468
|
+
"has_schema": bool(action.schema),
|
|
469
|
+
"schema": action.schema if action.schema else None,
|
|
470
|
+
}
|
|
471
|
+
for action in p._actions.values()
|
|
472
|
+
],
|
|
473
|
+
"conditions": list(p._conditions.keys()),
|
|
474
|
+
}
|
|
475
|
+
for p in self._plugins.values()
|
|
476
|
+
]
|
|
477
|
+
|
|
478
|
+
def get_plugin_action(self, plugin_name: str, action_name: str) -> PluginAction | None:
|
|
479
|
+
"""Get a specific action from a plugin.
|
|
480
|
+
|
|
481
|
+
Args:
|
|
482
|
+
plugin_name: Name of the plugin.
|
|
483
|
+
action_name: Name of the action.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
PluginAction if found, None otherwise.
|
|
487
|
+
"""
|
|
488
|
+
plugin = self._plugins.get(plugin_name)
|
|
489
|
+
if plugin is None:
|
|
490
|
+
return None
|
|
491
|
+
return plugin.get_action(action_name)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# =============================================================================
|
|
495
|
+
# Plugin Loader
|
|
496
|
+
# =============================================================================
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
class PluginLoader:
|
|
500
|
+
"""
|
|
501
|
+
Discovers and loads plugins from configured directories.
|
|
502
|
+
|
|
503
|
+
Handles plugin discovery, import, instantiation, and lifecycle management.
|
|
504
|
+
"""
|
|
505
|
+
|
|
506
|
+
def __init__(self, config: PluginsConfig) -> None:
|
|
507
|
+
"""
|
|
508
|
+
Initialize the plugin loader.
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
config: Plugin system configuration.
|
|
512
|
+
"""
|
|
513
|
+
self.config = config
|
|
514
|
+
self.registry = PluginRegistry()
|
|
515
|
+
self._loaded_modules: dict[str, Any] = {}
|
|
516
|
+
self._plugin_sources: dict[str, Path] = {} # Maps plugin name -> source file path
|
|
517
|
+
|
|
518
|
+
def discover_plugins(self, dirs: list[str] | None = None) -> list[type[HookPlugin]]:
|
|
519
|
+
"""
|
|
520
|
+
Discover plugin classes from configured directories.
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
dirs: Optional list of directories to scan. Uses config.plugin_dirs if None.
|
|
524
|
+
|
|
525
|
+
Returns:
|
|
526
|
+
List of discovered HookPlugin subclasses.
|
|
527
|
+
"""
|
|
528
|
+
search_dirs = dirs or self.config.plugin_dirs
|
|
529
|
+
discovered: list[type[HookPlugin]] = []
|
|
530
|
+
|
|
531
|
+
for dir_path in search_dirs:
|
|
532
|
+
# Expand ~ and resolve path
|
|
533
|
+
expanded = Path(dir_path).expanduser().resolve()
|
|
534
|
+
|
|
535
|
+
if not expanded.exists():
|
|
536
|
+
logger.debug(f"Plugin directory does not exist: {expanded}")
|
|
537
|
+
continue
|
|
538
|
+
|
|
539
|
+
if not expanded.is_dir():
|
|
540
|
+
logger.warning(f"Plugin path is not a directory: {expanded}")
|
|
541
|
+
continue
|
|
542
|
+
|
|
543
|
+
# Scan for Python files
|
|
544
|
+
for py_file in expanded.glob("*.py"):
|
|
545
|
+
if py_file.name.startswith("_"):
|
|
546
|
+
continue # Skip __init__.py, __pycache__, etc.
|
|
547
|
+
|
|
548
|
+
try:
|
|
549
|
+
plugin_classes = self._load_module(py_file)
|
|
550
|
+
discovered.extend(plugin_classes)
|
|
551
|
+
except Exception as e:
|
|
552
|
+
logger.error(f"Failed to load plugin module {py_file}: {e}")
|
|
553
|
+
|
|
554
|
+
logger.info(f"Discovered {len(discovered)} plugin class(es)")
|
|
555
|
+
return discovered
|
|
556
|
+
|
|
557
|
+
def _load_module(self, path: Path) -> list[type[HookPlugin]]:
|
|
558
|
+
"""
|
|
559
|
+
Load a Python module and find HookPlugin subclasses.
|
|
560
|
+
|
|
561
|
+
Args:
|
|
562
|
+
path: Path to the Python file.
|
|
563
|
+
|
|
564
|
+
Returns:
|
|
565
|
+
List of HookPlugin subclasses found in the module.
|
|
566
|
+
"""
|
|
567
|
+
module_name = f"gobby_plugin_{path.stem}"
|
|
568
|
+
|
|
569
|
+
# Check if already loaded
|
|
570
|
+
if module_name in self._loaded_modules:
|
|
571
|
+
module = self._loaded_modules[module_name]
|
|
572
|
+
else:
|
|
573
|
+
# Load the module
|
|
574
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
575
|
+
if spec is None or spec.loader is None:
|
|
576
|
+
raise ImportError(f"Cannot load module spec from {path}")
|
|
577
|
+
|
|
578
|
+
module = importlib.util.module_from_spec(spec)
|
|
579
|
+
sys.modules[module_name] = module
|
|
580
|
+
spec.loader.exec_module(module)
|
|
581
|
+
self._loaded_modules[module_name] = module
|
|
582
|
+
|
|
583
|
+
# Find HookPlugin subclasses
|
|
584
|
+
plugin_classes: list[type[HookPlugin]] = []
|
|
585
|
+
for _name, obj in inspect.getmembers(module, inspect.isclass):
|
|
586
|
+
if (
|
|
587
|
+
issubclass(obj, HookPlugin)
|
|
588
|
+
and obj is not HookPlugin
|
|
589
|
+
and hasattr(obj, "name")
|
|
590
|
+
and obj.name # Must have a non-empty name
|
|
591
|
+
):
|
|
592
|
+
# Store source path on the class for reload tracking
|
|
593
|
+
obj._gobby_source_path = path # type: ignore[attr-defined]
|
|
594
|
+
plugin_classes.append(obj)
|
|
595
|
+
|
|
596
|
+
return plugin_classes
|
|
597
|
+
|
|
598
|
+
def load_plugin(
|
|
599
|
+
self,
|
|
600
|
+
plugin_class: type[HookPlugin],
|
|
601
|
+
config: dict[str, Any] | None = None,
|
|
602
|
+
) -> HookPlugin:
|
|
603
|
+
"""
|
|
604
|
+
Instantiate and load a plugin.
|
|
605
|
+
|
|
606
|
+
Args:
|
|
607
|
+
plugin_class: The plugin class to instantiate.
|
|
608
|
+
config: Optional configuration to pass to on_load().
|
|
609
|
+
|
|
610
|
+
Returns:
|
|
611
|
+
The loaded plugin instance.
|
|
612
|
+
"""
|
|
613
|
+
# Get per-plugin config from PluginsConfig if available
|
|
614
|
+
plugin_config = config or {}
|
|
615
|
+
if plugin_class.name in self.config.plugins:
|
|
616
|
+
item_config = self.config.plugins[plugin_class.name]
|
|
617
|
+
if not item_config.enabled:
|
|
618
|
+
raise ValueError(f"Plugin is disabled in config: {plugin_class.name}")
|
|
619
|
+
plugin_config = item_config.config
|
|
620
|
+
|
|
621
|
+
# Instantiate
|
|
622
|
+
plugin = plugin_class()
|
|
623
|
+
|
|
624
|
+
# Call lifecycle hook
|
|
625
|
+
try:
|
|
626
|
+
plugin.on_load(plugin_config)
|
|
627
|
+
except Exception as e:
|
|
628
|
+
logger.error(f"Plugin on_load failed for {plugin.name}: {e}")
|
|
629
|
+
raise
|
|
630
|
+
|
|
631
|
+
# Register in registry
|
|
632
|
+
self.registry.register_plugin(plugin)
|
|
633
|
+
|
|
634
|
+
# Track source path for reload support
|
|
635
|
+
if hasattr(plugin_class, "_gobby_source_path"):
|
|
636
|
+
self._plugin_sources[plugin.name] = plugin_class._gobby_source_path
|
|
637
|
+
|
|
638
|
+
logger.info(f"Loaded plugin: {plugin.name} v{plugin.version}")
|
|
639
|
+
return plugin
|
|
640
|
+
|
|
641
|
+
def unload_plugin(self, name: str) -> None:
|
|
642
|
+
"""
|
|
643
|
+
Unload a plugin.
|
|
644
|
+
|
|
645
|
+
Args:
|
|
646
|
+
name: The plugin name to unload.
|
|
647
|
+
"""
|
|
648
|
+
plugin = self.registry.get_plugin(name)
|
|
649
|
+
if plugin is None:
|
|
650
|
+
logger.warning(f"Plugin not found: {name}")
|
|
651
|
+
return
|
|
652
|
+
|
|
653
|
+
# Call lifecycle hook
|
|
654
|
+
try:
|
|
655
|
+
plugin.on_unload()
|
|
656
|
+
except Exception as e:
|
|
657
|
+
logger.error(f"Plugin on_unload failed for {name}: {e}")
|
|
658
|
+
# Continue with unregistration even if on_unload fails
|
|
659
|
+
|
|
660
|
+
# Unregister
|
|
661
|
+
self.registry.unregister_plugin(name)
|
|
662
|
+
|
|
663
|
+
logger.info(f"Unloaded plugin: {name}")
|
|
664
|
+
|
|
665
|
+
def load_all(self) -> list[HookPlugin]:
|
|
666
|
+
"""
|
|
667
|
+
Discover and load all plugins.
|
|
668
|
+
|
|
669
|
+
Returns:
|
|
670
|
+
List of successfully loaded plugins.
|
|
671
|
+
"""
|
|
672
|
+
if not self.config.enabled:
|
|
673
|
+
logger.debug("Plugin system is disabled")
|
|
674
|
+
return []
|
|
675
|
+
|
|
676
|
+
loaded: list[HookPlugin] = []
|
|
677
|
+
|
|
678
|
+
if self.config.auto_discover:
|
|
679
|
+
plugin_classes = self.discover_plugins()
|
|
680
|
+
|
|
681
|
+
for plugin_class in plugin_classes:
|
|
682
|
+
# Check if explicitly disabled
|
|
683
|
+
if plugin_class.name in self.config.plugins:
|
|
684
|
+
if not self.config.plugins[plugin_class.name].enabled:
|
|
685
|
+
logger.debug(f"Skipping disabled plugin: {plugin_class.name}")
|
|
686
|
+
continue
|
|
687
|
+
|
|
688
|
+
try:
|
|
689
|
+
plugin = self.load_plugin(plugin_class)
|
|
690
|
+
loaded.append(plugin)
|
|
691
|
+
except Exception as e:
|
|
692
|
+
logger.error(f"Failed to load plugin {plugin_class.name}: {e}")
|
|
693
|
+
# Continue loading other plugins
|
|
694
|
+
|
|
695
|
+
return loaded
|
|
696
|
+
|
|
697
|
+
def unload_all(self) -> None:
|
|
698
|
+
"""Unload all plugins."""
|
|
699
|
+
plugin_names = list(self.registry._plugins.keys())
|
|
700
|
+
for name in plugin_names:
|
|
701
|
+
try:
|
|
702
|
+
self.unload_plugin(name)
|
|
703
|
+
except Exception as e:
|
|
704
|
+
logger.error(f"Failed to unload plugin {name}: {e}")
|
|
705
|
+
|
|
706
|
+
def reload_plugin(self, name: str) -> HookPlugin | None:
|
|
707
|
+
"""
|
|
708
|
+
Reload a plugin (unload then load).
|
|
709
|
+
|
|
710
|
+
Note: Plugin state is lost on reload.
|
|
711
|
+
|
|
712
|
+
Args:
|
|
713
|
+
name: The plugin name to reload.
|
|
714
|
+
|
|
715
|
+
Returns:
|
|
716
|
+
The reloaded plugin instance, or None if reload failed.
|
|
717
|
+
"""
|
|
718
|
+
plugin = self.registry.get_plugin(name)
|
|
719
|
+
if plugin is None:
|
|
720
|
+
logger.warning(f"Plugin not found for reload: {name}")
|
|
721
|
+
return None
|
|
722
|
+
|
|
723
|
+
# Get source path before unloading (prefer tracked path over name-based key)
|
|
724
|
+
source_path = self._plugin_sources.get(name)
|
|
725
|
+
|
|
726
|
+
# Unload
|
|
727
|
+
self.unload_plugin(name)
|
|
728
|
+
|
|
729
|
+
# Compute module name from source path if available, else fall back to plugin name
|
|
730
|
+
if source_path is not None:
|
|
731
|
+
module_name = f"gobby_plugin_{source_path.stem}"
|
|
732
|
+
else:
|
|
733
|
+
module_name = f"gobby_plugin_{name}"
|
|
734
|
+
|
|
735
|
+
# Clear module cache to force reimport
|
|
736
|
+
if module_name in self._loaded_modules:
|
|
737
|
+
del self._loaded_modules[module_name]
|
|
738
|
+
if module_name in sys.modules:
|
|
739
|
+
del sys.modules[module_name]
|
|
740
|
+
|
|
741
|
+
# Clear source tracking (will be re-added on load)
|
|
742
|
+
if name in self._plugin_sources:
|
|
743
|
+
del self._plugin_sources[name]
|
|
744
|
+
|
|
745
|
+
# Reload from source file if available
|
|
746
|
+
if source_path is not None and source_path.exists():
|
|
747
|
+
try:
|
|
748
|
+
plugin_classes = self._load_module(source_path)
|
|
749
|
+
# Find the plugin class with matching name
|
|
750
|
+
for plugin_class in plugin_classes:
|
|
751
|
+
if plugin_class.name == name:
|
|
752
|
+
return self.load_plugin(plugin_class)
|
|
753
|
+
logger.error(f"Plugin class '{name}' not found in reloaded module")
|
|
754
|
+
return None
|
|
755
|
+
except Exception as e:
|
|
756
|
+
logger.error(f"Failed to reload plugin {name}: {e}")
|
|
757
|
+
return None
|
|
758
|
+
else:
|
|
759
|
+
logger.error(f"Cannot reload plugin {name}: source path not available")
|
|
760
|
+
return None
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
# =============================================================================
|
|
764
|
+
# Handler Execution
|
|
765
|
+
# =============================================================================
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def run_plugin_handlers(
|
|
769
|
+
registry: PluginRegistry,
|
|
770
|
+
event: HookEvent,
|
|
771
|
+
pre: bool = True,
|
|
772
|
+
core_response: HookResponse | None = None,
|
|
773
|
+
) -> HookResponse | None:
|
|
774
|
+
"""
|
|
775
|
+
Execute plugin handlers for an event.
|
|
776
|
+
|
|
777
|
+
Args:
|
|
778
|
+
registry: The plugin registry.
|
|
779
|
+
event: The hook event to process.
|
|
780
|
+
pre: If True, run pre-handlers (priority < 50). If False, run post-handlers.
|
|
781
|
+
core_response: For post-handlers, the response from the core handler.
|
|
782
|
+
|
|
783
|
+
Returns:
|
|
784
|
+
For pre-handlers: HookResponse if any handler blocks, None otherwise.
|
|
785
|
+
For post-handlers: Always None (observe only).
|
|
786
|
+
"""
|
|
787
|
+
handlers = registry.get_handlers(event.event_type, pre_only=pre, post_only=not pre)
|
|
788
|
+
|
|
789
|
+
for handler in handlers:
|
|
790
|
+
try:
|
|
791
|
+
if pre:
|
|
792
|
+
# Pre-handlers can return HookResponse to block
|
|
793
|
+
result = handler.method(event)
|
|
794
|
+
if result is not None and isinstance(result, HookResponse):
|
|
795
|
+
if result.decision in ("deny", "block"):
|
|
796
|
+
logger.info(f"Plugin {handler.plugin.name} blocked event: {result.reason}")
|
|
797
|
+
return HookResponse(
|
|
798
|
+
decision=result.decision,
|
|
799
|
+
reason=result.reason,
|
|
800
|
+
metadata=result.metadata,
|
|
801
|
+
)
|
|
802
|
+
else:
|
|
803
|
+
# Post-handlers receive the core response but can't block
|
|
804
|
+
handler.method(event, core_response)
|
|
805
|
+
|
|
806
|
+
except Exception as e:
|
|
807
|
+
# Fail-open: log error but continue processing
|
|
808
|
+
logger.error(
|
|
809
|
+
f"Plugin handler {handler.plugin.name}.{handler.method.__name__} failed: {e}",
|
|
810
|
+
exc_info=True,
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
return None
|