progit-plugin-sdk 0.2.1

Plugin SDK for ProGit — sandboxed LuaJIT runtime with capability-based security. LSL-1.0 (file-level copyleft, proprietary plugins allowed via the commercial bridge).
Documentation
// SPDX-License-Identifier: LSL-1.0
// Copyright (c) 2026 Markus Maiwald

//! Plugin contribution contract.
//!
//! Contributions are the stable surface a plugin exposes to ProGit. Hooks say
//! what code can receive; contributions say what the host may show, route, and
//! integrate into command palettes or TUI surfaces.

use serde::{Deserialize, Serialize};

/// Manifest envelope used when only contribution metadata is needed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PluginContributionManifest {
    #[serde(default)]
    pub name: String,
    #[serde(default)]
    pub description: String,
    #[serde(default)]
    pub contributions: PluginContributions,
}

impl PluginContributionManifest {
    /// Parse contribution metadata from a `.progit-plugin.json` document.
    pub fn from_json(input: &str) -> serde_json::Result<Self> {
        serde_json::from_str(input)
    }
}

/// Contributions exposed by a plugin.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PluginContributions {
    /// Command namespaces the plugin owns under `prog plugin <name> ...`.
    #[serde(default)]
    pub commands: Vec<CommandContribution>,
}

impl PluginContributions {
    /// Find the command contribution matching a command namespace.
    pub fn command(&self, name: &str) -> Option<&CommandContribution> {
        self.commands.iter().find(|command| command.matches(name))
    }
}

/// A command namespace exposed by a plugin.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommandContribution {
    /// Command namespace, e.g. `sober`.
    pub name: String,
    /// Human title for palettes and menus.
    #[serde(default)]
    pub title: Option<String>,
    /// Human description for palettes and menus.
    #[serde(default)]
    pub description: String,
    /// Runtime entrypoint. Currently `on_command`.
    #[serde(default = "default_command_entrypoint")]
    pub entrypoint: String,
    /// Argument contract for the namespace.
    #[serde(default)]
    pub args: CommandArgs,
    /// Additional command names that route to the same entrypoint.
    #[serde(default)]
    pub aliases: Vec<String>,
    /// Whether this command appears in the fuzzy palette.
    #[serde(default = "default_true")]
    pub palette: bool,
    /// TUI display hints.
    #[serde(default)]
    pub tui: CommandTuiContribution,
}

impl CommandContribution {
    /// Does this contribution own `name`?
    pub fn matches(&self, name: &str) -> bool {
        self.name == name || self.aliases.iter().any(|alias| alias == name)
    }

    /// Title to show in palettes and menus.
    pub fn display_title(&self) -> &str {
        self.title.as_deref().unwrap_or(&self.name)
    }
}

/// Argument contract for a command contribution.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum CommandArgs {
    /// No arguments are expected.
    #[default]
    None,
    /// Plugin owns a fixed command grammar.
    Fixed,
    /// Plugin receives arbitrary argv after the command namespace.
    Passthrough,
}

/// TUI display hints for a command contribution.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommandTuiContribution {
    /// How ProGit should show command output.
    #[serde(default)]
    pub show_output: CommandOutputMode,
}

impl Default for CommandTuiContribution {
    fn default() -> Self {
        Self {
            show_output: CommandOutputMode::Modal,
        }
    }
}

/// Output presentation mode for a command contribution.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum CommandOutputMode {
    /// Show output in a dismissible modal.
    #[default]
    Modal,
    /// Show output inline in command status surfaces.
    Inline,
    /// Do not show command output unless the command fails.
    Silent,
}

fn default_command_entrypoint() -> String {
    "on_command".to_string()
}

fn default_true() -> bool {
    true
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_command_contributions() {
        let manifest = PluginContributionManifest::from_json(
            r#"{
                "name": "sober-raccoon",
                "description": "Sober cockpit",
                "contributions": {
                    "commands": [
                        {
                            "name": "sober",
                            "title": "Sober",
                            "description": "Run Sober",
                            "args": "passthrough",
                            "aliases": ["sober-raccoon"]
                        }
                    ]
                }
            }"#,
        )
        .unwrap();

        let command = manifest.contributions.command("sober-raccoon").unwrap();
        assert_eq!(command.name, "sober");
        assert_eq!(command.display_title(), "Sober");
        assert_eq!(command.args, CommandArgs::Passthrough);
        assert_eq!(command.tui.show_output, CommandOutputMode::Modal);
    }

    #[test]
    fn ignores_root_commands() {
        let manifest = PluginContributionManifest::from_json(
            r#"{
                "name": "legacy",
                "commands": ["old"]
            }"#,
        )
        .unwrap();

        assert!(manifest.contributions.commands.is_empty());
    }
}