Skip to main content

runx_cli/
add.rs

1//! GitHub repository indexing CLI: thin orchestrator over the runtime's `/v1/index`
2//! client. This module owns argument resolution (env + plan defaults) and
3//! presentation (human text vs JSON envelope); it owns no parsing, HTTP, or
4//! response shaping logic — those live in `runx_runtime::registry::index`.
5
6use std::collections::BTreeMap;
7use std::process::ExitCode;
8
9use runx_runtime::registry::{
10    GithubRepoRef, IndexGithubRepoOptions, IndexResponse, IndexWarning, IndexedListing,
11    IndexedRepo, TrustTier, index_github_repo, parse_github_repo_ref,
12};
13use serde::Serialize;
14
15use crate::router::AddUrlPlan;
16
17pub fn run_native_add(plan: AddUrlPlan) -> ExitCode {
18    let env = crate::history::env_map();
19    let base_url = resolve_public_api_base_url(&plan, &env);
20
21    let repo_ref = match parse_github_repo_ref(&plan.repo) {
22        Ok(parsed) => parsed,
23        Err(error) => return fail(&error.to_string()),
24    };
25
26    let transport = match crate::public_api::transport(false) {
27        Ok(transport) => transport,
28        Err(error) => return fail(&format!("failed to initialize HTTP transport: {error}")),
29    };
30
31    let options = IndexGithubRepoOptions {
32        base_url: &base_url,
33        repo_url: &repo_ref.canonical_url,
34        repo_ref: plan.repo_ref.as_deref(),
35    };
36
37    match index_github_repo(&transport, &options) {
38        Ok(response) => render_result(plan.json, &repo_ref, &response),
39        Err(error) => fail(&error.to_string()),
40    }
41}
42
43fn resolve_public_api_base_url(plan: &AddUrlPlan, env: &BTreeMap<String, String>) -> String {
44    crate::public_api::resolve_base_url(plan.api_base_url.as_deref(), env)
45}
46
47fn render_result(json: bool, repo_ref: &GithubRepoRef, response: &IndexResponse) -> ExitCode {
48    if json {
49        let envelope = AddUrlJsonResult {
50            status: "success",
51            requested: AddUrlRequestedRef {
52                canonical_url: &repo_ref.canonical_url,
53                owner: &repo_ref.owner,
54                repo: &repo_ref.repo,
55            },
56            repo: &response.repo,
57            listings: &response.listings,
58            warnings: &response.warnings,
59        };
60        match serde_json::to_string_pretty(&envelope) {
61            Ok(serialized) => crate::cli_io::write_stdout_code(&format!("{serialized}\n"), 0),
62            Err(error) => fail(&format!("failed to serialize add result: {error}")),
63        }
64    } else {
65        crate::cli_io::write_stdout_code(&render_text(response), 0)
66    }
67}
68
69fn render_text(response: &IndexResponse) -> String {
70    let mut out = String::new();
71    let sha_short: String = response.repo.sha.chars().take(12).collect();
72    let count = response.listings.len();
73    out.push_str(&format!(
74        "indexed {count} skill{plural} from {owner}/{repo}@{sha}\n\n",
75        plural = if count == 1 { "" } else { "s" },
76        owner = response.repo.owner,
77        repo = response.repo.repo,
78        sha = sha_short,
79    ));
80    for listing in &response.listings {
81        let tag = if listing.digest_unchanged {
82            "(unchanged)"
83        } else {
84            "(new)"
85        };
86        out.push_str(&format!(
87            "  {}@{} · {} {}\n",
88            listing.skill_id,
89            listing.version,
90            trust_tier_label(&listing.trust_tier),
91            tag,
92        ));
93        out.push_str(&format!("    → {}\n", listing.permalink));
94        out.push_str(&format!(
95            "    install: runx add {}@{}\n",
96            listing.skill_id, listing.version,
97        ));
98        out.push_str(&format!("    run:     runx {}\n\n", listing.name));
99    }
100    if !response.warnings.is_empty() {
101        out.push_str("warnings:\n");
102        for warning in &response.warnings {
103            let where_ = warning
104                .skill_path
105                .as_deref()
106                .map(|path| format!(" ({path})"))
107                .unwrap_or_default();
108            out.push_str(&format!(
109                "  - {}{}: {}\n",
110                warning.code, where_, warning.detail,
111            ));
112        }
113        out.push('\n');
114    }
115    out
116}
117
118fn trust_tier_label(tier: &TrustTier) -> &'static str {
119    match tier {
120        TrustTier::FirstParty => "first_party",
121        TrustTier::Verified => "verified",
122        TrustTier::Community => "community",
123    }
124}
125
126fn fail(message: &str) -> ExitCode {
127    let _ignored = crate::cli_io::write_stderr(&format!("runx: {message}\n"));
128    ExitCode::from(1)
129}
130
131#[derive(Serialize)]
132struct AddUrlJsonResult<'a> {
133    status: &'a str,
134    requested: AddUrlRequestedRef<'a>,
135    repo: &'a IndexedRepo,
136    listings: &'a [IndexedListing],
137    warnings: &'a [IndexWarning],
138}
139
140#[derive(Serialize)]
141struct AddUrlRequestedRef<'a> {
142    canonical_url: &'a str,
143    owner: &'a str,
144    repo: &'a str,
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use runx_runtime::registry::{IndexedRepo, TrustTier};
151
152    fn sample_listing(skill_id: &str, version: &str, unchanged: bool) -> IndexedListing {
153        IndexedListing {
154            owner: skill_id.split('/').next().unwrap_or("runxhq").to_owned(),
155            name: skill_id.split('/').nth(1).unwrap_or("demo").to_owned(),
156            skill_id: skill_id.to_owned(),
157            version: version.to_owned(),
158            permalink: format!("https://runx.ai/{skill_id}/{version}"),
159            trust_tier: TrustTier::Community,
160            skill_path: format!("skills/{skill_id}/SKILL.md"),
161            digest_unchanged: unchanged,
162        }
163    }
164
165    fn sample_response(
166        listings: Vec<IndexedListing>,
167        warnings: Vec<IndexWarning>,
168    ) -> IndexResponse {
169        IndexResponse {
170            repo: IndexedRepo {
171                owner: "runxhq".to_owned(),
172                repo: "runx".to_owned(),
173                git_ref: "main".to_owned(),
174                sha: "abcdef0123456789".to_owned(),
175            },
176            listings,
177            warnings,
178        }
179    }
180
181    #[test]
182    fn renders_singular_for_one_listing() {
183        let response = sample_response(vec![sample_listing("runxhq/demo", "sha-1", false)], vec![]);
184        let text = render_text(&response);
185        assert!(text.starts_with("indexed 1 skill from runxhq/runx@abcdef012345\n"));
186        assert!(text.contains("(new)"));
187        assert!(text.contains("install: runx add runxhq/demo@sha-1"));
188    }
189
190    #[test]
191    fn renders_plural_and_unchanged_tag() {
192        let response = sample_response(
193            vec![
194                sample_listing("runxhq/a", "sha-1", true),
195                sample_listing("runxhq/b", "sha-2", false),
196            ],
197            vec![],
198        );
199        let text = render_text(&response);
200        assert!(text.starts_with("indexed 2 skills from runxhq/runx@abcdef012345\n"));
201        assert!(text.contains("(unchanged)"));
202        assert!(text.contains("(new)"));
203    }
204
205    #[test]
206    fn renders_warnings_block_only_when_present() {
207        let bare = sample_response(vec![], vec![]);
208        assert!(!render_text(&bare).contains("warnings:"));
209
210        let warned = sample_response(
211            vec![],
212            vec![IndexWarning {
213                skill_path: Some("skills/foo/SKILL.md".to_owned()),
214                code: "missing_runner".to_owned(),
215                detail: "runner manifest absent".to_owned(),
216            }],
217        );
218        let text = render_text(&warned);
219        assert!(text.contains("warnings:"));
220        assert!(text.contains("missing_runner (skills/foo/SKILL.md): runner manifest absent"));
221    }
222
223    #[test]
224    fn resolves_base_url_in_precedence_order() {
225        let plan_with_override = AddUrlPlan {
226            repo: "https://github.com/runxhq/runx".to_owned(),
227            repo_ref: None,
228            api_base_url: Some("https://override.example/".to_owned()),
229            json: false,
230        };
231        let mut env: BTreeMap<String, String> = BTreeMap::new();
232        env.insert(
233            "RUNX_PUBLIC_API_BASE_URL".to_owned(),
234            "https://from-env.example/".to_owned(),
235        );
236
237        // Plan override wins, trailing slash stripped.
238        assert_eq!(
239            resolve_public_api_base_url(&plan_with_override, &env),
240            "https://override.example",
241        );
242
243        // Without plan override, env takes over.
244        let plan_no_override = AddUrlPlan {
245            repo: "https://github.com/runxhq/runx".to_owned(),
246            repo_ref: None,
247            api_base_url: None,
248            json: false,
249        };
250        assert_eq!(
251            resolve_public_api_base_url(&plan_no_override, &env),
252            "https://from-env.example",
253        );
254
255        // Without either, default.
256        let empty_env: BTreeMap<String, String> = BTreeMap::new();
257        assert_eq!(
258            resolve_public_api_base_url(&plan_no_override, &empty_env),
259            "https://api.runx.ai",
260        );
261    }
262
263    #[test]
264    fn trust_tier_labels_match_snake_case_wire_form() {
265        assert_eq!(trust_tier_label(&TrustTier::FirstParty), "first_party");
266        assert_eq!(trust_tier_label(&TrustTier::Verified), "verified");
267        assert_eq!(trust_tier_label(&TrustTier::Community), "community");
268    }
269}