Skip to content

Commit e4a302c

Browse files
authored
Merge pull request #7 from derekross/feature/skills-library
feat: skills.sh library integration
2 parents e793862 + 9a16dcd commit e4a302c

8 files changed

Lines changed: 909 additions & 77 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "onyx",
3-
"version": "0.7.0",
3+
"version": "0.8.0",
44
"description": "Open source knowledge base and note-taking app",
55
"type": "module",
66
"scripts": {

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "onyx"
3-
version = "0.7.0"
3+
version = "0.8.0"
44
description = "Open source knowledge base and note-taking app"
55
authors = ["you"]
66
license = "MIT"

src-tauri/src/lib.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1480,6 +1480,49 @@ fn skill_read_file(skill_id: String, file_name: String) -> Result<String, String
14801480
fs::read_to_string(&file_path).map_err(|e| e.to_string())
14811481
}
14821482

1483+
#[tauri::command]
1484+
async fn fetch_skills_sh(limit: Option<u32>) -> Result<String, String> {
1485+
let limit = limit.unwrap_or(500); // Fetch up to 500 skills by default
1486+
let url = format!("https://skills.sh/api/skills?limit={}", limit);
1487+
1488+
let client = reqwest::Client::new();
1489+
let response = client
1490+
.get(&url)
1491+
.timeout(Duration::from_secs(30))
1492+
.send()
1493+
.await
1494+
.map_err(|e| format!("Failed to fetch skills.sh: {}", e))?;
1495+
1496+
if !response.status().is_success() {
1497+
return Err(format!("skills.sh returned status: {}", response.status()));
1498+
}
1499+
1500+
response
1501+
.text()
1502+
.await
1503+
.map_err(|e| format!("Failed to read response: {}", e))
1504+
}
1505+
1506+
#[tauri::command]
1507+
async fn fetch_skill_file(url: String) -> Result<String, String> {
1508+
let client = reqwest::Client::new();
1509+
let response = client
1510+
.get(&url)
1511+
.timeout(Duration::from_secs(30))
1512+
.send()
1513+
.await
1514+
.map_err(|e| format!("Failed to fetch skill file: {}", e))?;
1515+
1516+
if !response.status().is_success() {
1517+
return Err(format!("Failed to fetch skill file: status {}", response.status()));
1518+
}
1519+
1520+
response
1521+
.text()
1522+
.await
1523+
.map_err(|e| format!("Failed to read skill file: {}", e))
1524+
}
1525+
14831526
// Keyring commands for secure credential storage (desktop only)
14841527
#[cfg(not(target_os = "android"))]
14851528
mod keyring_commands {
@@ -1661,6 +1704,8 @@ pub fn run() {
16611704
skill_delete,
16621705
skill_list_installed,
16631706
skill_read_file,
1707+
fetch_skills_sh,
1708+
fetch_skill_file,
16641709
get_platform_info,
16651710
opencode_installer::check_opencode_installed,
16661711
opencode_installer::get_opencode_install_path,

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
33
"productName": "Onyx",
4-
"version": "0.7.0",
4+
"version": "0.8.0",
55
"identifier": "com.onyxnotes.dev",
66
"build": {
77
"frontendDist": "../dist",

src/components/Settings.tsx

Lines changed: 387 additions & 73 deletions
Large diffs are not rendered by default.

src/lib/skills.ts

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
/**
2+
* Skills library integration
3+
*
4+
* Provides access to:
5+
* 1. Curated skills from onyx-skills repo (recommended)
6+
* 2. Full skills.sh ecosystem (browse library)
7+
*/
8+
9+
import { invoke } from '@tauri-apps/api/core';
10+
11+
// Skills.sh API types
12+
export interface SkillsShSkill {
13+
id: string;
14+
name: string;
15+
installs: number;
16+
topSource: string; // Format: "owner/repo"
17+
}
18+
19+
export interface SkillsShResponse {
20+
skills: SkillsShSkill[];
21+
hasMore: boolean;
22+
}
23+
24+
// Extended skill info for UI
25+
export interface SkillInfo {
26+
id: string;
27+
name: string;
28+
description: string;
29+
icon: string;
30+
category: string;
31+
dependencies?: string[];
32+
files: string[];
33+
isCustom?: boolean;
34+
// skills.sh specific fields
35+
source?: 'onyx' | 'skillssh';
36+
installs?: number;
37+
topSource?: string; // "owner/repo"
38+
}
39+
40+
export interface SkillState {
41+
enabled: boolean;
42+
installed: boolean;
43+
downloading: boolean;
44+
}
45+
46+
// Cache for skills.sh data
47+
interface SkillsShCache {
48+
data: SkillsShSkill[];
49+
timestamp: number;
50+
}
51+
52+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
53+
54+
let skillsShCache: SkillsShCache | null = null;
55+
56+
/**
57+
* Fetch skills leaderboard from skills.sh API
58+
* Uses Tauri backend to bypass CORS restrictions
59+
* Returns cached data if available and not expired
60+
*/
61+
export async function fetchSkillsShLeaderboard(forceRefresh = false): Promise<SkillsShSkill[]> {
62+
// Check cache
63+
if (!forceRefresh && skillsShCache && Date.now() - skillsShCache.timestamp < CACHE_TTL_MS) {
64+
return skillsShCache.data;
65+
}
66+
67+
try {
68+
// Use Tauri backend to bypass CORS
69+
const responseText = await invoke<string>('fetch_skills_sh');
70+
const data: SkillsShResponse = JSON.parse(responseText);
71+
72+
// Update cache
73+
skillsShCache = {
74+
data: data.skills,
75+
timestamp: Date.now(),
76+
};
77+
78+
return data.skills;
79+
} catch (err) {
80+
console.error('Failed to fetch skills.sh leaderboard:', err);
81+
// Return cached data if available, even if expired
82+
if (skillsShCache) {
83+
return skillsShCache.data;
84+
}
85+
throw err;
86+
}
87+
}
88+
89+
/**
90+
* Search/filter skills.sh skills
91+
*/
92+
export function searchSkillsSh(skills: SkillsShSkill[], query: string): SkillsShSkill[] {
93+
if (!query.trim()) {
94+
return skills;
95+
}
96+
97+
const lowerQuery = query.toLowerCase();
98+
return skills.filter(skill =>
99+
skill.id.toLowerCase().includes(lowerQuery) ||
100+
skill.name.toLowerCase().includes(lowerQuery) ||
101+
skill.topSource.toLowerCase().includes(lowerQuery)
102+
);
103+
}
104+
105+
/**
106+
* Sort skills by different criteria
107+
*/
108+
export type SkillsSortOption = 'popular' | 'name' | 'source';
109+
110+
export function sortSkillsSh(skills: SkillsShSkill[], sortBy: SkillsSortOption): SkillsShSkill[] {
111+
const sorted = [...skills];
112+
switch (sortBy) {
113+
case 'popular':
114+
return sorted.sort((a, b) => b.installs - a.installs);
115+
case 'name':
116+
return sorted.sort((a, b) => a.name.localeCompare(b.name));
117+
case 'source':
118+
return sorted.sort((a, b) => a.topSource.localeCompare(b.topSource));
119+
default:
120+
return sorted;
121+
}
122+
}
123+
124+
/**
125+
* Format install count for display (e.g., 27165 -> "27.2K")
126+
*/
127+
export function formatInstallCount(count: number): string {
128+
if (count >= 1000000) {
129+
return `${(count / 1000000).toFixed(1)}M`;
130+
}
131+
if (count >= 1000) {
132+
return `${(count / 1000).toFixed(1)}K`;
133+
}
134+
return count.toString();
135+
}
136+
137+
/**
138+
* Get GitHub raw URL for a skill file
139+
* skills.sh skills are stored at: https://raw.githubusercontent.com/{owner}/{repo}/main/skills/{skill-id}/SKILL.md
140+
*/
141+
export function getSkillFileUrl(topSource: string, skillId: string, fileName: string): string {
142+
const [owner, repo] = topSource.split('/');
143+
return `https://raw.githubusercontent.com/${owner}/${repo}/main/skills/${skillId}/${fileName}`;
144+
}
145+
146+
/**
147+
* Get GitHub URL for viewing a skill on GitHub
148+
*/
149+
export function getSkillGitHubUrl(topSource: string, skillId: string): string {
150+
const [owner, repo] = topSource.split('/');
151+
return `https://github.com/${owner}/${repo}/tree/main/skills/${skillId}`;
152+
}
153+
154+
/**
155+
* Download and install a skill from skills.sh
156+
* Uses Tauri backend to bypass CORS restrictions
157+
*/
158+
export async function installSkillFromSkillsSh(skill: SkillsShSkill): Promise<void> {
159+
const skillUrl = getSkillFileUrl(skill.topSource, skill.id, 'SKILL.md');
160+
161+
try {
162+
// Download SKILL.md using Tauri backend to bypass CORS
163+
const content = await invoke<string>('fetch_skill_file', { url: skillUrl });
164+
165+
// Save the skill file using Tauri backend
166+
await invoke('skill_save_file', {
167+
skillId: skill.id,
168+
fileName: 'SKILL.md',
169+
content,
170+
});
171+
} catch (err) {
172+
console.error(`Failed to install skill ${skill.id}:`, err);
173+
throw err;
174+
}
175+
}
176+
177+
/**
178+
* Check if a skill is installed
179+
*/
180+
export async function isSkillInstalled(skillId: string): Promise<boolean> {
181+
try {
182+
return await invoke<boolean>('skill_is_installed', { skillId });
183+
} catch {
184+
return false;
185+
}
186+
}
187+
188+
/**
189+
* Delete an installed skill
190+
*/
191+
export async function deleteSkill(skillId: string): Promise<void> {
192+
await invoke('skill_delete', { skillId });
193+
}
194+
195+
/**
196+
* Get list of all installed skill IDs
197+
*/
198+
export async function getInstalledSkillIds(): Promise<string[]> {
199+
return await invoke<string[]>('skill_list_installed');
200+
}
201+
202+
/**
203+
* Read a skill file content
204+
*/
205+
export async function readSkillFile(skillId: string, fileName: string): Promise<string> {
206+
return await invoke<string>('skill_read_file', { skillId, fileName });
207+
}
208+
209+
/**
210+
* Parse skill name from SKILL.md content
211+
*/
212+
export function parseSkillName(content: string, fallbackId: string): string {
213+
// Try to find a # heading
214+
const headingMatch = content.match(/^#\s+(.+)$/m);
215+
if (headingMatch) {
216+
return headingMatch[1].trim();
217+
}
218+
219+
// Try to find a title: metadata
220+
const titleMatch = content.match(/^title:\s*(.+)$/mi);
221+
if (titleMatch) {
222+
return titleMatch[1].trim();
223+
}
224+
225+
// Use ID as fallback, converting kebab-case to Title Case
226+
return fallbackId
227+
.split('-')
228+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
229+
.join(' ');
230+
}
231+
232+
/**
233+
* Parse skill description from SKILL.md content
234+
*/
235+
export function parseSkillDescription(content: string): string {
236+
// Try to find a description: metadata
237+
const descMatch = content.match(/^description:\s*(.+)$/mi);
238+
if (descMatch) {
239+
return descMatch[1].trim();
240+
}
241+
242+
// Try to find the first paragraph after the heading
243+
const lines = content.split('\n');
244+
let foundHeading = false;
245+
for (const line of lines) {
246+
if (line.startsWith('#')) {
247+
foundHeading = true;
248+
continue;
249+
}
250+
if (foundHeading && line.trim() && !line.startsWith('#') && !line.startsWith('-') && !line.startsWith('*')) {
251+
return line.trim().slice(0, 150);
252+
}
253+
}
254+
255+
return 'A skill for AI agents';
256+
}
257+
258+
/**
259+
* Clear the skills.sh cache
260+
*/
261+
export function clearSkillsShCache(): void {
262+
skillsShCache = null;
263+
}
264+
265+
/**
266+
* Get unique sources (owners) from skills list for filtering
267+
*/
268+
export function getUniqueSources(skills: SkillsShSkill[]): string[] {
269+
const sources = new Set<string>();
270+
for (const skill of skills) {
271+
const [owner] = skill.topSource.split('/');
272+
sources.add(owner);
273+
}
274+
return Array.from(sources).sort();
275+
}
276+
277+
/**
278+
* Filter skills by source/owner
279+
*/
280+
export function filterBySource(skills: SkillsShSkill[], source: string): SkillsShSkill[] {
281+
if (!source || source === 'all') {
282+
return skills;
283+
}
284+
return skills.filter(skill => skill.topSource.startsWith(source + '/'));
285+
}

0 commit comments

Comments
 (0)