Skip to main content

cmdhub_shared/
aci.rs

1//! ACI (Agent-Computer Interface) schema types.
2//!
3//! These types define the machine-readable contract that CmdHub returns
4//! to AI Agents for CLI command discovery and execution.
5
6use serde::{Deserialize, Serialize};
7
8/// The hierarchical level of a command node.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum NodeType {
12    /// Root command (e.g., `tar`, `git`)
13    Root,
14    /// Sub-command (e.g., `git commit`, `tar create`)
15    Sub,
16    /// Argument/flag (e.g., `--verbose`, `-f`)
17    Arg,
18}
19
20/// Security risk level for execution gating.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum RiskLevel {
24    /// Read-only operations, no side effects.
25    Safe,
26    /// Local file modifications, network requests.
27    Medium,
28    /// Destructive deletions, privilege escalations.
29    Dangerous,
30}
31
32/// Cross-platform installation instructions.
33#[derive(Debug, Clone, Default, Serialize, Deserialize)]
34pub struct InstallInstructions {
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub brew: Option<String>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub apt: Option<String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub pacman: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub cargo: Option<String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub scoop: Option<String>,
45
46    #[serde(flatten)]
47    #[serde(default)]
48    pub others: std::collections::HashMap<String, String>,
49}
50
51impl InstallInstructions {
52    pub fn get_command(&self, key: &str) -> Option<&String> {
53        match key {
54            "brew" => self.brew.as_ref(),
55            "apt" => self.apt.as_ref(),
56            "pacman" => self.pacman.as_ref(),
57            "cargo" => self.cargo.as_ref(),
58            "scoop" => self.scoop.as_ref(),
59            _ => self.others.get(key),
60        }
61    }
62}
63
64/// The core ACI command contract returned by CmdHub search.
65///
66/// This is the primary data structure that AI Agents consume.
67/// It provides everything needed to discover, understand, and execute a CLI command.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct AciCommandContract {
70    /// Unique identifier (e.g., "org.github.mtoyoda.sl")
71    pub app_id: String,
72    /// Base command name (e.g., "sl")
73    pub name: String,
74    /// Materialized path (e.g., "sl.-l", "gh.pr.create")
75    pub cmd_path: String,
76    /// Hierarchical level
77    pub node_type: NodeType,
78    /// Agent-friendly description
79    pub description: String,
80    /// Security risk rating
81    pub risk_level: RiskLevel,
82    /// Ready-to-execute template (e.g., "sl -l")
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub example_template: Option<String>,
85    /// Cross-platform install commands
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub install_instructions: Option<InstallInstructions>,
88    /// Docker container image for isolated execution
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub docker_image: Option<String>,
91    /// Direct URL to official install shell scripts
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub script_url: Option<String>,
94    /// URL of the open-source code repository
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub source_url: Option<String>,
97}
98
99/// Metadata about the local offline database.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct DbMetadata {
102    /// ETag for cache validation
103    pub etag: String,
104    /// Database version string
105    pub version: String,
106    /// Last update timestamp (Unix seconds)
107    pub updated_at: i64,
108    /// Total number of indexed apps
109    pub app_count: u64,
110    /// Total number of indexed commands
111    pub command_count: u64,
112}
113
114/// Update check response from the cloud sync endpoint.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct UpdateManifest {
117    /// Current latest version
118    pub version: String,
119    /// ETag for cache validation
120    pub etag: String,
121    /// CDN download URL for the .zst compressed database
122    pub db_url: String,
123    /// CDN download URL for the Ed25519 signature file
124    pub sig_url: String,
125    /// SHA-256 checksum of the .zst file
126    pub sha256: String,
127}
128
129/// Database record representing the `apps` table row.
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131pub struct DbApp {
132    pub app_id: String,
133    pub name: String,
134    pub install_instructions: Option<String>,
135}
136
137/// Database record representing the `arguments` table row.
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
139pub struct DbArgument {
140    pub cmd_path: String,
141    pub app_id: String,
142    pub node_name: String,
143    pub node_type: String,
144    pub description: String,
145    pub risk_level: String,
146    pub example_template: Option<String>,
147    pub docker_image: Option<String>,
148    pub script_url: Option<String>,
149    pub source_url: Option<String>,
150}
151
152/// Flattened database record representing the JOIN of `arguments` and `apps`.
153///
154/// This provides the exact structure returned by combining a specific
155/// CLI command/argument with its parent app metadata.
156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
157pub struct DbAciRecord {
158    pub app_id: String,
159    pub name: String,
160    pub cmd_path: String,
161    pub node_type: String,
162    pub description: String,
163    pub risk_level: String,
164    pub example_template: Option<String>,
165    pub install_instructions: Option<String>,
166    pub docker_image: Option<String>,
167    pub script_url: Option<String>,
168    pub source_url: Option<String>,
169}
170
171impl AciCommandContract {
172    /// Extracts the node name from the cmd_path (the last component after '.')
173    pub fn node_name(&self) -> &str {
174        self.cmd_path.split('.').next_back().unwrap_or(&self.name)
175    }
176
177    /// Converts this contract into offline SQLite database records.
178    pub fn to_db_records(&self) -> Result<(DbApp, DbArgument), crate::error::CmdHubError> {
179        let install_instructions = if let Some(ref inst) = self.install_instructions {
180            Some(serde_json::to_string(inst)?)
181        } else {
182            None
183        };
184
185        let app = DbApp {
186            app_id: self.app_id.clone(),
187            name: self.name.clone(),
188            install_instructions,
189        };
190
191        let node_type_str = match self.node_type {
192            NodeType::Root => "root",
193            NodeType::Sub => "sub",
194            NodeType::Arg => "arg",
195        };
196
197        let risk_level_str = match self.risk_level {
198            RiskLevel::Safe => "safe",
199            RiskLevel::Medium => "medium",
200            RiskLevel::Dangerous => "dangerous",
201        };
202
203        let argument = DbArgument {
204            cmd_path: self.cmd_path.clone(),
205            app_id: self.app_id.clone(),
206            node_name: self.node_name().to_string(),
207            node_type: node_type_str.to_string(),
208            description: self.description.clone(),
209            risk_level: risk_level_str.to_string(),
210            example_template: self.example_template.clone(),
211            docker_image: self.docker_image.clone(),
212            script_url: self.script_url.clone(),
213            source_url: self.source_url.clone(),
214        };
215
216        Ok((app, argument))
217    }
218}
219
220impl TryFrom<DbAciRecord> for AciCommandContract {
221    type Error = crate::error::CmdHubError;
222
223    fn try_from(record: DbAciRecord) -> Result<Self, Self::Error> {
224        let node_type = match record.node_type.as_str() {
225            "root" => NodeType::Root,
226            "sub" => NodeType::Sub,
227            "arg" => NodeType::Arg,
228            other => {
229                return Err(crate::error::CmdHubError::Validation(format!(
230                    "Invalid node_type in database: '{}'",
231                    other
232                )))
233            }
234        };
235
236        let risk_level = match record.risk_level.as_str() {
237            "safe" => RiskLevel::Safe,
238            "medium" => RiskLevel::Medium,
239            "dangerous" => RiskLevel::Dangerous,
240            other => {
241                return Err(crate::error::CmdHubError::Validation(format!(
242                    "Invalid risk_level in database: '{}'",
243                    other
244                )))
245            }
246        };
247
248        let install_instructions = if let Some(ref inst_str) = record.install_instructions {
249            if inst_str.trim().is_empty() {
250                None
251            } else {
252                Some(serde_json::from_str(inst_str).map_err(|e| {
253                    crate::error::CmdHubError::Validation(format!(
254                        "Failed to parse install_instructions JSON: {}",
255                        e
256                    ))
257                })?)
258            }
259        } else {
260            None
261        };
262
263        Ok(AciCommandContract {
264            app_id: record.app_id,
265            name: record.name,
266            cmd_path: record.cmd_path,
267            node_type,
268            description: record.description,
269            risk_level,
270            example_template: record.example_template,
271            install_instructions,
272            docker_image: record.docker_image,
273            script_url: record.script_url,
274            source_url: record.source_url,
275        })
276    }
277}
278
279/// SQL statement to create the physical table `apps`.
280pub const CREATE_APPS_TABLE: &str = r#"
281CREATE TABLE IF NOT EXISTS apps (
282    app_id TEXT PRIMARY KEY,
283    name TEXT NOT NULL,
284    install_instructions TEXT
285);
286"#;
287
288/// SQL statement to create the physical table `arguments`.
289pub const CREATE_ARGUMENTS_TABLE: &str = r#"
290CREATE TABLE IF NOT EXISTS arguments (
291    cmd_path TEXT PRIMARY KEY,
292    app_id TEXT NOT NULL,
293    node_name TEXT NOT NULL,
294    node_type TEXT NOT NULL,
295    description TEXT NOT NULL,
296    risk_level TEXT NOT NULL,
297    example_template TEXT,
298    docker_image TEXT,
299    script_url TEXT,
300    source_url TEXT,
301    FOREIGN KEY(app_id) REFERENCES apps(app_id) ON DELETE CASCADE
302);
303"#;
304
305/// SQL statement to create the FTS5 virtual table `apps_fts`.
306pub const CREATE_APPS_FTS_TABLE: &str = r#"
307CREATE VIRTUAL TABLE IF NOT EXISTS apps_fts USING fts5(
308    cmd_path UNINDEXED,
309    name,
310    capabilities
311);
312"#;
313
314/// SQL statement to create the sqlite-vec virtual table `commands_vec`.
315pub const CREATE_COMMANDS_VEC_TABLE: &str = r#"
316CREATE VIRTUAL TABLE IF NOT EXISTS commands_vec USING vec0(
317    cmd_path TEXT PRIMARY KEY,
318    embedding float[512]
319);
320"#;
321
322/// The Reciprocal Rank Fusion (RRF) hybrid search query combining FTS5 and sqlite-vec.
323pub const RRF_QUERY: &str = r#"
324WITH fts_rank AS (
325    SELECT cmd_path, row_number() OVER (ORDER BY bm25(apps_fts) ASC) as fts_pos
326    FROM apps_fts WHERE apps_fts MATCH :query
327),
328vec_rank AS (
329    SELECT cmd_path, row_number() OVER (ORDER BY distance ASC) as vec_pos
330    FROM commands_vec
331    WHERE embedding MATCH :query_vector AND k = 100
332)
333SELECT
334    arg.cmd_path, arg.node_name, arg.description, arg.risk_level, arg.example_template,
335    COALESCE(1.0 / (60.0 + fts.fts_pos), 0.0) + COALESCE(1.0 / (60.0 + vec.vec_pos), 0.0) as rrf_score
336FROM arguments arg
337LEFT JOIN fts_rank fts ON arg.cmd_path = fts.cmd_path
338LEFT JOIN vec_rank vec ON arg.cmd_path = vec.cmd_path
339WHERE fts.cmd_path IS NOT NULL OR vec.cmd_path IS NOT NULL
340ORDER BY rrf_score DESC
341LIMIT :limit_num;
342"#;
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn test_aci_serialization_roundtrip() {
350        let contract = AciCommandContract {
351            app_id: "org.github.mtoyoda.sl".to_string(),
352            name: "sl".to_string(),
353            cmd_path: "sl.-l".to_string(),
354            node_type: NodeType::Arg,
355            description: "Display a train moving from left to right".to_string(),
356            risk_level: RiskLevel::Safe,
357            example_template: Some("sl -l".to_string()),
358            install_instructions: None,
359            docker_image: None,
360            script_url: None,
361            source_url: None,
362        };
363
364        let json = serde_json::to_string(&contract).unwrap();
365        let deserialized: AciCommandContract = serde_json::from_str(&json).unwrap();
366        assert_eq!(contract.app_id, deserialized.app_id);
367        assert_eq!(contract.cmd_path, deserialized.cmd_path);
368        assert_eq!(contract.risk_level, deserialized.risk_level);
369    }
370
371    #[test]
372    fn test_risk_level_json_values() {
373        assert_eq!(serde_json::to_string(&RiskLevel::Safe).unwrap(), "\"safe\"");
374        assert_eq!(
375            serde_json::to_string(&RiskLevel::Dangerous).unwrap(),
376            "\"dangerous\""
377        );
378    }
379
380    #[test]
381    fn test_db_conversions() {
382        let contract = AciCommandContract {
383            app_id: "org.github.mtoyoda.sl".to_string(),
384            name: "sl".to_string(),
385            cmd_path: "sl.-l".to_string(),
386            node_type: NodeType::Arg,
387            description: "Display a train moving from left to right".to_string(),
388            risk_level: RiskLevel::Safe,
389            example_template: Some("sl -l".to_string()),
390            install_instructions: Some(InstallInstructions {
391                brew: Some("brew install sl".to_string()),
392                apt: Some("sudo apt install sl".to_string()),
393                pacman: None,
394                cargo: None,
395                scoop: Some("scoop install sl".to_string()),
396                ..Default::default()
397            }),
398            docker_image: Some("docker.io/library/sl:latest".to_string()),
399            script_url: Some(
400                "https://raw.githubusercontent.com/mtoyoda/sl/master/install.sh".to_string(),
401            ),
402            source_url: Some("https://github.com/mtoyoda/sl".to_string()),
403        };
404
405        // Test node_name extraction
406        assert_eq!(contract.node_name(), "-l");
407
408        // Test converting to DB records
409        let (db_app, db_arg) = contract.to_db_records().unwrap();
410        assert_eq!(db_app.app_id, "org.github.mtoyoda.sl");
411        assert_eq!(db_app.name, "sl");
412        assert!(db_app
413            .install_instructions
414            .as_ref()
415            .unwrap()
416            .contains("brew install sl"));
417
418        assert_eq!(db_arg.cmd_path, "sl.-l");
419        assert_eq!(db_arg.app_id, "org.github.mtoyoda.sl");
420        assert_eq!(db_arg.node_name, "-l");
421        assert_eq!(db_arg.node_type, "arg");
422        assert_eq!(db_arg.risk_level, "safe");
423        assert_eq!(db_arg.example_template, Some("sl -l".to_string()));
424        assert_eq!(
425            db_arg.docker_image,
426            Some("docker.io/library/sl:latest".to_string())
427        );
428        assert_eq!(
429            db_arg.script_url,
430            Some("https://raw.githubusercontent.com/mtoyoda/sl/master/install.sh".to_string())
431        );
432        assert_eq!(
433            db_arg.source_url,
434            Some("https://github.com/mtoyoda/sl".to_string())
435        );
436
437        // Test reconstruction from DbAciRecord
438        let db_record = DbAciRecord {
439            app_id: db_app.app_id,
440            name: db_app.name,
441            cmd_path: db_arg.cmd_path,
442            node_type: db_arg.node_type,
443            description: db_arg.description,
444            risk_level: db_arg.risk_level,
445            example_template: db_arg.example_template,
446            install_instructions: db_app.install_instructions,
447            docker_image: db_arg.docker_image,
448            script_url: db_arg.script_url,
449            source_url: db_arg.source_url,
450        };
451
452        let reconstructed = AciCommandContract::try_from(db_record).unwrap();
453        assert_eq!(reconstructed.app_id, contract.app_id);
454        assert_eq!(reconstructed.cmd_path, contract.cmd_path);
455        assert_eq!(reconstructed.node_type, contract.node_type);
456        assert_eq!(reconstructed.risk_level, contract.risk_level);
457        assert_eq!(
458            reconstructed.install_instructions.as_ref().unwrap().brew,
459            Some("brew install sl".to_string())
460        );
461        assert_eq!(
462            reconstructed.install_instructions.as_ref().unwrap().scoop,
463            Some("scoop install sl".to_string())
464        );
465        assert_eq!(
466            reconstructed.docker_image,
467            Some("docker.io/library/sl:latest".to_string())
468        );
469        assert_eq!(
470            reconstructed.script_url,
471            Some("https://raw.githubusercontent.com/mtoyoda/sl/master/install.sh".to_string())
472        );
473        assert_eq!(
474            reconstructed.source_url,
475            Some("https://github.com/mtoyoda/sl".to_string())
476        );
477    }
478
479    #[test]
480    fn test_install_instructions_flattened_others() {
481        let json_data = r#"{
482            "brew": "brew install git",
483            "dnf": "dnf install -y git",
484            "apk": "apk add git"
485        }"#;
486        let inst: InstallInstructions = serde_json::from_str(json_data).unwrap();
487        assert_eq!(inst.brew.as_deref(), Some("brew install git"));
488        assert_eq!(
489            inst.get_command("brew").map(|s| s.as_str()),
490            Some("brew install git")
491        );
492        assert_eq!(
493            inst.get_command("dnf").map(|s| s.as_str()),
494            Some("dnf install -y git")
495        );
496        assert_eq!(
497            inst.get_command("apk").map(|s| s.as_str()),
498            Some("apk add git")
499        );
500        assert_eq!(inst.get_command("pacman"), None);
501    }
502}