1use std::collections::BTreeMap;
5use std::env;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::process::ExitCode;
9
10use runx_runtime::ConfigError;
11use runx_runtime::registry::{
12 AcquireOptions, FileRegistryStore, IngestSkillOptions, InstallCandidate,
13 InstallLocalSkillOptions, LocalRegistryClient, PublishSkillMarkdownOptions, RegistryClient,
14 RegistryManifestSourceAuthority, RegistryPackageFile, RegistryResolveOptions,
15 RegistrySearchOptions, RegistrySkillResolution, TrustTier, TrustedRegistryManifestKey,
16 install_local_skill, publish_skill_markdown, read_registry_skill, resolve_registry_skill,
17 search_registry_with_options,
18};
19use runx_runtime::scaffold::{InitGeneratedValues, ensure_runx_install_state};
20
21mod output;
22mod package;
23mod remote_publish;
24mod target;
25
26pub(crate) use target::{
27 RegistryTarget, destination_root, official_skills_cache_root, registry_skills_cache_root,
28 registry_source_description, resolve_registry_target,
29};
30
31#[derive(Debug, Eq, PartialEq)]
32pub enum RegistryAction {
33 Search,
34 Read,
35 Resolve,
36 Install,
37 Publish,
38}
39
40#[derive(Debug, Eq, PartialEq)]
41pub struct RegistryPlan {
42 pub action: RegistryAction,
43 pub subject: String,
44 pub registry: Option<String>,
45 pub registry_dir: Option<PathBuf>,
46 pub version: Option<String>,
47 pub expected_digest: Option<String>,
48 pub destination: Option<PathBuf>,
49 pub owner: Option<String>,
50 pub profile: Option<PathBuf>,
51 pub trust_tier: Option<TrustTier>,
52 pub limit: Option<usize>,
53 pub upsert: bool,
54 pub json: bool,
55}
56
57pub fn run_native_registry(plan: RegistryPlan) -> ExitCode {
58 let json = plan.json;
59 match run_registry(plan) {
60 Ok(output) => crate::cli_io::write_stdout_code(&output.stdout, output.exit_code),
61 Err(error) => {
62 if json {
63 return crate::cli_io::write_stdout_code(
64 &crate::router::json_failure_output(&error.message, error.code()),
65 error.exit_code,
66 );
67 }
68 let _ignored = crate::cli_io::write_stderr(&format!("\n ✗ {}\n\n", error.message));
69 ExitCode::from(error.exit_code)
70 }
71 }
72}
73
74struct RegistryCliOutput {
75 stdout: String,
76 exit_code: u8,
77}
78
79fn run_registry(plan: RegistryPlan) -> Result<RegistryCliOutput, RegistryCliError> {
80 let env = env_map();
81 let cwd = env::current_dir().map_err(|error| internal_error(error.to_string()))?;
82 let target = resolve_registry_target(&plan, &env, &cwd);
83 match plan.action {
84 RegistryAction::Search => run_search(plan, target),
85 RegistryAction::Read => run_read(plan, target),
86 RegistryAction::Resolve => run_resolve(plan, target),
87 RegistryAction::Install => run_install(plan, target, &env, &cwd),
88 RegistryAction::Publish => run_publish(plan, target, &env, &cwd),
89 }
90}
91
92fn run_search(
93 plan: RegistryPlan,
94 target: RegistryTarget,
95) -> Result<RegistryCliOutput, RegistryCliError> {
96 let source = target.label();
97 let query = plan.subject;
98 let results = match target {
99 RegistryTarget::Remote { registry_url } => RegistryClient::new(®istry_url)?
100 .search_with_limit(&query, plan.limit.unwrap_or(20))?,
101 RegistryTarget::Local {
102 registry_path,
103 registry_url,
104 ..
105 } => search_registry_with_options(
106 &FileRegistryStore::new(registry_path),
107 &query,
108 RegistrySearchOptions {
109 limit: plan.limit,
110 registry_url,
111 },
112 )?,
113 };
114 let human = output::render_search(&query, source, &results);
115 output::write_output(
116 plan.json,
117 &output::RegistryEnvelope {
118 status: "success",
119 registry: output::RegistryPayload::Search {
120 source,
121 query: query.clone(),
122 results,
123 },
124 },
125 || human,
126 )
127}
128
129fn run_read(
130 plan: RegistryPlan,
131 target: RegistryTarget,
132) -> Result<RegistryCliOutput, RegistryCliError> {
133 let source = target.label();
134 let skill = match target {
135 RegistryTarget::Remote { registry_url } => RegistryClient::new(®istry_url)?
136 .read(&plan.subject, plan.version.as_deref())?
137 .ok_or_else(|| not_found(&plan.subject))?,
138 RegistryTarget::Local {
139 registry_path,
140 registry_url,
141 ..
142 } => read_registry_skill(
143 &FileRegistryStore::new(registry_path),
144 &plan.subject,
145 plan.version.as_deref(),
146 registry_url.as_deref(),
147 )?
148 .ok_or_else(|| not_found(&plan.subject))?,
149 };
150 let human = output::render_read(source, &plan.subject, &skill);
151 output::write_output(
152 plan.json,
153 &output::RegistryEnvelope {
154 status: "success",
155 registry: output::RegistryPayload::Read {
156 source,
157 r#ref: plan.subject,
158 skill: Box::new(skill),
159 },
160 },
161 || human,
162 )
163}
164
165fn run_resolve(
166 plan: RegistryPlan,
167 target: RegistryTarget,
168) -> Result<RegistryCliOutput, RegistryCliError> {
169 let source = target.label();
170 let resolution = match target {
171 RegistryTarget::Remote { registry_url } => {
172 let client = RegistryClient::new(®istry_url)?;
173 let resolved = client
174 .resolve_ref(&plan.subject, plan.version.as_deref())?
175 .ok_or_else(|| not_found(&plan.subject))?;
176 let detail = client
177 .read(&resolved.skill_id, resolved.version.as_deref())?
178 .ok_or_else(|| not_found(&resolved.skill_id))?;
179 output::RemoteOrLocalResolution::Remote(Box::new(detail))
180 }
181 RegistryTarget::Local {
182 registry_path,
183 registry_url,
184 ..
185 } => output::RemoteOrLocalResolution::Local(Box::new(
186 resolve_registry_skill(
187 &FileRegistryStore::new(registry_path),
188 &plan.subject,
189 RegistryResolveOptions {
190 version: plan.version,
191 registry_url,
192 },
193 )?
194 .ok_or_else(|| not_found(&plan.subject))?,
195 )),
196 };
197 let human = output::render_resolve(source, &plan.subject, &resolution);
198 output::write_output(
199 plan.json,
200 &output::RegistryEnvelope {
201 status: "success",
202 registry: output::RegistryPayload::Resolve {
203 source,
204 r#ref: plan.subject,
205 resolution,
206 },
207 },
208 || human,
209 )
210}
211
212fn run_install(
213 plan: RegistryPlan,
214 target: RegistryTarget,
215 env: &BTreeMap<String, String>,
216 cwd: &Path,
217) -> Result<RegistryCliOutput, RegistryCliError> {
218 let source = target.label();
219 let source_authority = target.manifest_source_authority();
220 let (candidate, acquisition) = install_candidate(&plan, target, env, cwd)?;
221 let install = install_local_skill(
222 &candidate,
223 &InstallLocalSkillOptions {
224 destination_root: destination_root(&plan, env, cwd),
225 expected_digest: plan.expected_digest,
226 trusted_manifest_keys: trusted_manifest_keys_from_env_for_source(
227 env,
228 source_authority,
229 )?,
230 },
231 )?;
232 let receipt_metadata = runx_runtime::registry_install_receipt_metadata(
233 runx_runtime::RegistryInstallMetadataInput {
234 candidate: &candidate,
235 install: &install,
236 acquisition: acquisition.as_ref(),
237 },
238 );
239 let human = output::render_install(
240 source,
241 &plan.subject,
242 &install,
243 candidate.signed_manifest.as_ref(),
244 );
245 output::write_output(
246 plan.json,
247 &output::RegistryEnvelope {
248 status: "success",
249 registry: output::RegistryPayload::Install {
250 source,
251 r#ref: plan.subject,
252 install: Box::new(install),
253 receipt_metadata,
254 },
255 },
256 || human,
257 )
258}
259
260fn run_publish(
263 plan: RegistryPlan,
264 target: RegistryTarget,
265 env: &BTreeMap<String, String>,
266 cwd: &Path,
267) -> Result<RegistryCliOutput, RegistryCliError> {
268 match target {
269 RegistryTarget::Remote { registry_url } => {
270 let package = package::read_skill_package(
271 &plan.subject,
272 plan.profile.as_deref(),
273 env,
274 cwd,
275 true,
276 )?;
277 let harness = package::run_publish_harness(
278 package.harness_path.as_deref(),
279 &package.harness_fixture_paths,
280 );
281 if let Some(temp_dir) = package.harness_temp_dir.as_ref() {
282 let _ignored = fs::remove_dir_all(temp_dir);
283 }
284 let harness = harness?;
285 let result = remote_publish::publish_remote_skill_package(
286 ®istry_url,
287 &plan,
288 &package,
289 &harness,
290 env,
291 cwd,
292 )?;
293 output::write_output(
294 plan.json,
295 &output::RegistryEnvelope {
296 status: "success",
297 registry: output::RegistryPayload::Publish {
298 publish: output::PublishPayload::Hosted(Box::new(result)),
299 },
300 },
301 || "\n registry publish success\n\n".to_owned(),
302 )
303 }
304 RegistryTarget::Local {
305 registry_path,
306 registry_url,
307 ..
308 } => {
309 let package = package::read_skill_package(
310 &plan.subject,
311 plan.profile.as_deref(),
312 env,
313 cwd,
314 true,
315 )?;
316 let harness = package::run_publish_harness(
317 package.harness_path.as_deref(),
318 &package.harness_fixture_paths,
319 );
320 if let Some(temp_dir) = package.harness_temp_dir.as_ref() {
321 let _ignored = fs::remove_dir_all(temp_dir);
322 }
323 let harness = harness?;
324 let result = publish_skill_markdown(
325 &LocalRegistryClient::new(FileRegistryStore::new(registry_path)),
326 &package.markdown,
327 PublishSkillMarkdownOptions {
328 ingest: IngestSkillOptions {
329 owner: plan.owner,
330 version: plan.version,
331 profile_document: package.profile_document,
332 package_files: package.package_files.into_iter().map(Into::into).collect(),
333 trust_tier: plan.trust_tier,
334 upsert: plan.upsert,
335 ..IngestSkillOptions::default()
336 },
337 registry_url,
338 harness,
339 },
340 )?;
341 output::write_output(
342 plan.json,
343 &output::RegistryEnvelope {
344 status: "success",
345 registry: output::RegistryPayload::Publish {
346 publish: output::PublishPayload::Local(Box::new(result)),
347 },
348 },
349 || "\n registry publish success\n\n".to_owned(),
350 )
351 }
352 }
353}
354
355pub(crate) fn install_candidate(
356 plan: &RegistryPlan,
357 target: RegistryTarget,
358 env: &BTreeMap<String, String>,
359 cwd: &Path,
360) -> Result<
361 (
362 InstallCandidate,
363 Option<runx_runtime::registry::AcquiredRegistrySkill>,
364 ),
365 RegistryCliError,
366> {
367 let source_authority = target.manifest_source_authority();
368 match target {
369 RegistryTarget::Remote { registry_url } => {
370 let installation_id = remote_installation_id(env, cwd)?;
371 let acquired = RegistryClient::new(®istry_url)?.acquire(
372 &plan.subject,
373 AcquireOptions {
374 installation_id: &installation_id,
375 version: plan.version.as_deref(),
376 channel: Some("cli"),
377 },
378 )?;
379 Ok((
380 candidate_from_acquired(&plan.subject, &acquired, source_authority),
381 Some(acquired),
382 ))
383 }
384 RegistryTarget::Local {
385 registry_path,
386 registry_url,
387 ..
388 } => {
389 let resolution = resolve_registry_skill(
390 &FileRegistryStore::new(registry_path),
391 &plan.subject,
392 RegistryResolveOptions {
393 version: plan.version.clone(),
394 registry_url,
395 },
396 )?
397 .ok_or_else(|| not_found(&plan.subject))?;
398 Ok((
399 candidate_from_resolution(&plan.subject, resolution, source_authority),
400 None,
401 ))
402 }
403 }
404}
405
406fn remote_installation_id(
407 env: &BTreeMap<String, String>,
408 cwd: &Path,
409) -> Result<String, RegistryCliError> {
410 if let Some(installation_id) = env
411 .get("RUNX_INSTALLATION_ID")
412 .map(String::as_str)
413 .map(str::trim)
414 .filter(|value| !value.is_empty())
415 {
416 return Ok(installation_id.to_owned());
417 }
418
419 let generated = InitGeneratedValues::generate();
420 let home = runx_runtime::resolve_runx_global_home_dir(env, cwd);
421 let state = ensure_runx_install_state(&home, &generated.installation_id, &generated.created_at)
422 .map_err(|error| usage_error(error.to_string()))?;
423 Ok(state.state.installation_id)
424}
425
426fn candidate_from_resolution(
427 registry_ref: &str,
428 resolution: RegistrySkillResolution,
429 source_authority: RegistryManifestSourceAuthority,
430) -> InstallCandidate {
431 InstallCandidate {
432 markdown: resolution.markdown,
433 profile_document: resolution.profile_document,
434 package_files: resolution.package_files,
435 package_digest: resolution.package_digest,
436 source: resolution.source,
437 source_label: resolution.source_label,
438 r#ref: registry_ref.to_owned(),
439 skill_id: Some(resolution.skill_id),
440 version: Some(resolution.version),
441 signed_manifest: resolution.signed_manifest,
442 profile_digest: resolution.profile_digest,
443 runner_names: resolution.runner_names,
444 trust_tier: Some(resolution.trust_tier),
445 manifest_source_authority: Some(source_authority),
446 }
447}
448
449fn candidate_from_acquired(
450 registry_ref: &str,
451 acquired: &runx_runtime::registry::AcquiredRegistrySkill,
452 source_authority: RegistryManifestSourceAuthority,
453) -> InstallCandidate {
454 InstallCandidate {
455 markdown: acquired.markdown.clone(),
456 profile_document: acquired.profile_document.clone(),
457 package_files: acquired.package_files.clone(),
458 package_digest: acquired.package_digest.clone(),
459 source: "runx-registry".to_owned(),
460 source_label: "runx registry".to_owned(),
461 r#ref: registry_ref.to_owned(),
462 skill_id: Some(acquired.skill_id.clone()),
463 version: Some(acquired.version.clone()),
464 signed_manifest: acquired.signed_manifest.clone(),
465 profile_digest: acquired.profile_digest.clone(),
466 runner_names: acquired.runner_names.clone(),
467 trust_tier: Some(acquired.trust_tier.clone()),
468 manifest_source_authority: Some(source_authority),
469 }
470}
471
472impl From<package::HostedSkillPackageFile> for RegistryPackageFile {
473 fn from(file: package::HostedSkillPackageFile) -> Self {
474 Self {
475 path: file.path,
476 content: file.content,
477 }
478 }
479}
480
481fn resolve_path(
482 path: &Path,
483 env: &BTreeMap<String, String>,
484 cwd: &Path,
485 prefer_existing: bool,
486) -> PathBuf {
487 if path.is_absolute() {
488 return path.to_path_buf();
489 }
490 runx_runtime::resolve_path_from_user_input(
491 &path.display().to_string(),
492 env,
493 cwd,
494 prefer_existing,
495 )
496}
497
498pub(crate) fn trusted_manifest_keys_from_env_for_source(
499 env: &BTreeMap<String, String>,
500 source_authority: RegistryManifestSourceAuthority,
501) -> Result<Vec<TrustedRegistryManifestKey>, RegistryCliError> {
502 runx_runtime::registry::trusted_registry_manifest_keys_from_env_with_source(
503 env,
504 Some(source_authority),
505 )
506 .map_err(trust_env_error)
507}
508
509fn trust_env_error(
510 error: runx_runtime::registry::RegistryManifestTrustEnvError,
511) -> RegistryCliError {
512 match error {
513 runx_runtime::registry::RegistryManifestTrustEnvError::InvalidKey => {
514 internal_error(error.to_string())
515 }
516 runx_runtime::registry::RegistryManifestTrustEnvError::MissingKeyId => {
517 usage_error(error.to_string())
518 }
519 runx_runtime::registry::RegistryManifestTrustEnvError::MissingOwner => {
520 usage_error(error.to_string())
521 }
522 runx_runtime::registry::RegistryManifestTrustEnvError::MissingSource => {
523 usage_error(error.to_string())
524 }
525 }
526}
527
528pub(crate) fn env_map() -> BTreeMap<String, String> {
529 crate::cli_io::env_map()
530}
531
532pub(crate) struct RegistryCliError {
533 message: String,
534 exit_code: u8,
535}
536
537impl std::fmt::Display for RegistryCliError {
538 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
539 formatter.write_str(&self.message)
540 }
541}
542
543impl std::fmt::Debug for RegistryCliError {
544 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
545 formatter
546 .debug_struct("RegistryCliError")
547 .field("message", &self.message)
548 .field("exit_code", &self.exit_code)
549 .finish()
550 }
551}
552
553impl std::error::Error for RegistryCliError {}
554
555impl RegistryCliError {
556 pub(crate) fn into_message(self) -> String {
557 self.message
558 }
559
560 fn code(&self) -> &'static str {
561 if self.exit_code == 64 {
562 "invalid_args"
563 } else {
564 "registry_error"
565 }
566 }
567}
568
569fn usage_error(message: impl Into<String>) -> RegistryCliError {
570 RegistryCliError {
571 message: message.into(),
572 exit_code: 64,
573 }
574}
575
576fn internal_error(message: impl Into<String>) -> RegistryCliError {
577 RegistryCliError {
578 message: message.into(),
579 exit_code: 1,
580 }
581}
582
583fn not_found(registry_ref: &str) -> RegistryCliError {
584 RegistryCliError {
585 message: format!("registry skill not found: {registry_ref}"),
586 exit_code: 1,
587 }
588}
589
590impl From<runx_runtime::registry::RegistryClientError> for RegistryCliError {
591 fn from(error: runx_runtime::registry::RegistryClientError) -> Self {
592 internal_error(error.to_string())
593 }
594}
595
596impl From<runx_runtime::registry::RegistryResolveError> for RegistryCliError {
597 fn from(error: runx_runtime::registry::RegistryResolveError) -> Self {
598 internal_error(error.to_string())
599 }
600}
601
602impl From<runx_runtime::registry::LocalRegistryError> for RegistryCliError {
603 fn from(error: runx_runtime::registry::LocalRegistryError) -> Self {
604 internal_error(error.to_string())
605 }
606}
607
608impl From<runx_runtime::registry::InstallError> for RegistryCliError {
609 fn from(error: runx_runtime::registry::InstallError) -> Self {
610 let error_kind = match &error {
611 runx_runtime::registry::InstallError::UnsignedManifest(_) => Some("unsigned_manifest"),
612 runx_runtime::registry::InstallError::UnknownManifestKey { .. } => Some("unknown_key"),
613 runx_runtime::registry::InstallError::InvalidManifestSignature { .. } => {
614 Some("invalid_signature")
615 }
616 runx_runtime::registry::InstallError::DigestMismatch { .. } => Some("digest_mismatch"),
617 _ => None,
618 };
619 match error_kind {
620 Some(kind) => internal_error(format!("registry install {kind}: {error}")),
621 None => internal_error(error.to_string()),
622 }
623 }
624}
625
626impl From<ConfigError> for RegistryCliError {
627 fn from(error: ConfigError) -> Self {
628 internal_error(error.to_string())
629 }
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635 use runx_runtime::registry::{InstallLocalSkillResult, InstallStatus, TrustTier};
636
637 #[test]
638 fn registry_install_render_shows_direct_skill_run_command() {
639 let rendered = output::render_install(
640 "local",
641 "acme/echo@1.2.3",
642 &InstallLocalSkillResult {
643 status: InstallStatus::Installed,
644 destination: PathBuf::from("/tmp/runx/skills/acme/echo/SKILL.md"),
645 skill_name: "echo".to_owned(),
646 source: "local".to_owned(),
647 source_label: "local registry".to_owned(),
648 skill_id: Some("acme/echo".to_owned()),
649 version: Some("1.2.3".to_owned()),
650 digest: "sha256:abc".to_owned(),
651 profile_digest: None,
652 profile_state_path: None,
653 runner_names: Vec::new(),
654 trust_tier: Some(TrustTier::Community),
655 },
656 None,
657 );
658
659 assert!(rendered.contains("next runx skill acme/echo@1.2.3"));
660 }
661}