diff --git a/src/anolisa/crates/anolisa-cli/src/commands/tier1/forget.rs b/src/anolisa/crates/anolisa-cli/src/commands/tier1/forget.rs index a6ad43546c..4ceb2ab135 100644 --- a/src/anolisa/crates/anolisa-cli/src/commands/tier1/forget.rs +++ b/src/anolisa/crates/anolisa-cli/src/commands/tier1/forget.rs @@ -828,7 +828,7 @@ mod tests { let layout = common::resolve_layout(ctx); let repo_v1 = layout.prefix.join("repo").join("v1"); fs::create_dir_all(&repo_v1).expect("mkdir repo"); - fs::write(repo_v1.join("components.toml"), index).expect("write components.toml"); + fs::write(repo_v1.join("components-v2.toml"), index).expect("write components-v2.toml"); fs::create_dir_all(&layout.etc_dir).expect("mkdir etc"); fs::write( layout.etc_dir.join("repo.toml"), @@ -862,10 +862,11 @@ mod tests { seed_component_index( &c, r#" -schema_version = 1 +schema_version = 2 [[components]] name = "cosh" +targets = [{ os = "linux", arch = "x86_64" }] [[components.backends]] kind = "rpm" @@ -907,10 +908,11 @@ name = "copilot-shell" seed_component_index( &c, r#" -schema_version = 1 +schema_version = 2 [[components]] name = "cosh" +targets = [{ os = "linux", arch = "x86_64" }] [[components.aliases]] kind = "rpm-package" diff --git a/src/anolisa/crates/anolisa-cli/src/commands/tier1/install/batch.rs b/src/anolisa/crates/anolisa-cli/src/commands/tier1/install/batch.rs index 94a3eaaf9e..61315bca62 100644 --- a/src/anolisa/crates/anolisa-cli/src/commands/tier1/install/batch.rs +++ b/src/anolisa/crates/anolisa-cli/src/commands/tier1/install/batch.rs @@ -40,6 +40,7 @@ use crate::commands::tier1::recovery::LockedJournalGate; use crate::commands::tier1::rpm_install; use crate::context::CliContext; use crate::progress::{self, Activity}; +use crate::resolution::ComponentIndex; use crate::response::{CliError, render_json, render_json_with_status}; use super::types::InstallOutcome; @@ -832,9 +833,26 @@ pub(crate) fn batch_status(outcome: InstallOutcome, dry_run: bool) -> &'static s } } -/// Load the component index and return names of components that support -/// the given backend. When `backend` is `None`, the repo's default -/// backend is used. +fn component_names_for_target( + index: &ComponentIndex, + backend: &str, + os: &str, + arch: &str, +) -> Vec { + index + .components + .iter() + .filter(|entry| { + entry.backends.iter().any(|item| item.kind == backend) + && entry.supports_target(os, arch) + }) + .map(|entry| entry.name.clone()) + .collect() +} + +/// Load the component index and return names of components that support the +/// current target and selected backend. When `backend` is `None`, the repo's +/// default backend is used. pub(crate) fn resolve_all_components( ctx: &CliContext, backend: Option<&str>, @@ -857,14 +875,12 @@ pub(crate) fn resolve_all_components( command: "install --all".to_string(), reason: format!("{err}"), })?; - let selected_backend = selected_backend.to_string(); - let names: Vec = index - .components - .iter() - .filter(|entry| entry.backends.iter().any(|b| b.kind == selected_backend)) - .map(|entry| entry.name.clone()) - .collect(); - Ok(names) + Ok(component_names_for_target( + &index, + selected_backend, + &env.os, + &env.arch, + )) } #[cfg(test)] @@ -899,6 +915,22 @@ mod tests { ); } + #[test] + fn batch_component_selection_uses_host_target() { + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let index_path = manifest_dir.join("../../manifests/components-v2.toml"); + let index = ComponentIndex::load(index_path).expect("component index template must parse"); + + assert_eq!( + component_names_for_target(&index, "raw", "linux", "aarch64"), + ["cosh-ng", "tokenless"] + ); + assert_eq!( + component_names_for_target(&index, "raw", "macos", "aarch64"), + ["cosh-ng", "agentsight", "tokenless"] + ); + } + /// Multi-package transaction fake: records each backend call with its /// full package set, so tests can pin that a batch shared one native /// transaction. `fail_install` fails the install verb as a whole. diff --git a/src/anolisa/crates/anolisa-cli/src/commands/tier1/install/dispatch.rs b/src/anolisa/crates/anolisa-cli/src/commands/tier1/install/dispatch.rs index e121403ff1..3fb7b9719e 100644 --- a/src/anolisa/crates/anolisa-cli/src/commands/tier1/install/dispatch.rs +++ b/src/anolisa/crates/anolisa-cli/src/commands/tier1/install/dispatch.rs @@ -2382,12 +2382,13 @@ mod tests { let repo_v1 = repo_root.join("v1"); std::fs::create_dir_all(&repo_v1).expect("repo dir"); std::fs::write( - repo_v1.join("components.toml"), + repo_v1.join("components-v2.toml"), r#" -schema_version = 1 +schema_version = 2 [[components]] name = "cosh" +targets = [{ os = "linux", arch = "x86_64" }] [[components.backends]] kind = "raw" @@ -2399,6 +2400,7 @@ name = "legacy-name" [[components]] name = "sec-core" +targets = [{ os = "linux", arch = "x86_64" }] [[components.backends]] kind = "raw" diff --git a/src/anolisa/crates/anolisa-cli/src/commands/tier1/install/tests/raw_e2e.rs b/src/anolisa/crates/anolisa-cli/src/commands/tier1/install/tests/raw_e2e.rs index bf53c8d2a0..4e8c18076d 100644 --- a/src/anolisa/crates/anolisa-cli/src/commands/tier1/install/tests/raw_e2e.rs +++ b/src/anolisa/crates/anolisa-cli/src/commands/tier1/install/tests/raw_e2e.rs @@ -1378,11 +1378,13 @@ manifest_digest = "sha256:{manifest_sha}" } std::fs::write(v1.join("index.toml"), index).expect("write distribution index"); std::fs::write( - v1.join("components.toml"), - r#"schema_version = 1 + v1.join("components-v2.toml"), + format!( + r#"schema_version = 2 [[components]] name = "cosh" +targets = [{{ os = "{os}", arch = "{arch}" }}] [[components.backends]] kind = "raw" @@ -1390,11 +1392,15 @@ package = "cosh" [[components]] name = "cosh-ng" +targets = [{{ os = "{os}", arch = "{arch}" }}] [[components.backends]] kind = "raw" package = "cosh-ng" "#, + os = env.os, + arch = env.arch, + ), ) .expect("write component index"); format!("file://{}", v1.display()) diff --git a/src/anolisa/crates/anolisa-cli/src/commands/tier1/list.rs b/src/anolisa/crates/anolisa-cli/src/commands/tier1/list.rs index 71e481145a..1bea7c40ca 100644 --- a/src/anolisa/crates/anolisa-cli/src/commands/tier1/list.rs +++ b/src/anolisa/crates/anolisa-cli/src/commands/tier1/list.rs @@ -1,6 +1,6 @@ //! `anolisa list` — list available components from the component index. //! -//! Reads the repo-side `components.toml` (the component identity index), +//! Reads the repo-side `components-v2.toml` (the component identity index), //! merges install status from `installed.toml`, and renders as a human //! table or `--json` envelope. @@ -21,7 +21,9 @@ use crate::commands::common; use crate::commands::common::RepoPersistPolicy; use crate::commands::state_view::{StateScope, StateView, StateVisibility}; use crate::context::{CliContext, InstallMode}; -use crate::resolution::{ComponentIndex, ComponentIndexEntry, load_component_index}; +use crate::resolution::{ + ComponentIndex, ComponentIndexEntry, ComponentTarget, load_component_index, +}; use crate::response::{CliError, render_json}; use self::render::render_human; @@ -44,10 +46,10 @@ pub struct Row { pub display_name: String, pub summary: String, pub backends: Vec, - /// Platforms declared by the component index. - pub platforms: Vec, - /// Whether the component declares support for the current platform. - pub platform_available: bool, + /// OS/architecture targets declared by the component index. + pub targets: Vec, + /// Whether the component declares support for the current host target. + pub target_available: bool, pub status: String, pub local_state: String, pub ownership: String, @@ -101,6 +103,7 @@ pub fn handle(args: ListArgs, ctx: &CliContext) -> Result<(), CliError> { &view, rpm_query.as_ref().map(|query| query as &dyn PackageQuery), &env.os, + &env.arch, ); if ctx.json { @@ -115,7 +118,7 @@ pub fn handle(args: ListArgs, ctx: &CliContext) -> Result<(), CliError> { if !ctx.quiet { render_warnings(&view.warnings); - render_human(&rows, ctx.no_color, &env.os); + render_human(&rows, ctx.no_color, &env.os, &env.arch); } Ok(()) } @@ -127,16 +130,17 @@ fn build_rows( state: &StateStore, rpm_query: Option<&dyn PackageQuery>, ) -> Vec { - build_rows_for_platform(index, args, state, rpm_query, "linux") + build_rows_for_target(index, args, state, rpm_query, "linux", "x86_64") } #[cfg(test)] -fn build_rows_for_platform( +fn build_rows_for_target( index: &ComponentIndex, args: &ListArgs, state: &StateStore, rpm_query: Option<&dyn PackageQuery>, - platform: &str, + os: &str, + arch: &str, ) -> Vec { index .components @@ -149,7 +153,8 @@ fn build_rows_for_platform( Some(entry_to_row( entry, projection, - platform, + os, + arch, RowScope { scope: "none".to_string(), active: false, @@ -167,7 +172,8 @@ fn build_rows_from_view( args: &ListArgs, view: &StateView, rpm_query: Option<&dyn PackageQuery>, - platform: &str, + os: &str, + arch: &str, ) -> Vec { let visible_components = view.visible_components(); index @@ -196,7 +202,7 @@ fn build_rows_from_view( .map(str::to_string), state_path: Some(record.root.state_path.display().to_string()), }; - Some(entry_to_row(entry, projection, platform, row_scope)) + Some(entry_to_row(entry, projection, os, arch, row_scope)) }) .collect::>(); } @@ -219,7 +225,8 @@ fn build_rows_from_view( vec![entry_to_row( entry, projection, - platform, + os, + arch, RowScope { scope: scope.to_string(), active: false, @@ -243,14 +250,15 @@ struct RowScope { fn entry_to_row( entry: &ComponentIndexEntry, projection: LocalProjection, - platform: &str, + os: &str, + arch: &str, row_scope: RowScope, ) -> Row { let backends: Vec = entry.backends.iter().map(|b| b.kind.clone()).collect(); let local_state = projection.local_state.label().to_string(); let ownership = projection.ownership_label().to_string(); - let platform_available = entry.supports_platform(platform); - let install_available = platform_available && !backends.is_empty(); + let target_available = entry.supports_target(os, arch); + let install_available = target_available && !backends.is_empty(); let action = if install_available || projection.action_label() != "install" { projection.action_label().to_string() } else { @@ -264,8 +272,8 @@ fn entry_to_row( .unwrap_or_else(|| entry.name.clone()), summary: entry.summary.clone().unwrap_or_default(), backends, - platforms: entry.platforms.clone(), - platform_available, + targets: entry.targets.clone(), + target_available, status: projection.status, local_state, ownership, diff --git a/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/render.rs b/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/render.rs index 08bec792fa..3344b2130f 100644 --- a/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/render.rs +++ b/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/render.rs @@ -47,11 +47,11 @@ impl HumanWidths { } } -pub(super) fn render_human(rows: &[Row], no_color: bool, platform: &str) { +pub(super) fn render_human(rows: &[Row], no_color: bool, os: &str, arch: &str) { let color = Palette::new(no_color); println!( "{}", - color.header(format!("Components for {}", display_platform(platform))) + color.header(format!("Components for {}/{arch}", display_os(os))) ); println!(); if rows.is_empty() { @@ -64,7 +64,7 @@ pub(super) fn render_human(rows: &[Row], no_color: bool, platform: &str) { println!("{}", color.header(human_header(rows))); for row in rows { let availability = availability_label(row); - let availability = if row.platform_available { + let availability = if row.target_available { color.ok(availability) } else { color.err(availability) @@ -88,20 +88,20 @@ pub(super) fn render_human(rows: &[Row], no_color: bool, platform: &str) { } pub(super) fn availability_label(row: &Row) -> String { - if row.platform_available { + if row.target_available { return "available".to_string(); } let supported = row - .platforms + .targets .iter() - .map(|platform| display_platform(platform)) + .map(|target| format!("{}/{}", display_os(&target.os), target.arch)) .collect::>() - .join("/"); + .join(", "); format!("{supported} only") } -fn display_platform(platform: &str) -> String { - match platform { +fn display_os(os: &str) -> String { + match os { "linux" => "Linux".to_string(), "macos" => "macOS".to_string(), other => other.to_string(), diff --git a/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/tests/compatibility.rs b/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/tests/compatibility.rs index 5e1d8b07d5..1110402f20 100644 --- a/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/tests/compatibility.rs +++ b/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/tests/compatibility.rs @@ -1,11 +1,11 @@ use anolisa_core::domain::LifecycleStatus; use anolisa_core::state::ObjectKind; -use crate::commands::tier1::list::{ListArgs, ListPayload, build_rows, build_rows_for_platform}; +use crate::commands::tier1::list::{ListArgs, ListPayload, build_rows, build_rows_for_target}; use crate::resolution::{ComponentBackendEntry, ComponentIndex, ComponentIndexEntry}; use super::support::{ - empty_state, sample_index, state_with_adopted_object, state_with_owned_object, + empty_state, sample_index, state_with_adopted_object, state_with_owned_object, target, }; #[test] @@ -21,31 +21,53 @@ fn index_builds_rows() { assert_eq!(sight.display_name, "AgentSight"); assert_eq!(sight.summary, "eBPF-based AI agent observability tool"); assert_eq!(sight.backends, vec!["raw", "rpm"]); - assert_eq!(sight.platforms, vec!["linux"]); - assert!(sight.platform_available); + assert_eq!( + sight.targets, + vec![target("linux", "x86_64"), target("macos", "aarch64")] + ); + assert!(sight.target_available); assert_eq!(sight.status, "not_installed"); let token = &rows[1]; assert_eq!(token.name, "tokenless"); assert_eq!(token.backends, vec!["raw"]); - assert_eq!(token.platforms, vec!["linux", "macos"]); - assert!(token.platform_available); + assert_eq!( + token.targets, + vec![ + target("linux", "x86_64"), + target("linux", "aarch64"), + target("macos", "aarch64"), + ] + ); + assert!(token.target_available); } #[test] -fn macos_rows_remain_complete_and_report_platform_availability() { +fn target_matrix_distinguishes_os_and_architecture() { let index = sample_index(); let args = ListArgs { installed: false }; let state = empty_state(); - let rows = build_rows_for_platform(&index, &args, &state, None, "macos"); + let rows = build_rows_for_target(&index, &args, &state, None, "macos", "aarch64"); assert_eq!(rows.len(), 2); let sight = rows.iter().find(|row| row.name == "agentsight").unwrap(); - assert!(!sight.platform_available); + assert!(sight.target_available); + assert_eq!(sight.action, "install"); + let token = rows.iter().find(|row| row.name == "tokenless").unwrap(); + assert!(token.target_available); + assert_eq!(token.action, "install"); + + let rows = build_rows_for_target(&index, &args, &state, None, "macos", "x86_64"); + assert!(rows.iter().all(|row| !row.target_available)); + assert!(rows.iter().all(|row| row.action == "unavailable")); + + let rows = build_rows_for_target(&index, &args, &state, None, "linux", "aarch64"); + let sight = rows.iter().find(|row| row.name == "agentsight").unwrap(); + assert!(!sight.target_available); assert_eq!(sight.action, "unavailable"); let token = rows.iter().find(|row| row.name == "tokenless").unwrap(); - assert!(token.platform_available); + assert!(token.target_available); assert_eq!(token.action, "install"); } @@ -222,14 +244,14 @@ fn json_payload_status_reflects_install_state() { #[test] fn missing_optional_fields_use_defaults() { let index = ComponentIndex { - schema_version: 1, + schema_version: 2, generated_at: None, publisher: None, components: vec![ComponentIndexEntry { name: "minimal".to_string(), display_name: None, summary: None, - platforms: vec!["linux".to_string()], + targets: vec![target("linux", "x86_64")], backends: Vec::new(), aliases: Vec::new(), }], @@ -243,8 +265,8 @@ fn missing_optional_fields_use_defaults() { assert_eq!(row.display_name, "minimal"); assert!(row.summary.is_empty()); assert!(row.backends.is_empty()); - assert_eq!(row.platforms, ["linux"]); - assert!(row.platform_available); + assert_eq!(row.targets, [target("linux", "x86_64")]); + assert!(row.target_available); assert_eq!(row.status, "not_installed"); assert_eq!(row.action, "unavailable"); } @@ -252,14 +274,14 @@ fn missing_optional_fields_use_defaults() { #[test] fn unknown_backend_kind_preserved() { let index = ComponentIndex { - schema_version: 1, + schema_version: 2, generated_at: None, publisher: None, components: vec![ComponentIndexEntry { name: "test".to_string(), display_name: None, summary: None, - platforms: vec!["linux".to_string()], + targets: vec![target("linux", "x86_64")], backends: vec![ComponentBackendEntry { kind: "custom-repo".to_string(), package: "test".to_string(), diff --git a/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/tests/output.rs b/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/tests/output.rs index f046eff43c..e2aa984ae8 100644 --- a/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/tests/output.rs +++ b/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/tests/output.rs @@ -1,7 +1,7 @@ use crate::commands::tier1::list::render::{availability_label, human_header}; use crate::commands::tier1::list::{ListArgs, ListPayload, Row, build_rows}; -use super::support::{FakeRpmQuery, empty_state, pkg_info, sample_index}; +use super::support::{FakeRpmQuery, empty_state, pkg_info, sample_index, target}; #[test] fn row_json_retains_status_and_adds_local_fields() { @@ -39,8 +39,16 @@ fn row_json_retains_status_and_adds_local_fields() { assert_eq!(sight["active"], false); assert_eq!(sight["mutable_by_current_invocation"], false); assert_eq!(sight["action"], "install"); - assert_eq!(sight["platforms"], serde_json::json!(["linux"])); - assert_eq!(sight["platform_available"], true); + assert_eq!( + sight["targets"], + serde_json::json!([ + {"os": "linux", "arch": "x86_64"}, + {"os": "macos", "arch": "aarch64"} + ]) + ); + assert_eq!(sight["target_available"], true); + assert!(sight.get("platforms").is_none()); + assert!(sight.get("platform_available").is_none()); } #[test] @@ -50,8 +58,8 @@ fn human_header_contains_local_state() { display_name: "AgentSight".to_string(), summary: "observability".to_string(), backends: vec!["rpm".to_string()], - platforms: vec!["linux".to_string()], - platform_available: true, + targets: vec![target("linux", "x86_64")], + target_available: true, status: "not_installed".to_string(), local_state: "observed".to_string(), ownership: "none".to_string(), @@ -76,7 +84,7 @@ fn human_header_contains_local_state() { } #[test] -fn human_availability_explains_the_supported_platform() { +fn human_availability_explains_the_supported_targets() { let mut rows = build_rows( &sample_index(), &ListArgs { installed: false }, @@ -87,9 +95,12 @@ fn human_availability_explains_the_supported_platform() { .iter_mut() .find(|row| row.name == "agentsight") .unwrap(); - sight.platform_available = false; + sight.target_available = false; - assert_eq!(availability_label(sight), "Linux only"); + assert_eq!( + availability_label(sight), + "Linux/x86_64, macOS/aarch64 only" + ); } #[test] diff --git a/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/tests/scoped_rows.rs b/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/tests/scoped_rows.rs index d2f842a41e..4cdf7b74e5 100644 --- a/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/tests/scoped_rows.rs +++ b/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/tests/scoped_rows.rs @@ -20,7 +20,7 @@ fn user_view_installed_filter_keeps_system_provided_component() { state_with_component_object(owned_component("agentsight", LifecycleStatus::Installed)), ); - let rows = build_rows_from_view(&index, &args, &view, None, "linux"); + let rows = build_rows_from_view(&index, &args, &view, None, "linux", "x86_64"); assert_eq!(rows.len(), 1); assert_eq!(rows[0].name, "agentsight"); @@ -47,7 +47,7 @@ fn user_record_shadows_system_record_in_list_rows() { )), ); - let rows = build_rows_from_view(&index, &args, &view, None, "linux"); + let rows = build_rows_from_view(&index, &args, &view, None, "linux", "x86_64"); assert_eq!(rows.len(), 2); assert_eq!(rows[0].name, "agentsight"); diff --git a/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/tests/support.rs b/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/tests/support.rs index 52c8ecf594..c44d0e1da2 100644 --- a/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/tests/support.rs +++ b/src/anolisa/crates/anolisa-cli/src/commands/tier1/list/tests/support.rs @@ -9,11 +9,19 @@ use anolisa_platform::pkg_query::{PackageInfo, PackageQuery, PackageQueryError, use crate::commands::tier1::list::state_view::{self, LocalProjection}; use crate::resolution::{ ComponentAliasEntry, ComponentBackendEntry, ComponentIndex, ComponentIndexEntry, + ComponentTarget, }; +pub(super) fn target(os: &str, arch: &str) -> ComponentTarget { + ComponentTarget { + os: os.to_string(), + arch: arch.to_string(), + } +} + pub(super) fn sample_index() -> ComponentIndex { ComponentIndex { - schema_version: 1, + schema_version: 2, generated_at: None, publisher: Some("anolisa".to_string()), components: vec![ @@ -21,7 +29,7 @@ pub(super) fn sample_index() -> ComponentIndex { name: "agentsight".to_string(), display_name: Some("AgentSight".to_string()), summary: Some("eBPF-based AI agent observability tool".to_string()), - platforms: vec!["linux".to_string()], + targets: vec![target("linux", "x86_64"), target("macos", "aarch64")], backends: vec![ ComponentBackendEntry { kind: "raw".to_string(), @@ -42,7 +50,11 @@ pub(super) fn sample_index() -> ComponentIndex { name: "tokenless".to_string(), display_name: Some("Tokenless".to_string()), summary: Some("LLM token optimization toolkit".to_string()), - platforms: vec!["linux".to_string(), "macos".to_string()], + targets: vec![ + target("linux", "x86_64"), + target("linux", "aarch64"), + target("macos", "aarch64"), + ], backends: vec![ComponentBackendEntry { kind: "raw".to_string(), package: "tokenless".to_string(), @@ -286,14 +298,14 @@ pub(super) fn projection_for_index( /// with an rpm-package alias — mirrors the real `cosh` / `copilot-shell` mapping. pub(super) fn sample_index_with_aliases() -> ComponentIndex { ComponentIndex { - schema_version: 1, + schema_version: 2, generated_at: None, publisher: Some("anolisa".to_string()), components: vec![ComponentIndexEntry { name: "cosh".to_string(), display_name: Some("Copilot Shell".to_string()), summary: Some("shell".to_string()), - platforms: vec!["linux".to_string()], + targets: vec![target("linux", "x86_64")], backends: vec![ ComponentBackendEntry { kind: "raw".to_string(), diff --git a/src/anolisa/crates/anolisa-cli/src/commands/tier1/status.rs b/src/anolisa/crates/anolisa-cli/src/commands/tier1/status.rs index 6bfa3529a3..1491a8c63e 100644 --- a/src/anolisa/crates/anolisa-cli/src/commands/tier1/status.rs +++ b/src/anolisa/crates/anolisa-cli/src/commands/tier1/status.rs @@ -2882,10 +2882,11 @@ mod tests { fn status_lookup_name_uses_component_index_alias_before_state_selection() { let idx = ComponentIndex::from_toml_str( r#" -schema_version = 1 +schema_version = 2 [[components]] name = "cosh" +targets = [{ os = "linux", arch = "x86_64" }] [[components.backends]] kind = "rpm" diff --git a/src/anolisa/crates/anolisa-cli/src/commands/tier1/uninstall.rs b/src/anolisa/crates/anolisa-cli/src/commands/tier1/uninstall.rs index 6b97bcef64..4de032290a 100644 --- a/src/anolisa/crates/anolisa-cli/src/commands/tier1/uninstall.rs +++ b/src/anolisa/crates/anolisa-cli/src/commands/tier1/uninstall.rs @@ -2161,7 +2161,7 @@ mod tests { let layout = common::resolve_layout(ctx); let repo_v1 = layout.prefix.join("repo").join("v1"); fs::create_dir_all(&repo_v1).expect("mkdir repo"); - fs::write(repo_v1.join("components.toml"), index).expect("write components.toml"); + fs::write(repo_v1.join("components-v2.toml"), index).expect("write components-v2.toml"); fs::create_dir_all(&layout.etc_dir).expect("mkdir etc"); fs::write( layout.etc_dir.join("repo.toml"), @@ -3101,10 +3101,11 @@ mod tests { seed_component_index( &c, r#" -schema_version = 1 +schema_version = 2 [[components]] name = "cosh" +targets = [{ os = "linux", arch = "x86_64" }] [[components.backends]] kind = "rpm" diff --git a/src/anolisa/crates/anolisa-cli/src/commands/tier1/update/check/tests.rs b/src/anolisa/crates/anolisa-cli/src/commands/tier1/update/check/tests.rs index 8ab2500c29..0d4458bf5d 100644 --- a/src/anolisa/crates/anolisa-cli/src/commands/tier1/update/check/tests.rs +++ b/src/anolisa/crates/anolisa-cli/src/commands/tier1/update/check/tests.rs @@ -583,7 +583,7 @@ fn update_check_default_present_via_index_package_without_provide() { info("copilot-shell", "2.6.1", Some("1")), ); let index = crate::resolution::ComponentIndex::from_toml_str( - "schema_version = 1\n\n[[components]]\nname = \"cosh\"\n\n[[components.backends]]\nkind = \"rpm\"\npackage = \"copilot-shell\"\n", + "schema_version = 2\n\n[[components]]\nname = \"cosh\"\ntargets = [{ os = \"linux\", arch = \"x86_64\" }]\n\n[[components.backends]]\nkind = \"rpm\"\npackage = \"copilot-shell\"\n", "test-components.toml", ) .expect("index parses"); @@ -833,7 +833,7 @@ fn update_check_default_resolved_package_absent_is_item_error() { #[test] fn update_check_index_rpm_packages_are_deduplicated() { let index = crate::resolution::ComponentIndex::from_toml_str( - "schema_version = 1\n\n[[components]]\nname = \"cosh\"\n\n[[components.backends]]\nkind = \"rpm\"\npackage = \"copilot-shell\"\n\n[[components.aliases]]\nkind = \"rpm-package\"\nname = \"copilot-shell\"\n", + "schema_version = 2\n\n[[components]]\nname = \"cosh\"\ntargets = [{ os = \"linux\", arch = \"x86_64\" }]\n\n[[components.backends]]\nkind = \"rpm\"\npackage = \"copilot-shell\"\n\n[[components.aliases]]\nkind = \"rpm-package\"\nname = \"copilot-shell\"\n", "test-components.toml", ) .expect("index parses"); diff --git a/src/anolisa/crates/anolisa-cli/src/repo_config.rs b/src/anolisa/crates/anolisa-cli/src/repo_config.rs index 1afe655669..656f7723d1 100644 --- a/src/anolisa/crates/anolisa-cli/src/repo_config.rs +++ b/src/anolisa/crates/anolisa-cli/src/repo_config.rs @@ -690,15 +690,23 @@ pub fn raw_index_v2_url(base_url: &str) -> String { format!("{}/index-v2.toml", raw_root(base_url)) } -/// Component identity index location under the raw repository root. +/// Generation-1 component identity index location under the raw repository root. /// -/// `components.toml` is separate from raw `index.toml`: the former resolves a -/// stable ANOLISA component identity to backend-native package names, while the -/// latter selects raw artifact versions and hashes. +/// Released clients continue reading this path, so publishers must preserve its +/// schema v1 contents when publishing later component-index generations. +#[cfg(test)] pub fn component_index_url(base_url: &str) -> String { format!("{}/components.toml", raw_root(base_url)) } +/// Generation-2 component identity index location under the raw repository root. +/// +/// Keeping v2 at a distinct path prevents its incompatible target rows from +/// breaking released clients that parse `components.toml` atomically. +pub fn component_index_v2_url(base_url: &str) -> String { + format!("{}/components-v2.toml", raw_root(base_url)) +} + /// Root that repo-relative index-row `url`s join onto. Same prefix the /// index itself lives under, so a mirrored tree stays self-contained. pub fn raw_relative_root(base_url: &str) -> String { @@ -1262,11 +1270,15 @@ base_url = "https://example.com/$typo_var/repo" } #[test] - fn component_index_url_uses_raw_repository_root() { + fn component_index_urls_are_generation_specific() { assert_eq!( component_index_url("file:///srv/repo"), "file:///srv/repo/v1/components.toml" ); + assert_eq!( + component_index_v2_url("file:///srv/repo"), + "file:///srv/repo/v1/components-v2.toml" + ); } /// A legacy template-form `base_url` is reduced to its static v1 root. diff --git a/src/anolisa/crates/anolisa-cli/src/resolution.rs b/src/anolisa/crates/anolisa-cli/src/resolution.rs index 14cb671272..8cba4912aa 100644 --- a/src/anolisa/crates/anolisa-cli/src/resolution.rs +++ b/src/anolisa/crates/anolisa-cli/src/resolution.rs @@ -11,13 +11,13 @@ use std::path::Path; use anolisa_core::download::DownloadCache; use anolisa_platform::fs_layout::FsLayout; use anolisa_platform::pkg_query::{PackageQuery, PackageQueryError}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::repo_config::{BackendConfig, HostVars, RepoConfig, component_index_url}; +use crate::repo_config::{BackendConfig, HostVars, RepoConfig, component_index_v2_url}; -/// On-disk schema version for repo-side `components.toml`. -pub(crate) const COMPONENT_INDEX_SCHEMA_VERSION: u32 = 1; +/// On-disk schema version for repo-side `components-v2.toml`. +pub(crate) const COMPONENT_INDEX_SCHEMA_VERSION: u32 = 2; /// Repository-side component identity and backend mapping index. #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] @@ -26,8 +26,7 @@ pub(crate) struct ComponentIndex { pub(crate) schema_version: u32, /// Optional publish timestamp for diagnostics. /// - /// This is not required in v1 indexes, so absent fields deserialize as - /// `None` to keep hand-authored indexes small. + /// Absent fields deserialize as `None` to keep hand-authored indexes small. #[serde(default)] pub(crate) generated_at: Option, /// Optional publishing party for diagnostics. @@ -48,19 +47,14 @@ pub(crate) struct ComponentIndex { pub(crate) struct ComponentIndexEntry { /// Stable ANOLISA component name. pub(crate) name: String, - /// Optional human label; not used by resolution v1. + /// Optional human label; not used by identity resolution. #[serde(default)] pub(crate) display_name: Option, - /// Optional one-line summary; not used by resolution v1. + /// Optional one-line summary; not used by identity resolution. #[serde(default)] pub(crate) summary: Option, - /// Operating systems supported by this component. - /// - /// The field is optional on the wire so existing v1 indexes remain valid; - /// indexes that omit it inherit the original Linux-only behavior. A - /// published backend is still required before installation is actionable. - #[serde(default = "default_component_platforms")] - pub(crate) platforms: Vec, + /// Supported host OS/architecture combinations. + pub(crate) targets: Vec, /// Backend-native package names for this component. /// /// Components may initially ship on only one backend, so the list defaults @@ -74,6 +68,15 @@ pub(crate) struct ComponentIndexEntry { pub(crate) aliases: Vec, } +/// One host target supported by a component. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub(crate) struct ComponentTarget { + /// Operating system identifier used by distribution indexes. + pub(crate) os: String, + /// CPU architecture identifier used by distribution indexes. + pub(crate) arch: String, +} + /// Backend-native identity for a component. #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub(crate) struct ComponentBackendEntry { @@ -105,12 +108,8 @@ pub(crate) struct ComponentAliasEntry { pub(crate) name: String, } -fn default_component_platforms() -> Vec { - vec!["linux".to_string()] -} - -fn is_canonical_platform(platform: &str) -> bool { - let bytes = platform.as_bytes(); +fn is_canonical_os(os: &str) -> bool { + let bytes = os.as_bytes(); bytes.first().is_some_and(u8::is_ascii_lowercase) && bytes .last() @@ -120,7 +119,16 @@ fn is_canonical_platform(platform: &str) -> bool { .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-') } -/// Parse or validation failures for `components.toml`. +fn is_canonical_architecture(architecture: &str) -> bool { + let bytes = architecture.as_bytes(); + bytes.first().is_some_and(u8::is_ascii_alphanumeric) + && bytes.last().is_some_and(u8::is_ascii_alphanumeric) + && bytes.iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-' || *byte == b'_' + }) +} + +/// Parse or validation failures for `components-v2.toml`. #[derive(Debug, Error)] pub(crate) enum ComponentIndexError { /// TOML parse or read error. @@ -174,7 +182,7 @@ pub(crate) enum ResolutionUse { /// Source that produced a resolved component/package pair. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ResolutionSource { - /// Repository `components.toml`. + /// Repository component index. ComponentIndex, /// Site-local `[backends.rpm.package_map]`. RepoPackageMap, @@ -439,7 +447,7 @@ pub(crate) fn lookup_component_alias( } impl ComponentIndex { - /// Parse and validate `components.toml`. + /// Parse and validate a component index. pub(crate) fn from_toml_str( s: &str, path: impl AsRef, @@ -484,30 +492,26 @@ impl ComponentIndex { reason: format!("duplicate component '{name}'"), }); } - if entry.platforms.is_empty() { + if entry.targets.is_empty() { return Err(ComponentIndexError::Invalid { - reason: format!("component '{name}' must declare at least one platform"), + reason: format!("component '{name}' must declare at least one target"), }); } - let mut platforms = BTreeSet::new(); - for platform in &entry.platforms { - if platform.trim().is_empty() { - return Err(ComponentIndexError::Invalid { - reason: format!("component '{name}' has an empty platform"), - }); - } - if !is_canonical_platform(platform) { + let mut targets = BTreeSet::new(); + for target in &entry.targets { + if !is_canonical_os(&target.os) || !is_canonical_architecture(&target.arch) { return Err(ComponentIndexError::Invalid { reason: format!( - "component '{name}' platform '{platform}' must be a lowercase ASCII \ - identifier with optional internal digits or hyphens" + "component '{name}' target '{}/{}' must use canonical lowercase OS and architecture identifiers", + target.os, target.arch ), }); } - if !platforms.insert(platform) { + if !targets.insert((target.os.as_str(), target.arch.as_str())) { return Err(ComponentIndexError::Invalid { reason: format!( - "component '{name}' declares duplicate platform '{platform}'" + "component '{name}' declares duplicate target '{}/{}'", + target.os, target.arch ), }); } @@ -591,9 +595,11 @@ impl ComponentIndex { } impl ComponentIndexEntry { - /// Whether the component index permits installation on `platform`. - pub(crate) fn supports_platform(&self, platform: &str) -> bool { - self.platforms.iter().any(|candidate| candidate == platform) + /// Whether the component index permits installation on `os`/`arch`. + pub(crate) fn supports_target(&self, os: &str, arch: &str) -> bool { + self.targets + .iter() + .any(|candidate| candidate.os == os && candidate.arch == arch) } fn backends_for(&self, backend: BackendKind) -> impl Iterator { @@ -707,7 +713,7 @@ pub(crate) fn rpm_components_from_capabilities(capabilities: &[String]) -> Vec ComponentIndex { ComponentIndex::from_toml_str( r#" -schema_version = 1 +schema_version = 2 publisher = "anolisa" [[components]] name = "cosh" display_name = "Copilot Shell" summary = "shell" +targets = [{ os = "linux", arch = "x86_64" }] [[components.backends]] kind = "raw" @@ -880,71 +887,116 @@ name = "copilot-shell" #[test] fn repository_component_index_template_is_valid() { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let index_path = manifest_dir.join("../../manifests/components.toml"); + let index_path = manifest_dir.join("../../manifests/components-v2.toml"); ComponentIndex::load(&index_path).expect("component index template must parse"); } #[test] - fn component_index_without_platforms_keeps_legacy_linux_default() { - let index = index(); + fn component_index_requires_at_least_one_target() { + let source = r#" +schema_version = 2 - assert_eq!(index.components[0].platforms, ["linux"]); - assert!(index.components[0].supports_platform("linux")); - assert!(!index.components[0].supports_platform("macos")); +[[components]] +name = "test" +targets = [] +"#; + + let err = ComponentIndex::from_toml_str(source, "components.toml") + .expect_err("empty targets must be rejected"); + assert!( + matches!( + err, + ComponentIndexError::Invalid { ref reason } + if reason.contains("at least one target") + ), + "unexpected error: {err}" + ); } #[test] - fn non_canonical_platform_identifiers_are_rejected() { - for platform in ["Linux", " linux "] { + fn invalid_or_duplicate_targets_are_rejected() { + for (targets, expected) in [ + ( + r#"[{ os = "Linux", arch = "x86_64" }]"#, + "canonical lowercase", + ), + ( + r#"[{ os = "linux", arch = "X86_64" }]"#, + "canonical lowercase", + ), + ( + r#"[{ os = "linux", arch = "x86_64" }, { os = "linux", arch = "x86_64" }]"#, + "duplicate target", + ), + ] { let source = format!( r#" -schema_version = 1 +schema_version = 2 [[components]] name = "test" -platforms = ["{platform}"] +targets = {targets} "# ); let err = ComponentIndex::from_toml_str(&source, "components.toml") - .expect_err("non-canonical platform must be rejected"); + .expect_err("invalid target must be rejected"); assert!( matches!( err, ComponentIndexError::Invalid { ref reason } - if reason.contains("lowercase ASCII identifier") + if reason.contains(expected) ), - "unexpected error for {platform:?}: {err}" + "unexpected error for {targets}: {err}" ); } } #[test] - fn repository_component_index_declares_current_platform_matrix() { + fn repository_component_index_declares_current_target_matrix() { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let index_path = manifest_dir.join("../../manifests/components.toml"); + let index_path = manifest_dir.join("../../manifests/components-v2.toml"); let index = ComponentIndex::load(&index_path).expect("component index template must parse"); assert!( index .components .iter() - .all(|component| component.supports_platform("linux")), - "every published component must remain visible as available on Linux" + .all(|component| component.supports_target("linux", "x86_64")) ); let macos_components = index .components .iter() - .filter(|component| component.supports_platform("macos")) + .filter(|component| component.targets.iter().any(|target| target.os == "macos")) .map(|component| component.name.as_str()) .collect::>(); - assert_eq!(macos_components, ["tokenless"]); + assert_eq!(macos_components, ["cosh-ng", "agentsight", "tokenless"]); + + let agentsight = index + .components + .iter() + .find(|component| component.name == "agentsight") + .expect("agentsight entry"); + assert!(agentsight.supports_target("macos", "aarch64")); + assert!(!agentsight.supports_target("macos", "x86_64")); + assert!(!agentsight.supports_target("linux", "aarch64")); + + for name in ["cosh-ng", "tokenless"] { + let component = index + .components + .iter() + .find(|component| component.name == name) + .expect("component entry"); + assert!(component.supports_target("linux", "aarch64")); + assert!(component.supports_target("macos", "aarch64")); + assert!(!component.supports_target("macos", "x86_64")); + } } #[test] fn repository_component_index_uses_sec_core_as_canonical_name() { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let index_path = manifest_dir.join("../../manifests/components.toml"); + let index_path = manifest_dir.join("../../manifests/components-v2.toml"); let idx = ComponentIndex::load(&index_path).expect("component index template must parse"); let query = FakeQuery::default(); let resolver = ComponentResolver::new(Some(&idx), None, Some(&query)); @@ -987,7 +1039,7 @@ platforms = ["{platform}"] #[test] fn repository_component_index_maps_cosh_ng_rpm_to_cosh_ng() { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let index_path = manifest_dir.join("../../manifests/components.toml"); + let index_path = manifest_dir.join("../../manifests/components-v2.toml"); let idx = ComponentIndex::load(&index_path).expect("component index template must parse"); let query = FakeQuery::default(); let resolver = ComponentResolver::new(Some(&idx), None, Some(&query)); @@ -1012,7 +1064,7 @@ platforms = ["{platform}"] } #[test] - fn load_optional_component_index_uses_raw_index_for_rpm_resolution() { + fn load_optional_component_index_uses_generation_2_raw_index_for_rpm_resolution() { let tmp = tempfile::tempdir().expect("tmpdir"); let layout = anolisa_platform::fs_layout::FsLayout::system(Some(tmp.path().join("install-root"))); @@ -1026,21 +1078,34 @@ platforms = ["{platform}"] r#" schema_version = 1 +[[components]] +name = "legacy-index" +platforms = ["linux"] +"#, + ) + .expect("write generation-1 component index"); + std::fs::write( + raw_v1.join("components-v2.toml"), + r#" +schema_version = 2 + [[components]] name = "raw-index" +targets = [{ os = "linux", arch = "x86_64" }] "#, ) - .expect("write raw components.toml"); + .expect("write generation-2 component index"); std::fs::write( - rpm_root.join("components.toml"), + rpm_root.join("components-v2.toml"), r#" -schema_version = 1 +schema_version = 2 [[components]] name = "rpm-ignored" +targets = [{ os = "linux", arch = "x86_64" }] "#, ) - .expect("write rpm components.toml"); + .expect("write rpm component index"); let repo_config = RepoConfig::from_toml_str(&format!( r#" schema_version = 1 @@ -1260,11 +1325,17 @@ cosh = "site-copilot" #[test] fn unsupported_schema_is_rejected() { - let err = ComponentIndex::from_toml_str("schema_version = 99", "components.toml") - .expect_err("unsupported schema"); - assert!(matches!( - err, - ComponentIndexError::UnsupportedSchema { actual: 99, .. } - )); + for actual in [1, 99] { + let source = format!("schema_version = {actual}"); + let err = ComponentIndex::from_toml_str(&source, "components.toml") + .expect_err("unsupported schema"); + assert!(matches!( + err, + ComponentIndexError::UnsupportedSchema { + actual: rejected, + .. + } if rejected == actual + )); + } } } diff --git a/src/anolisa/crates/anolisa-cli/tests/scope_identity.rs b/src/anolisa/crates/anolisa-cli/tests/scope_identity.rs index c268e814b2..eab5939f5f 100644 --- a/src/anolisa/crates/anolisa-cli/tests/scope_identity.rs +++ b/src/anolisa/crates/anolisa-cli/tests/scope_identity.rs @@ -346,12 +346,13 @@ sha256 = "0000000000000000000000000000000000000000000000000000000000000000" ) .expect("distribution index"); std::fs::write( - repo_v1.join("components.toml"), + repo_v1.join("components-v2.toml"), r#" -schema_version = 1 +schema_version = 2 [[components]] name = "cosh" +targets = [{ os = "linux", arch = "x86_64" }] [[components.backends]] kind = "raw" diff --git a/src/anolisa/manifests/components-v2.toml b/src/anolisa/manifests/components-v2.toml new file mode 100644 index 0000000000..1e06ba7ed2 --- /dev/null +++ b/src/anolisa/manifests/components-v2.toml @@ -0,0 +1,170 @@ +schema_version = 2 +generated_at = "2026-06-30T00:00:00Z" +publisher = "anolisa" + +# Repo-side component identity index. +# +# `index.toml` remains the raw artifact version/hash index. This file is only +# for resolving stable ANOLISA component names to backend-native package names. +# Scope: runtime components only. The ANOLISA CLI package itself and osbase +# package lists are intentionally excluded because they are not component +# identities. + +[[components]] +name = "cosh" +display_name = "Copilot Shell" +summary = "Copilot shell launcher and deterministic CLI gateway" +targets = [{ os = "linux", arch = "x86_64" }] + +[[components.backends]] +kind = "raw" +package = "cosh" + +[[components.backends]] +kind = "rpm" +package = "copilot-shell" +provides = "anolisa-component(cosh)" +legacy_adopt = true + +[[components.aliases]] +kind = "rpm-package" +name = "copilot-shell" + +[[components.aliases]] +kind = "raw-package" +name = "copilot-shell" + +[[components]] +name = "cosh-ng" +display_name = "cosh-ng" +summary = "Computable Operating System Harness (cosh-ng) - a deterministic Agent-OS interface" +targets = [ + { os = "linux", arch = "x86_64" }, + { os = "linux", arch = "aarch64" }, + { os = "macos", arch = "aarch64" }, +] + +[[components.backends]] +kind = "raw" +package = "cosh-ng" + +[[components.backends]] +kind = "rpm" +package = "cosh-ng" +provides = "anolisa-component(cosh-ng)" +legacy_adopt = true + +[[components]] +name = "agentsight" +display_name = "AgentSight" +summary = "eBPF-based AI agent observability tool" +targets = [ + { os = "linux", arch = "x86_64" }, + { os = "macos", arch = "aarch64" }, +] + +[[components.backends]] +kind = "raw" +package = "agentsight" + +[[components.backends]] +kind = "rpm" +package = "agentsight" +provides = "anolisa-component(agentsight)" +legacy_adopt = true + +[[components]] +name = "agent-memory" +display_name = "Agent Memory" +summary = "Agent persistent memory and context" +targets = [{ os = "linux", arch = "x86_64" }] + +[[components.backends]] +kind = "raw" +package = "agent-memory" + +[[components.backends]] +kind = "rpm" +package = "agent-memory" +provides = "anolisa-component(agent-memory)" +legacy_adopt = true + +[[components]] +name = "os-skills" +display_name = "OS Skills" +summary = "Curated OS Skill library for agent runtimes" +targets = [{ os = "linux", arch = "x86_64" }] + +[[components.backends]] +kind = "raw" +package = "os-skills" + +[[components.backends]] +kind = "rpm" +package = "os-skills" +provides = "anolisa-component(os-skills)" +legacy_adopt = true + +[[components]] +name = "tokenless" +display_name = "Tokenless" +summary = "LLM token optimization toolkit" +targets = [ + { os = "linux", arch = "x86_64" }, + { os = "linux", arch = "aarch64" }, + { os = "macos", arch = "aarch64" }, +] + +[[components.backends]] +kind = "raw" +package = "tokenless" + +[[components.backends]] +kind = "rpm" +package = "tokenless" +provides = "anolisa-component(tokenless)" +legacy_adopt = true + +[[components]] +name = "ws-ckpt" +display_name = "Workspace Checkpoint" +summary = "Workspace checkpoint and rollback service" +targets = [{ os = "linux", arch = "x86_64" }] + +[[components.backends]] +kind = "raw" +package = "ws-ckpt" + +[[components.backends]] +kind = "rpm" +package = "ws-ckpt" +provides = "anolisa-component(ws-ckpt)" +legacy_adopt = true + +[[components]] +name = "sec-core" +display_name = "Agent Security Core" +summary = "Agent security core adapters and runtime" +targets = [{ os = "linux", arch = "x86_64" }] + +[[components.backends]] +kind = "rpm" +package = "agent-sec-core" +provides = "anolisa-component(sec-core)" +legacy_adopt = true + +[[components]] +name = "skillfs" +display_name = "SkillFS" +summary = "AI agent skill management via virtual filesystem" +targets = [{ os = "linux", arch = "x86_64" }] + +[[components.backends]] +kind = "raw" +package = "skillfs" + +[[components.backends]] +kind = "rpm" +package = "skillfs" +provides = "anolisa-component(skillfs)" +legacy_adopt = true