Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
864a26b
feat(ui): surface package bundles in matrix
May 20, 2026
f8c1e3a
feat(ui): enrich package inspector details
May 20, 2026
57aff52
feat(ui): add package inspector actions
May 20, 2026
cf80137
feat(ui): show package sync status
May 20, 2026
e39639c
feat(ui): preview skill readmes in inspector
May 20, 2026
a5e81ec
feat(ui): search package bundles in spotlight
May 20, 2026
f071218
feat(ui): show package component usage
May 20, 2026
a716566
feat(ui): show skill usage in inspector
May 20, 2026
211c32d
feat(ui): show matrix usage metrics
May 20, 2026
f689660
ci: avoid duplicate branch builds
May 20, 2026
aeb4143
ci: select Xcode 16 for Swift tests
May 20, 2026
de1c96e
fix(ui): stabilize CJK skill search matching
May 20, 2026
4317b35
feat(ui): densify capability matrix
May 20, 2026
9da655b
fix(ui): load persisted stubs on launch
May 20, 2026
4098c07
fix(ui): normalize capability search delimiters
May 20, 2026
ae9b0fa
feat(ui): segment matrix inspector details
May 20, 2026
dbf3664
fix(ui): surface sync diagnostics
May 20, 2026
8eb1d68
feat(ui): enrich package component statuses
May 20, 2026
873bde4
feat(ui): split skill inspector paths and version
May 20, 2026
3d13771
feat(ui): add inspector header status chips
May 20, 2026
09e6d2b
feat(ui): filter matrix broken links
May 20, 2026
7fbf241
feat(ui): add matrix sidebar shortcuts
May 20, 2026
c58bf88
feat(ui): surface sidebar sync status
May 20, 2026
6d338d3
feat(ui): add matrix version column
May 20, 2026
f0b283a
perf(ui): cache matrix sidebar counts
May 20, 2026
4e52165
feat(ui): badge broken matrix rows
May 20, 2026
fba0aec
feat(ui): make matrix sort interactive
May 20, 2026
8c69910
feat(ui): broaden matrix search
May 20, 2026
cc71641
feat(ui): scope matrix usage to recent window
May 20, 2026
00a4969
feat(ui): chart inspector usage trend
May 20, 2026
74ec1f4
feat(ui): show skill bundle context
May 20, 2026
fdb52fc
feat(ui): add skill inspector actions
May 20, 2026
f069693
feat(ui): activate bundle apps from inspector
May 20, 2026
17c9586
feat(ui): summarize skill machine state
May 20, 2026
6459c22
feat(ui): show skill source paths
May 20, 2026
7a28e9b
feat(ui): surface skill manifest metadata
May 20, 2026
818bd62
feat(ui): show skill dependency health
May 20, 2026
1335d9c
feat(ui): summarize package machine usage
May 20, 2026
01e2170
feat(ui): summarize package sync health
May 20, 2026
bf874b1
feat(ui): show package component versions
May 20, 2026
4717b9d
feat(ui): mark final package component row
May 20, 2026
59fa83a
feat(ui): summarize package composition
May 20, 2026
a4a534a
feat(ui): rescan usage from spotlight
May 20, 2026
c90586b
feat(ui): filter matrix from spotlight
May 21, 2026
f4aa021
feat(ui): rank spotlight recents by usage
May 21, 2026
b1c8ba4
feat(ui): surface package actions in overview
May 21, 2026
9118bdf
feat(ui): explain package coverage gaps
May 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,46 @@ name: CI

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
macos:
name: macOS Build
runs-on: macos-14
timeout-minutes: 30

steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive

- name: Select Xcode 16
timeout-minutes: 2
run: |
XCODE_PATH="$(ls -d /Applications/Xcode_16*.app 2>/dev/null | sort | tail -n 1)"
test -n "$XCODE_PATH"
sudo xcode-select -s "$XCODE_PATH"
swift --version

- name: Install Rust
timeout-minutes: 5
run: rustup toolchain install stable --profile minimal

- name: Build sidecar
run: cargo build --manifest-path skill-cli/Cargo.toml
timeout-minutes: 10
run: cargo build --locked --manifest-path skill-cli/Cargo.toml

- name: Build SwiftUI app
timeout-minutes: 10
run: swift build --package-path swift-app

- name: Run Swift tests
timeout-minutes: 10
run: swift test --package-path swift-app
Binary file modified docs/screenshots/matrix.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
266 changes: 257 additions & 9 deletions skill-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::env;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::process::ExitCode;
Expand Down Expand Up @@ -557,7 +559,7 @@ async fn run() -> Result<()> {
.get_installed_skill(&skill_id)
.context("failed to read installed skill")?
.with_context(|| format!("skill not found: {skill_id}"))?;
print_json(&ApiResponse::ok(skill))
print_json(&ApiResponse::ok(enrich_installed_skill(skill)))
}
Commands::CheckUpdates { json: _ } => {
let service = SkillService::new();
Expand Down Expand Up @@ -726,15 +728,15 @@ async fn run() -> Result<()> {
);
}
}
print_json(&ApiResponse::ok(installed))
print_json(&ApiResponse::ok(enrich_installed_skill(installed)))
}
Commands::Update { skill_id, json: _ } => {
let service = SkillService::new();
let skill = service
.update_skill(&db, &skill_id)
.await
.with_context(|| format!("failed to update skill '{skill_id}'"))?;
print_json(&ApiResponse::ok(skill))
print_json(&ApiResponse::ok(enrich_installed_skill(skill)))
}
Commands::Uninstall {
skill_id,
Expand All @@ -761,7 +763,7 @@ async fn run() -> Result<()> {
let app_type = parse_target_app(&app)?;
let skill = rehydrate_stub(&db, &skill_id, &app_type)
.with_context(|| format!("failed to rehydrate skill '{skill_id}'"))?;
print_json(&ApiResponse::ok(skill))
print_json(&ApiResponse::ok(enrich_installed_skill(skill)))
}
Commands::SecurityScan {
skill_dir,
Expand Down Expand Up @@ -795,7 +797,7 @@ async fn run() -> Result<()> {
let app_type = parse_target_app(&app)?;
let skill = SkillService::restore_from_backup(&db, &backup_id, &app_type)
.with_context(|| format!("failed to restore skill backup '{backup_id}'"))?;
print_json(&ApiResponse::ok(skill))
print_json(&ApiResponse::ok(enrich_installed_skill(skill)))
}
Commands::BackupDelete { backup_id, json: _ } => {
SkillService::delete_backup(&backup_id)
Expand Down Expand Up @@ -2183,6 +2185,44 @@ struct EnrichedInstalledSkill {
/// "位置与链接" section 以及"链接健康" view。
#[serde(skip_serializing_if = "Option::is_none")]
deployment: Option<DeploymentInfo>,
/// SKILL.md frontmatter that is useful for the matrix/detail UI.
#[serde(skip_serializing_if = "Option::is_none")]
manifest: Option<SkillManifest>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct SkillManifest {
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
license: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
homepage: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
required_bins: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
required_tools: Vec<SkillManifestTool>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct SkillManifestTool {
name: String,
available: bool,
}

impl SkillManifest {
fn is_empty(&self) -> bool {
self.version.is_none()
&& self.author.is_none()
&& self.license.is_none()
&& self.homepage.is_none()
&& self.required_bins.is_empty()
&& self.required_tools.is_empty()
}
}

/// 描述一个 skill 在中心仓(SSOT)和各 AI 工具目录之间的部署形态。
Expand Down Expand Up @@ -2590,13 +2630,17 @@ fn npm_global_mcp_list() -> Vec<String> {

fn enrich_installed_skill(skill: InstalledSkill) -> EnrichedInstalledSkill {
// CC Switch already parses SKILL.md frontmatter description (including YAML
// multi-line scalars), so reuse it for capability_summary. Only read SKILL.md
// ourselves to extract the `triggers:` field, which CC Switch does not expose.
let trigger_scenarios = installed_skill_dir(&skill)
// multi-line scalars), so reuse it for capability_summary. Read SKILL.md
// once here for UI metadata CC Switch does not expose.
let skill_markdown = installed_skill_dir(&skill)
.ok()
.and_then(|dir| std::fs::read_to_string(dir.join("SKILL.md")).ok())
.map(|content| parse_skill_triggers(&content))
.map(|content| content);
let trigger_scenarios = skill_markdown
.as_deref()
.map(parse_skill_triggers)
.unwrap_or_default();
let manifest = skill_markdown.as_deref().and_then(parse_skill_manifest);

let capability_summary = skill.description.as_deref().and_then(first_sentence_of);
let source_type = Some(detect_source_type(&skill));
Expand All @@ -2608,6 +2652,7 @@ fn enrich_installed_skill(skill: InstalledSkill) -> EnrichedInstalledSkill {
trigger_scenarios,
source_type,
deployment,
manifest,
}
}

Expand Down Expand Up @@ -3055,6 +3100,126 @@ fn parse_skill_triggers(content: &str) -> Vec<String> {
triggers
}

fn parse_skill_manifest(content: &str) -> Option<SkillManifest> {
let Some((frontmatter, _body)) = split_frontmatter(content) else {
return None;
};

let mut manifest = SkillManifest::default();
let mut in_required_bins_block = false;

for line in frontmatter.lines() {
let trimmed = line.trim_start();

if in_required_bins_block {
if let Some(item) = trimmed.strip_prefix("- ") {
let bin = unquote_frontmatter_value(item.trim());
if !bin.is_empty() {
manifest.required_bins.push(bin);
}
continue;
}
in_required_bins_block = false;
}

let Some((key, value)) = trimmed.split_once(':') else {
continue;
};
let key = key.trim();
let value = value.trim();

match key {
"version" => assign_manifest_scalar(&mut manifest.version, value),
"author" => assign_manifest_scalar(&mut manifest.author, value),
"license" => assign_manifest_scalar(&mut manifest.license, value),
"homepage" => assign_manifest_scalar(&mut manifest.homepage, value),
"anyBins" | "requiredBins" | "bins" => {
if value.is_empty() {
in_required_bins_block = true;
} else {
manifest.required_bins.extend(parse_frontmatter_list(value));
}
}
_ => {}
}
}

manifest.required_bins.sort();
manifest.required_bins.dedup();
manifest.required_tools = manifest
.required_bins
.iter()
.map(|name| SkillManifestTool {
name: name.clone(),
available: command_available(name),
})
.collect();

if manifest.is_empty() {
None
} else {
Some(manifest)
}
}

fn assign_manifest_scalar(target: &mut Option<String>, value: &str) {
if target.is_some() {
return;
}
let value = unquote_frontmatter_value(value);
if !value.is_empty() {
*target = Some(value);
}
}

fn parse_frontmatter_list(value: &str) -> Vec<String> {
let trimmed = value.trim().trim_start_matches('[').trim_end_matches(']');
trimmed
.split(',')
.map(unquote_frontmatter_value)
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
.collect()
}

fn command_available(command: &str) -> bool {
let command = command.trim();
if command.is_empty() {
return false;
}

let explicit_path = Path::new(command);
if explicit_path.components().count() > 1 {
return is_executable(explicit_path);
}

let Some(paths) = env::var_os("PATH") else {
return false;
};

env::split_paths(&paths).any(|path| is_executable(&path.join(command)))
}

fn is_executable(path: &Path) -> bool {
let Ok(metadata) = fs::metadata(path) else {
return false;
};
if !metadata.is_file() {
return false;
}
has_execute_bit(&metadata)
}

#[cfg(unix)]
fn has_execute_bit(metadata: &fs::Metadata) -> bool {
metadata.permissions().mode() & 0o111 != 0
}

#[cfg(not(unix))]
fn has_execute_bit(_metadata: &fs::Metadata) -> bool {
true
}

fn first_sentence_of(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
Expand Down Expand Up @@ -4221,6 +4386,89 @@ Turns fuzzy product ideas into crisp release plans.
);
}

#[test]
fn parse_skill_manifest_reads_version_source_and_required_bins() {
let manifest = parse_skill_manifest(
r#"---
name: baoyu-comic
description: Create knowledge comics.
version: 1.56.1
author: "@dotey"
license: MIT
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-comic
requires:
anyBins:
- bun
- npx
---
# Knowledge Comic Creator
"#,
)
.expect("manifest should parse");

assert_eq!(manifest.version.as_deref(), Some("1.56.1"));
assert_eq!(manifest.author.as_deref(), Some("@dotey"));
assert_eq!(manifest.license.as_deref(), Some("MIT"));
assert_eq!(
manifest.homepage.as_deref(),
Some("https://github.com/JimLiu/baoyu-skills#baoyu-comic")
);
assert_eq!(manifest.required_bins, vec!["bun", "npx"]);
assert_eq!(
manifest
.required_tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>(),
vec!["bun", "npx"]
);
}

#[test]
fn parse_skill_manifest_accepts_inline_required_bins() {
let manifest = parse_skill_manifest(
r#"---
version: "2.4.1"
homepage: 'https://example.com/demo'
requiredBins: [sh, "definitely-popskill-missing-command"]
---
"#,
)
.expect("manifest should parse");

assert_eq!(manifest.version.as_deref(), Some("2.4.1"));
assert_eq!(
manifest.homepage.as_deref(),
Some("https://example.com/demo")
);
assert_eq!(
manifest.required_bins,
vec!["definitely-popskill-missing-command", "sh"]
);
assert_eq!(
manifest.required_tools,
vec![
SkillManifestTool {
name: "definitely-popskill-missing-command".to_string(),
available: false,
},
SkillManifestTool {
name: "sh".to_string(),
available: true,
}
]
);
}

#[test]
fn command_available_detects_missing_commands() {
assert!(!command_available(
"definitely-popskill-missing-command-for-tests"
));
}

#[test]
fn local_agent_from_markdown_infers_category_and_title() {
let root = Path::new("/tmp/agents");
Expand Down
Loading
Loading