agecli-skill-protocol 0.1.0

Wire protocol contract for agecli skill ↔ host UDS communication (binary framing + payload types)
Documentation
//! Skill message types and payload structures for binary UDS protocol.
//!
//! Wire format: `[type: u32 BE][length: u32 BE][payload: JSON bytes]`

use serde::{Deserialize, Serialize};
use std::collections::HashMap;

/// Message types for skill ↔ server communication.
///
/// Values 1–127 are server→skill (requests), 128+ are skill→server (responses).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum SkillMessageType {
    // Server → Skill
    Execute = 1,
    Cancel = 2,
    StdinData = 3,
    Resize = 4,
    Signal = 5,
    StartSession = 6,
    Shutdown = 7,

    // Skill → Server
    Ack = 128,
    StdoutChunk = 129,
    StderrChunk = 130,
    Progress = 131,
    Completed = 132,
    Error = 133,
    SessionStarted = 134,
}

impl SkillMessageType {
    pub fn from_u32(v: u32) -> Option<Self> {
        match v {
            1 => Some(Self::Execute),
            2 => Some(Self::Cancel),
            3 => Some(Self::StdinData),
            4 => Some(Self::Resize),
            5 => Some(Self::Signal),
            6 => Some(Self::StartSession),
            7 => Some(Self::Shutdown),
            128 => Some(Self::Ack),
            129 => Some(Self::StdoutChunk),
            130 => Some(Self::StderrChunk),
            131 => Some(Self::Progress),
            132 => Some(Self::Completed),
            133 => Some(Self::Error),
            134 => Some(Self::SessionStarted),
            _ => None,
        }
    }
}

/// A raw skill message with type and payload bytes.
#[derive(Debug, Clone)]
pub struct SkillMessage {
    pub msg_type: SkillMessageType,
    pub payload: Vec<u8>,
}

impl SkillMessage {
    pub fn new(msg_type: SkillMessageType, payload: Vec<u8>) -> Self {
        Self { msg_type, payload }
    }

    /// Deserialize the JSON payload into a typed struct.
    pub fn deserialize_payload<T: for<'de> Deserialize<'de>>(&self) -> Option<T> {
        serde_json::from_slice(&self.payload).ok()
    }
}

// ─── Payload types ──────────────────────────────────────────────────────────

/// Server→Skill: execute a command.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ExecutePayload {
    pub execution_id: String,
    pub command_name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub command: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub args: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub working_directory: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub environment: Option<HashMap<String, String>>,
    #[serde(default)]
    pub timeout_ms: i32,
}

/// Server→Skill: start an interactive session (PTY).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct StartSessionPayload {
    pub session_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub shell: Option<String>,
    #[serde(default = "default_rows")]
    pub rows: i32,
    #[serde(default = "default_cols")]
    pub cols: i32,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub working_directory: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub environment: Option<HashMap<String, String>>,
}

fn default_rows() -> i32 {
    24
}
fn default_cols() -> i32 {
    80
}

/// Server→Skill: stdin data for an active execution or session.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct StdinDataPayload {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    #[serde(with = "base64_bytes")]
    pub data: Vec<u8>,
}

/// Server→Skill: resize terminal.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ResizePayload {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub session_id: Option<String>,
    pub rows: i32,
    pub cols: i32,
}

/// Server→Skill: send signal to process.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct SignalPayload {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    pub signal_number: i32,
}

/// Skill→Server: acknowledge an Execute/StartSession request.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct AckPayload {
    pub id: String,
    pub accepted: bool,
}

/// Skill→Server: a chunk of stdout or stderr data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct DataChunkPayload {
    pub id: String,
    pub seq: u64,
    #[serde(with = "base64_bytes")]
    pub data: Vec<u8>,
}

/// Skill→Server: progress update.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ProgressPayload {
    pub id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub percent: Option<i32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub stage: Option<String>,
}

/// Skill→Server: execution completed.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct CompletedPayload {
    pub id: String,
    pub exit_code: i32,
    pub status: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    pub finished_at_unix: i64,
}

/// Skill→Server: error response.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ErrorPayload {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    pub code: String,
    pub message: String,
}

/// Skill→Server: interactive session started.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct SessionStartedPayload {
    pub id: String,
    pub status: String,
}

// ─── Base64 byte serde helper ───────────────────────────────────────────────

mod base64_bytes {
    use base64::prelude::*;
    use serde::{self, Deserialize, Deserializer, Serializer};

    pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        serializer.serialize_str(&BASE64_STANDARD.encode(bytes))
    }

    pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
    where
        D: Deserializer<'de>,
    {
        let s = String::deserialize(deserializer)?;
        BASE64_STANDARD.decode(&s).map_err(serde::de::Error::custom)
    }
}

#[cfg(test)]
#[path = "message_tests.rs"]
mod tests;