Skip to main content

cyril_core/
kiro_ext.rs

1//! Types specific to Kiro extension notifications and commands.
2//! These are deserialized from `kiro.dev/commands/available` and related
3//! extension notifications that are not part of the standard ACP protocol.
4
5/// A command received from the `kiro.dev/commands/available` extension notification.
6#[derive(Debug, Clone, serde::Deserialize)]
7pub struct KiroExtCommand {
8    pub name: String,
9    #[serde(default)]
10    pub description: String,
11    #[serde(default)]
12    pub input_hint: Option<String>,
13    #[serde(default)]
14    pub meta: Option<KiroCommandMeta>,
15}
16
17/// Metadata for a Kiro command.
18#[derive(Debug, Clone, serde::Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct KiroCommandMeta {
21    /// "selection" requires a dropdown, "panel" needs special rendering.
22    pub input_type: Option<String>,
23    /// Extension method to call for options (e.g. `kiro.dev/commands/model/options`).
24    pub options_method: Option<String>,
25    /// If true, the command is purely local (e.g. /quit).
26    #[serde(default)]
27    pub local: bool,
28}
29
30impl KiroExtCommand {
31    /// Whether this command can be executed via `kiro.dev/commands/execute`.
32    /// Panel commands (like /context, /help) are allowed — they return structured
33    /// data that we display in chat. Only selection commands and local-only
34    /// commands are excluded.
35    pub fn is_executable(&self) -> bool {
36        match &self.meta {
37            None => true,
38            Some(meta) => !meta.local && meta.input_type.as_deref() != Some("selection"),
39        }
40    }
41}
42
43/// Payload for `kiro.dev/commands/available` ext_notification.
44/// We try multiple shapes since the format isn't documented.
45#[derive(serde::Deserialize)]
46#[serde(untagged)]
47pub(crate) enum KiroCommandsPayload {
48    /// `{ "commands": [...] }`
49    Wrapped { commands: Vec<KiroExtCommand> },
50    /// `{ "availableCommands": [...] }` (ACP-style)
51    AcpStyle {
52        #[serde(rename = "availableCommands")]
53        commands: Vec<KiroExtCommand>,
54    },
55    /// Top-level array `[...]`
56    Bare(Vec<KiroExtCommand>),
57}
58
59impl KiroCommandsPayload {
60    pub(crate) fn commands(self) -> Vec<KiroExtCommand> {
61        match self {
62            Self::Wrapped { commands } => commands,
63            Self::AcpStyle { commands } => commands,
64            Self::Bare(commands) => commands,
65        }
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    // -- KiroExtCommand::is_executable() --
74
75    #[test]
76    fn is_executable_no_meta_returns_true() {
77        let cmd = KiroExtCommand {
78            name: "/compact".into(),
79            description: String::new(),
80            input_hint: None,
81            meta: None,
82        };
83        assert!(cmd.is_executable());
84    }
85
86    #[test]
87    fn is_executable_local_command_returns_false() {
88        let cmd = KiroExtCommand {
89            name: "/quit".into(),
90            description: String::new(),
91            input_hint: None,
92            meta: Some(KiroCommandMeta {
93                input_type: None,
94                options_method: None,
95                local: true,
96            }),
97        };
98        assert!(!cmd.is_executable());
99    }
100
101    #[test]
102    fn is_executable_selection_input_type_returns_false() {
103        let cmd = KiroExtCommand {
104            name: "/model".into(),
105            description: String::new(),
106            input_hint: None,
107            meta: Some(KiroCommandMeta {
108                input_type: Some("selection".into()),
109                options_method: Some("_kiro.dev/commands/model/options".into()),
110                local: false,
111            }),
112        };
113        assert!(!cmd.is_executable());
114    }
115
116    #[test]
117    fn is_executable_panel_input_type_returns_true() {
118        let cmd = KiroExtCommand {
119            name: "/context".into(),
120            description: String::new(),
121            input_hint: None,
122            meta: Some(KiroCommandMeta {
123                input_type: Some("panel".into()),
124                options_method: None,
125                local: false,
126            }),
127        };
128        assert!(cmd.is_executable());
129    }
130
131    // -- KiroCommandsPayload deserialization --
132
133    #[test]
134    fn payload_wrapped_shape() {
135        let json = r#"{ "commands": [{ "name": "/compact" }] }"#;
136        let payload: KiroCommandsPayload = serde_json::from_str(json).unwrap();
137        let cmds = payload.commands();
138        assert_eq!(cmds.len(), 1);
139        assert_eq!(cmds[0].name, "/compact");
140    }
141
142    #[test]
143    fn payload_acp_style_shape() {
144        let json = r#"{ "availableCommands": [{ "name": "/help" }] }"#;
145        let payload: KiroCommandsPayload = serde_json::from_str(json).unwrap();
146        let cmds = payload.commands();
147        assert_eq!(cmds.len(), 1);
148        assert_eq!(cmds[0].name, "/help");
149    }
150
151    #[test]
152    fn payload_bare_array_shape() {
153        let json = r#"[{ "name": "/context" }]"#;
154        let payload: KiroCommandsPayload = serde_json::from_str(json).unwrap();
155        let cmds = payload.commands();
156        assert_eq!(cmds.len(), 1);
157        assert_eq!(cmds[0].name, "/context");
158    }
159
160    // -- KiroExtCommand serde defaults --
161
162    #[test]
163    fn command_minimal_deserialization() {
164        let json = r#"{ "name": "/compact" }"#;
165        let cmd: KiroExtCommand = serde_json::from_str(json).unwrap();
166        assert_eq!(cmd.name, "/compact");
167        assert_eq!(cmd.description, "");
168        assert!(cmd.input_hint.is_none());
169        assert!(cmd.meta.is_none());
170    }
171
172    #[test]
173    fn command_full_deserialization() {
174        let json = r#"{
175            "name": "/model",
176            "description": "Switch AI model",
177            "input_hint": "Choose a model",
178            "meta": {
179                "inputType": "selection",
180                "optionsMethod": "_kiro.dev/commands/model/options",
181                "local": true
182            }
183        }"#;
184        let cmd: KiroExtCommand = serde_json::from_str(json).unwrap();
185        assert_eq!(cmd.name, "/model");
186        assert_eq!(cmd.description, "Switch AI model");
187        assert_eq!(cmd.input_hint.as_deref(), Some("Choose a model"));
188        let meta = cmd.meta.unwrap();
189        assert_eq!(meta.input_type.as_deref(), Some("selection"));
190        assert_eq!(
191            meta.options_method.as_deref(),
192            Some("_kiro.dev/commands/model/options")
193        );
194        assert!(meta.local);
195    }
196}