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, PartialEq, Eq)]
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    #[serde(skip_serializing_if = "Option::is_none")]
46    pub dnf: Option<String>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub yum: Option<String>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub emerge: Option<String>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub apk: Option<String>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub zypper: Option<String>,
55    #[serde(rename = "nix-env", skip_serializing_if = "Option::is_none")]
56    pub nix_env: Option<String>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub pip: Option<String>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub npm: Option<String>,
61
62    #[serde(flatten)]
63    #[serde(default)]
64    pub others: std::collections::HashMap<String, String>,
65}
66
67impl InstallInstructions {
68    pub fn get_command(&self, key: &str) -> Option<&String> {
69        match key {
70            "brew" => self.brew.as_ref(),
71            "apt" => self.apt.as_ref(),
72            "pacman" => self.pacman.as_ref(),
73            "cargo" => self.cargo.as_ref(),
74            "scoop" => self.scoop.as_ref(),
75            "dnf" => self.dnf.as_ref(),
76            "yum" => self.yum.as_ref(),
77            "emerge" => self.emerge.as_ref(),
78            "apk" => self.apk.as_ref(),
79            "zypper" => self.zypper.as_ref(),
80            "nix-env" | "nix_env" => self.nix_env.as_ref(),
81            "pip" => self.pip.as_ref(),
82            "npm" => self.npm.as_ref(),
83            _ => self.others.get(key),
84        }
85    }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
89#[serde(untagged)]
90pub enum StringOrArray {
91    Single(String),
92    Multiple(Vec<String>),
93}
94
95#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
96pub struct OsAliases {
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub linux: Option<StringOrArray>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub macos: Option<String>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub windows: Option<String>,
103}
104
105/// The core ACI command contract returned by CmdHub search.
106///
107/// This is the primary data structure that AI Agents consume.
108/// It provides everything needed to discover, understand, and execute a CLI command.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct AciCommandContract {
111    /// Unique identifier (e.g., "org.github.mtoyoda.sl")
112    pub app_id: String,
113    /// Base command name (e.g., "sl")
114    pub name: String,
115    /// Materialized path (e.g., "sl.-l", "gh.pr.create")
116    pub cmd_path: String,
117    /// Hierarchical level
118    pub node_type: NodeType,
119    /// Agent-friendly description
120    pub description: String,
121    /// Security risk rating
122    pub risk_level: RiskLevel,
123    /// Ready-to-execute template (e.g., "sl -l")
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub example_template: Option<String>,
126    /// OS-specific aliases
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub os_aliases: Option<OsAliases>,
129    /// Cross-platform install commands
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub install_instructions: Option<InstallInstructions>,
132    /// Docker container image for isolated execution
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub docker_image: Option<String>,
135    /// Direct URL to official install shell scripts
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub script_url: Option<String>,
138    /// URL of the open-source code repository
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub source_url: Option<String>,
141    /// Popularity score (0.0 to 1.0) derived from distro package counts
142    #[serde(default)]
143    pub popularity: f64,
144    /// True when this contract was parsed from the tool's real `--help` output
145    /// (provenance = 'probe'); false for crawl+LLM-inferred contracts. Agents
146    /// should prefer verified contracts and treat inferred examples with caution.
147    #[serde(default)]
148    pub verified: bool,
149    /// Search confidence rating: "high", "low", or "none"
150    #[serde(default = "default_confidence")]
151    pub confidence: String,
152}
153
154fn default_confidence() -> String {
155    "high".to_string()
156}
157
158/// Metadata about the local offline database.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct DbMetadata {
161    /// ETag for cache validation
162    pub etag: String,
163    /// Database version string
164    pub version: String,
165    /// Last update timestamp (Unix seconds)
166    pub updated_at: i64,
167    /// Total number of indexed apps
168    pub app_count: u64,
169    /// Total number of indexed commands
170    pub command_count: u64,
171}
172
173/// Update check response from the cloud sync endpoint.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct UpdateManifest {
176    /// Current latest version
177    pub version: String,
178    /// ETag for cache validation
179    pub etag: String,
180    /// CDN download URL for the .zst compressed database
181    pub db_url: String,
182    /// CDN download URL for the Ed25519 signature file
183    pub sig_url: String,
184    /// SHA-256 checksum of the .zst file
185    pub sha256: String,
186    /// Sync mode: "full", "incremental", "noop"
187    #[serde(default)]
188    pub mode: Option<String>,
189    /// Server sync timestamp
190    #[serde(default)]
191    pub new_sync_time: Option<i64>,
192}
193
194/// Database record representing a single embedding vector in `commands_vec`.
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct DbCommandVec {
197    pub cmd_path: String,
198    pub embedding: Vec<f32>,
199}
200
201/// Payload package containing delta updates.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct IncrementalSyncPayload {
204    pub apps: Vec<DbApp>,
205    pub arguments: Vec<DbArgument>,
206    pub command_vecs: Vec<DbCommandVec>,
207    #[serde(default)]
208    pub deleted_apps: Vec<String>,
209}
210
211/// Database record representing the `apps` table row.
212#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
213pub struct DbApp {
214    pub app_id: String,
215    pub name: String,
216    pub os_aliases: Option<String>,
217    pub install_instructions: Option<String>,
218    pub popularity: f64,
219}
220
221/// Database record representing the `arguments` table row.
222#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
223pub struct DbArgument {
224    pub cmd_path: String,
225    pub app_id: String,
226    pub node_name: String,
227    pub node_type: String,
228    pub description: String,
229    pub risk_level: String,
230    pub example_template: Option<String>,
231    pub docker_image: Option<String>,
232    pub script_url: Option<String>,
233    pub source_url: Option<String>,
234}
235
236/// Flattened database record representing the JOIN of `arguments` and `apps`.
237///
238/// This provides the exact structure returned by combining a specific
239/// CLI command/argument with its parent app metadata.
240#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
241pub struct DbAciRecord {
242    pub app_id: String,
243    pub name: String,
244    pub cmd_path: String,
245    pub node_type: String,
246    pub description: String,
247    pub risk_level: String,
248    pub example_template: Option<String>,
249    pub os_aliases: Option<String>,
250    pub install_instructions: Option<String>,
251    pub popularity: f64,
252    pub docker_image: Option<String>,
253    pub script_url: Option<String>,
254    pub source_url: Option<String>,
255    /// 'probe' (parsed from real --help) or 'inferred' (crawl+LLM). None when the
256    /// local DB predates the provenance column — treated as 'inferred' (unverified).
257    pub provenance: Option<String>,
258}
259
260impl AciCommandContract {
261    /// Extracts the node name from the cmd_path (the last component after '.')
262    pub fn node_name(&self) -> &str {
263        self.cmd_path.split('.').next_back().unwrap_or(&self.name)
264    }
265
266    /// Converts this contract into offline SQLite database records.
267    pub fn to_db_records(&self) -> Result<(DbApp, DbArgument), crate::error::CmdHubError> {
268        let install_instructions = if let Some(ref inst) = self.install_instructions {
269            Some(serde_json::to_string(inst)?)
270        } else {
271            None
272        };
273
274        let os_aliases = if let Some(ref aliases) = self.os_aliases {
275            Some(serde_json::to_string(aliases)?)
276        } else {
277            None
278        };
279
280        let app = DbApp {
281            app_id: self.app_id.clone(),
282            name: self.name.clone(),
283            os_aliases,
284            install_instructions,
285            popularity: self.popularity,
286        };
287
288        let node_type_str = match self.node_type {
289            NodeType::Root => "root",
290            NodeType::Sub => "sub",
291            NodeType::Arg => "arg",
292        };
293
294        let risk_level_str = match self.risk_level {
295            RiskLevel::Safe => "safe",
296            RiskLevel::Medium => "medium",
297            RiskLevel::Dangerous => "dangerous",
298        };
299
300        let argument = DbArgument {
301            cmd_path: self.cmd_path.clone(),
302            app_id: self.app_id.clone(),
303            node_name: self.node_name().to_string(),
304            node_type: node_type_str.to_string(),
305            description: self.description.clone(),
306            risk_level: risk_level_str.to_string(),
307            example_template: self.example_template.clone(),
308            docker_image: self.docker_image.clone(),
309            script_url: self.script_url.clone(),
310            source_url: self.source_url.clone(),
311        };
312
313        Ok((app, argument))
314    }
315}
316
317impl TryFrom<DbAciRecord> for AciCommandContract {
318    type Error = crate::error::CmdHubError;
319
320    fn try_from(record: DbAciRecord) -> Result<Self, Self::Error> {
321        let node_type = match record.node_type.as_str() {
322            "root" => NodeType::Root,
323            "sub" => NodeType::Sub,
324            "arg" => NodeType::Arg,
325            other => {
326                return Err(crate::error::CmdHubError::Validation(format!(
327                    "Invalid node_type in database: '{}'",
328                    other
329                )))
330            }
331        };
332
333        let risk_level = match record.risk_level.as_str() {
334            "safe" => RiskLevel::Safe,
335            "medium" => RiskLevel::Medium,
336            "dangerous" => RiskLevel::Dangerous,
337            other => {
338                return Err(crate::error::CmdHubError::Validation(format!(
339                    "Invalid risk_level in database: '{}'",
340                    other
341                )))
342            }
343        };
344
345        let install_instructions = if let Some(ref inst_str) = record.install_instructions {
346            if inst_str.trim().is_empty() {
347                None
348            } else {
349                Some(serde_json::from_str(inst_str).map_err(|e| {
350                    crate::error::CmdHubError::Validation(format!(
351                        "Failed to parse install_instructions JSON: {}",
352                        e
353                    ))
354                })?)
355            }
356        } else {
357            None
358        };
359
360        let os_aliases = if let Some(ref alias_str) = record.os_aliases {
361            if alias_str.trim().is_empty() {
362                None
363            } else {
364                Some(serde_json::from_str(alias_str).map_err(|e| {
365                    crate::error::CmdHubError::Validation(format!(
366                        "Failed to parse os_aliases JSON: {}",
367                        e
368                    ))
369                })?)
370            }
371        } else {
372            None
373        };
374
375        Ok(AciCommandContract {
376            app_id: record.app_id,
377            name: record.name,
378            cmd_path: record.cmd_path,
379            node_type,
380            description: record.description,
381            risk_level,
382            example_template: record.example_template,
383            os_aliases,
384            install_instructions,
385            docker_image: record.docker_image,
386            script_url: record.script_url,
387            source_url: record.source_url,
388            popularity: record.popularity,
389            verified: record.provenance.as_deref() == Some("probe"),
390            confidence: "high".to_string(),
391        })
392    }
393}
394
395/// SQL statement to create the physical table `apps`.
396pub const CREATE_APPS_TABLE: &str = r#"
397CREATE TABLE IF NOT EXISTS apps (
398    app_id TEXT PRIMARY KEY,
399    name TEXT NOT NULL,
400    os_aliases TEXT,
401    install_instructions TEXT,
402    popularity REAL DEFAULT 0.0
403);
404"#;
405
406/// SQL statement to create the physical table `arguments`.
407pub const CREATE_ARGUMENTS_TABLE: &str = r#"
408CREATE TABLE IF NOT EXISTS arguments (
409    cmd_path TEXT PRIMARY KEY,
410    app_id TEXT NOT NULL,
411    node_name TEXT NOT NULL,
412    node_type TEXT NOT NULL,
413    description TEXT NOT NULL,
414    risk_level TEXT NOT NULL,
415    example_template TEXT,
416    docker_image TEXT,
417    script_url TEXT,
418    source_url TEXT,
419    provenance TEXT NOT NULL DEFAULT 'inferred',
420    FOREIGN KEY(app_id) REFERENCES apps(app_id) ON DELETE CASCADE
421);
422"#;
423
424/// SQL statement to create the FTS5 virtual table `apps_fts`.
425pub const CREATE_APPS_FTS_TABLE: &str = r#"
426CREATE VIRTUAL TABLE IF NOT EXISTS apps_fts USING fts5(
427    cmd_path UNINDEXED,
428    name,
429    capabilities
430);
431"#;
432
433/// SQL statement to create the sqlite-vec virtual table `commands_vec`.
434pub const CREATE_COMMANDS_VEC_TABLE: &str = r#"
435CREATE VIRTUAL TABLE IF NOT EXISTS commands_vec USING vec0(
436    cmd_path TEXT PRIMARY KEY,
437    embedding float[384]
438);
439"#;
440
441/// The Reciprocal Rank Fusion (RRF) hybrid search query combining FTS5 and sqlite-vec.
442pub const RRF_QUERY: &str = r#"
443WITH fts_rank AS (
444    SELECT cmd_path, row_number() OVER (ORDER BY bm25(apps_fts) ASC) as fts_pos
445    FROM apps_fts WHERE apps_fts MATCH :query
446),
447vec_rank AS (
448    SELECT cmd_path, row_number() OVER (ORDER BY distance ASC) as vec_pos
449    FROM commands_vec
450    WHERE embedding MATCH :query_vector AND k = 100
451)
452SELECT
453    arg.cmd_path, arg.node_name, arg.description, arg.risk_level, arg.example_template,
454    COALESCE(1.0 / (60.0 + fts.fts_pos), 0.0) + COALESCE(1.0 / (60.0 + vec.vec_pos), 0.0) as rrf_score
455FROM arguments arg
456LEFT JOIN fts_rank fts ON arg.cmd_path = fts.cmd_path
457LEFT JOIN vec_rank vec ON arg.cmd_path = vec.cmd_path
458WHERE fts.cmd_path IS NOT NULL OR vec.cmd_path IS NOT NULL
459ORDER BY rrf_score DESC
460LIMIT :limit_num;
461"#;
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn test_aci_serialization_roundtrip() {
469        let contract = AciCommandContract {
470            app_id: "org.github.mtoyoda.sl".to_string(),
471            name: "sl".to_string(),
472            cmd_path: "sl.-l".to_string(),
473            node_type: NodeType::Arg,
474            description: "Display a train moving from left to right".to_string(),
475            risk_level: RiskLevel::Safe,
476            example_template: Some("sl -l".to_string()),
477            os_aliases: Some(OsAliases {
478                linux: Some(StringOrArray::Multiple(vec![
479                    "sl-prompt".to_string(),
480                    "sl".to_string(),
481                ])),
482                macos: Some("sl".to_string()),
483                windows: None,
484            }),
485            install_instructions: None,
486            docker_image: None,
487            script_url: None,
488            source_url: None,
489            popularity: 0.0,
490            verified: false,
491            confidence: "high".to_string(),
492        };
493
494        let json = serde_json::to_string(&contract).unwrap();
495        let deserialized: AciCommandContract = serde_json::from_str(&json).unwrap();
496        assert_eq!(contract.app_id, deserialized.app_id);
497        assert_eq!(contract.cmd_path, deserialized.cmd_path);
498        assert_eq!(contract.risk_level, deserialized.risk_level);
499        assert_eq!(contract.os_aliases, deserialized.os_aliases);
500    }
501
502    #[test]
503    fn test_risk_level_json_values() {
504        assert_eq!(serde_json::to_string(&RiskLevel::Safe).unwrap(), "\"safe\"");
505        assert_eq!(
506            serde_json::to_string(&RiskLevel::Dangerous).unwrap(),
507            "\"dangerous\""
508        );
509    }
510
511    #[test]
512    fn test_db_conversions() {
513        let contract = AciCommandContract {
514            app_id: "org.github.mtoyoda.sl".to_string(),
515            name: "sl".to_string(),
516            cmd_path: "sl.-l".to_string(),
517            node_type: NodeType::Arg,
518            description: "Display a train moving from left to right".to_string(),
519            risk_level: RiskLevel::Safe,
520            example_template: Some("sl -l".to_string()),
521            os_aliases: None,
522            install_instructions: Some(InstallInstructions {
523                brew: Some("brew install sl".to_string()),
524                apt: Some("sudo apt install sl".to_string()),
525                pacman: None,
526                cargo: None,
527                scoop: Some("scoop install sl".to_string()),
528                ..Default::default()
529            }),
530            docker_image: Some("docker.io/library/sl:latest".to_string()),
531            script_url: Some(
532                "https://raw.githubusercontent.com/mtoyoda/sl/master/install.sh".to_string(),
533            ),
534            source_url: Some("https://github.com/mtoyoda/sl".to_string()),
535            popularity: 0.8,
536            verified: false,
537            confidence: "high".to_string(),
538        };
539
540        // Test node_name extraction
541        assert_eq!(contract.node_name(), "-l");
542
543        // Test converting to DB records
544        let (db_app, db_arg) = contract.to_db_records().unwrap();
545        assert_eq!(db_app.app_id, "org.github.mtoyoda.sl");
546        assert_eq!(db_app.name, "sl");
547        assert!(db_app
548            .install_instructions
549            .as_ref()
550            .unwrap()
551            .contains("brew install sl"));
552
553        assert_eq!(db_arg.cmd_path, "sl.-l");
554        assert_eq!(db_arg.app_id, "org.github.mtoyoda.sl");
555        assert_eq!(db_arg.node_name, "-l");
556        assert_eq!(db_arg.node_type, "arg");
557        assert_eq!(db_arg.risk_level, "safe");
558        assert_eq!(db_arg.example_template, Some("sl -l".to_string()));
559        assert_eq!(
560            db_arg.docker_image,
561            Some("docker.io/library/sl:latest".to_string())
562        );
563        assert_eq!(
564            db_arg.script_url,
565            Some("https://raw.githubusercontent.com/mtoyoda/sl/master/install.sh".to_string())
566        );
567        assert_eq!(
568            db_arg.source_url,
569            Some("https://github.com/mtoyoda/sl".to_string())
570        );
571
572        // Test reconstruction from DbAciRecord
573        let db_record = DbAciRecord {
574            app_id: db_app.app_id,
575            name: db_app.name,
576            cmd_path: db_arg.cmd_path,
577            node_type: db_arg.node_type,
578            description: db_arg.description,
579            risk_level: db_arg.risk_level,
580            example_template: db_arg.example_template,
581            os_aliases: db_app.os_aliases,
582            install_instructions: db_app.install_instructions,
583            popularity: db_app.popularity,
584            docker_image: db_arg.docker_image,
585            script_url: db_arg.script_url,
586            source_url: db_arg.source_url,
587            provenance: Some("probe".to_string()),
588        };
589
590        let reconstructed = AciCommandContract::try_from(db_record).unwrap();
591        assert_eq!(reconstructed.app_id, contract.app_id);
592        assert_eq!(reconstructed.cmd_path, contract.cmd_path);
593        assert_eq!(reconstructed.node_type, contract.node_type);
594        assert_eq!(reconstructed.risk_level, contract.risk_level);
595        assert_eq!(
596            reconstructed.install_instructions.as_ref().unwrap().brew,
597            Some("brew install sl".to_string())
598        );
599        assert_eq!(
600            reconstructed.install_instructions.as_ref().unwrap().scoop,
601            Some("scoop install sl".to_string())
602        );
603        assert_eq!(
604            reconstructed.docker_image,
605            Some("docker.io/library/sl:latest".to_string())
606        );
607        assert_eq!(
608            reconstructed.script_url,
609            Some("https://raw.githubusercontent.com/mtoyoda/sl/master/install.sh".to_string())
610        );
611        assert_eq!(
612            reconstructed.source_url,
613            Some("https://github.com/mtoyoda/sl".to_string())
614        );
615    }
616
617    #[test]
618    fn test_install_instructions_flattened_others() {
619        let json_data = r#"{
620            "brew": "brew install git",
621            "dnf": "dnf install -y git",
622            "apk": "apk add git"
623        }"#;
624        let inst: InstallInstructions = serde_json::from_str(json_data).unwrap();
625        assert_eq!(inst.brew.as_deref(), Some("brew install git"));
626        assert_eq!(
627            inst.get_command("brew").map(|s| s.as_str()),
628            Some("brew install git")
629        );
630        assert_eq!(
631            inst.get_command("dnf").map(|s| s.as_str()),
632            Some("dnf install -y git")
633        );
634        assert_eq!(
635            inst.get_command("apk").map(|s| s.as_str()),
636            Some("apk add git")
637        );
638        assert_eq!(inst.get_command("pacman"), None);
639    }
640}