pinto-cli 0.3.2

A lightweight, local-first, Git-friendly Scrum backlog and Kanban board for the CLI and TUI
Documentation
//! A format for safely receiving operation plans generated by AI agents.
//!
//! pinto does not configure or store AI providers or API keys. An external
//! agent or API creates this JSON; the CLI only executes the existing commands.

use serde::Deserialize;
use serde::Serialize;
use thiserror::Error;

const SAFE_COMMAND_NAMES: &[&str] = &[
    "init",
    "add",
    "a",
    "list",
    "ls",
    "next",
    "n",
    "show",
    "s",
    "move",
    "mv",
    "reorder",
    "ro",
    "edit",
    "e",
    "remove",
    "rm",
    "restore",
    "rs",
    "dep",
    "d",
    "link",
    "ln",
    "dod",
    "dd",
    "export",
    "sprint",
    "sp",
    "board",
    "b",
    "cycletime",
    "ct",
    "rebalance",
    "reb",
    "migrate",
    "mig",
    "doctor",
    "dr",
];

const UNSAFE_COMMAND_NAMES: &[&str] = &[
    "automate",
    "auto",
    "shell",
    "kanban",
    "k",
    "completion",
    // `import` replaces the entire board from a snapshot; it is a manual restore, not an
    // agent-plan step.
    "import",
    // `undo` reverts the most recent mutation; it is a human corrective action, not an agent-plan
    // step (a plan should not reverse its own earlier commands).
    "undo",
];

/// Why an automation plan cannot be run safely.
///
/// The detailed input is omitted so callers can log an error without leaking
/// secret values.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("invalid automation plan")]
pub enum AutomationPlanError {
    /// The JSON format, schema, or permitted command is invalid.
    Invalid,
}

/// A JSON plan of pinto commands to execute sequentially.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AutomationPlan {
    commands: Vec<Vec<String>>,
}

/// Per-command result returned by `pinto automate --json`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AutomationCommandResult {
    /// One-based position in the submitted plan.
    pub index: usize,
    /// Command name without argument values.
    pub command: String,
    /// `valid`, `succeeded`, `failed`, or `skipped`.
    pub status: String,
    /// IDs created by this command, when they can be determined.
    pub created_ids: Vec<String>,
    /// IDs targeted by an update command, when they can be determined.
    pub updated_ids: Vec<String>,
    /// Sanitized error detail for failed or invalid commands.
    pub error: Option<String>,
}

/// Structured automation summary returned by `pinto automate --json`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AutomationReport {
    /// `completed`, `partial_failure`, `invalid`, or `dry_run`.
    pub status: String,
    /// Whether the plan was validated without applying changes.
    pub dry_run: bool,
    /// Results in the same order as the submitted commands.
    pub commands: Vec<AutomationCommandResult>,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawPlan {
    commands: Vec<Vec<String>>,
}

impl AutomationPlan {
    /// Validate and read the JSON object `{\"commands\":[[\"add\", \"...\"], ...]}`.
    ///
    /// Reject unknown fields, so don't accept or store connection information like API keys.
    /// Detailed JSON parsing errors may include input content and are not returned to the caller.
    pub fn parse(input: &str) -> Result<Self, AutomationPlanError> {
        let raw: RawPlan = serde_json::from_str(input).map_err(|_| AutomationPlanError::Invalid)?;
        if raw.commands.is_empty()
            || raw.commands.iter().any(std::vec::Vec::is_empty)
            || raw.commands.iter().any(|command| {
                command
                    .first()
                    .is_some_and(|name| UNSAFE_COMMAND_NAMES.contains(&name.as_str()))
            })
        {
            return Err(AutomationPlanError::Invalid);
        }
        Ok(Self {
            commands: raw.commands,
        })
    }

    /// Return argv lists in execution order.
    #[must_use]
    pub fn commands(&self) -> &[Vec<String>] {
        &self.commands
    }

    /// Return the JSON Schema for the validated automation-plan envelope.
    ///
    /// The schema covers the JSON structure and excludes commands that would recurse into
    /// automation or start an interactive session. Arguments after the command name remain
    /// ordinary strings because the CLI's existing `clap` parser is the source of truth for each
    /// command's complete argument grammar.
    #[must_use]
    pub fn json_schema() -> serde_json::Value {
        serde_json::json!({
            "$schema": "https://json-schema.org/draft/2020-12/schema",
            "title": "pinto automation plan",
            "description": "A non-empty sequence of safe pinto command argv arrays.",
            "type": "object",
            "additionalProperties": false,
            "required": ["commands"],
            "properties": {
                "commands": {
                    "type": "array",
                    "description": "Commands are executed in order after clap validation.",
                    "minItems": 1,
                    "items": {"$ref": "#/$defs/command"}
                }
            },
            "$defs": {
                "command": {
                    "type": "array",
                    "description": "An argv-style pinto command; arguments are strings.",
                    "minItems": 1,
                    "prefixItems": [{
                        "type": "string",
                        "enum": SAFE_COMMAND_NAMES
                    }],
                    "items": {"type": "string"}
                }
            }
        })
    }
}

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

    #[test]
    fn accepts_a_sequence_of_commands() {
        let plan = AutomationPlan::parse(r#"{"commands":[["add","A"],["move","T-1","done"]]}"#)
            .expect("valid plan");
        assert_eq!(plan.commands()[1], ["move", "T-1", "done"]);
    }

    #[test]
    fn rejects_unknown_fields_including_api_keys() {
        assert!(AutomationPlan::parse(r#"{"api_key":"secret","commands":[["list"]]}"#).is_err());
    }

    #[test]
    fn rejects_recursive_or_interactive_commands() {
        for command in ["automate", "auto", "shell", "kanban", "k", "completion"] {
            assert!(AutomationPlan::parse(&format!(r#"{{"commands":[["{command}"]]}}"#)).is_err());
        }
    }

    #[test]
    fn exposes_a_strict_schema_for_safe_command_plans() {
        let schema = AutomationPlan::json_schema();

        assert_eq!(
            schema["$schema"],
            "https://json-schema.org/draft/2020-12/schema"
        );
        assert_eq!(schema["type"], "object");
        assert_eq!(schema["additionalProperties"], false);
        assert_eq!(schema["required"], serde_json::json!(["commands"]));
        assert_eq!(schema["properties"]["commands"]["type"], "array");
        assert_eq!(schema["properties"]["commands"]["minItems"], 1);

        let command = &schema["$defs"]["command"];
        assert_eq!(command["type"], "array");
        assert_eq!(command["minItems"], 1);
        let command_names = command["prefixItems"][0]["enum"]
            .as_array()
            .expect("schema command names are an array");
        for unsafe_command in ["automate", "auto", "shell", "kanban", "k", "completion"] {
            assert!(
                !command_names.iter().any(|name| name == unsafe_command),
                "unsafe command must not be schema-valid: {unsafe_command}"
            );
        }
        assert!(command_names.iter().any(|name| name == "add"));
        assert_eq!(command["items"]["type"], "string");
    }
}