diff --git a/openhands-sdk/openhands/sdk/plugin/plugin.py b/openhands-sdk/openhands/sdk/plugin/plugin.py index d60fdcf5f3..dfd47fa777 100644 --- a/openhands-sdk/openhands/sdk/plugin/plugin.py +++ b/openhands-sdk/openhands/sdk/plugin/plugin.py @@ -87,6 +87,22 @@ def description(self) -> str: """Get the plugin description.""" return self.manifest.description + @property + def entry_slash_command(self) -> str | None: + """Get the full slash command for the entry point, if defined. + + Returns the slash command in format /:, + or None if no entry_command is defined in the manifest. + + Example: + >>> plugin = Plugin.load(path) + >>> plugin.entry_slash_command + '/city-weather:now' + """ + if not self.manifest.entry_command: + return None + return f"/{self.name}:{self.manifest.entry_command}" + def get_all_skills(self) -> list[Skill]: """Get all skills including those converted from commands. diff --git a/openhands-sdk/openhands/sdk/plugin/types.py b/openhands-sdk/openhands/sdk/plugin/types.py index a8cb163f3b..bf35b9e0d8 100644 --- a/openhands-sdk/openhands/sdk/plugin/types.py +++ b/openhands-sdk/openhands/sdk/plugin/types.py @@ -172,6 +172,14 @@ class PluginManifest(BaseModel): version: str = Field(default="1.0.0", description="Plugin version") description: str = Field(default="", description="Plugin description") author: PluginAuthor | None = Field(default=None, description="Plugin author") + entry_command: str | None = Field( + default=None, + description=( + "Default command to invoke when launching this plugin. " + "Should match a command name from the commands/ directory. " + "Example: 'now' for a command defined in commands/now.md" + ), + ) model_config = {"extra": "allow"} @@ -386,6 +394,13 @@ class MarketplacePluginEntry(BaseModel): author: PluginAuthor | None = Field( default=None, description="Plugin author information" ) + entry_command: str | None = Field( + default=None, + description=( + "Default command to invoke when launching this plugin. " + "Should match a command name from the commands/ directory." + ), + ) # Marketplace-specific: source location source: str | MarketplacePluginSource = Field( @@ -491,6 +506,7 @@ def to_plugin_manifest(self) -> PluginManifest: version=self.version or "1.0.0", description=self.description or "", author=self.author, + entry_command=self.entry_command, ) diff --git a/tests/sdk/plugin/test_marketplace.py b/tests/sdk/plugin/test_marketplace.py index 2d51c2b42c..bd4fcb19db 100644 --- a/tests/sdk/plugin/test_marketplace.py +++ b/tests/sdk/plugin/test_marketplace.py @@ -671,6 +671,31 @@ def test_to_plugin_manifest_defaults(self): assert manifest.description == "" # Default assert manifest.author is None + def test_to_plugin_manifest_with_entry_command(self): + """Test to_plugin_manifest preserves entry_command field.""" + entry = MarketplacePluginEntry( + name="city-weather", + source="./plugins/city-weather", + version="1.0.0", + description="Get current weather for any city", + entry_command="now", + ) + + manifest = entry.to_plugin_manifest() + + assert manifest.name == "city-weather" + assert manifest.entry_command == "now" + + def test_entry_with_entry_command(self): + """Test MarketplacePluginEntry with entry_command field.""" + entry = MarketplacePluginEntry( + name="city-weather", + source="./plugins/city-weather", + entry_command="now", + ) + assert entry.name == "city-weather" + assert entry.entry_command == "now" + def test_invalid_github_source_missing_repo(self, tmp_path: Path): """Test that invalid GitHub source (missing repo) raises error at load time.""" marketplace_dir = tmp_path / "invalid-source" diff --git a/tests/sdk/plugin/test_plugin_loading.py b/tests/sdk/plugin/test_plugin_loading.py index d5fc965cf5..24af340185 100644 --- a/tests/sdk/plugin/test_plugin_loading.py +++ b/tests/sdk/plugin/test_plugin_loading.py @@ -38,6 +38,21 @@ def test_manifest_with_author_object(self): assert manifest.author.name == "Test Author" assert manifest.author.email == "test@example.com" + def test_manifest_with_entry_command(self): + """Test parsing manifest with entry_command field.""" + manifest = PluginManifest( + name="city-weather", + version="1.0.0", + entry_command="now", + ) + assert manifest.name == "city-weather" + assert manifest.entry_command == "now" + + def test_manifest_without_entry_command(self): + """Test that entry_command defaults to None.""" + manifest = PluginManifest(name="test-plugin") + assert manifest.entry_command is None + class TestPluginLoading: """Tests for Plugin.load() functionality.""" @@ -229,6 +244,40 @@ def test_load_plugin_with_commands(self, tmp_path: Path): assert "Read" in command.allowed_tools assert "Review the specified code" in command.content + def test_load_plugin_with_entry_command(self, tmp_path: Path): + """Test loading a plugin with entry_command in manifest.""" + plugin_dir = tmp_path / "city-weather" + plugin_dir.mkdir() + manifest_dir = plugin_dir / ".plugin" + manifest_dir.mkdir() + + # Write manifest with entry_command + manifest_file = manifest_dir / "plugin.json" + manifest_file.write_text( + """{ + "name": "city-weather", + "version": "1.0.0", + "description": "Get current weather for any city", + "entry_command": "now" + }""" + ) + + plugin = Plugin.load(plugin_dir) + + assert plugin.name == "city-weather" + assert plugin.manifest.entry_command == "now" + assert plugin.entry_slash_command == "/city-weather:now" + + def test_load_plugin_without_entry_command(self, tmp_path: Path): + """Test that entry_slash_command returns None when no entry_command is set.""" + plugin_dir = tmp_path / "test-plugin" + plugin_dir.mkdir() + + plugin = Plugin.load(plugin_dir) + + assert plugin.manifest.entry_command is None + assert plugin.entry_slash_command is None + def test_command_to_skill_conversion(self, tmp_path: Path): """Test converting a command to a keyword-triggered skill.""" from openhands.sdk.context.skills.trigger import KeywordTrigger