Skip to content

Commit dcbd82f

Browse files
fix: skills parser handles actual CLI output format (multi-line blocks with ❯ headers)
1 parent 192e33e commit dcbd82f

3 files changed

Lines changed: 107 additions & 70 deletions

File tree

crates/tauri-app/frontend/src/components/skills/SkillsPanel.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@ export function SkillsPanel() {
181181
<Show when={skill.source}>
182182
<span class="sk-item-source">@{skill.source}</span>
183183
</Show>
184+
<Show when={skill.version && skill.version !== "unknown"}>
185+
<span class="sk-item-version">v{skill.version}</span>
186+
</Show>
184187
</div>
185188
<div class="sk-item-actions">
186189
<button
@@ -242,7 +245,10 @@ export function SkillsPanel() {
242245
<For each={marketplaces()}>
243246
{(mp) => (
244247
<div class="sk-mp-item">
245-
<span class="sk-mp-url">{mp.url}</span>
248+
<span class="sk-mp-name">{mp.name}</span>
249+
<Show when={mp.source}>
250+
<span class="sk-mp-url">{mp.source}</span>
251+
</Show>
246252
</div>
247253
)}
248254
</For>

crates/tauri-app/frontend/src/ipc.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,10 +443,12 @@ export interface SkillInfo {
443443
name: string;
444444
source: string;
445445
enabled: boolean;
446+
version: string | null;
446447
}
447448

448449
export interface MarketplaceSource {
449-
url: string;
450+
name: string;
451+
source: string;
450452
}
451453

452454
export const listSkills = (provider: string) =>

crates/tauri-app/src/commands/skills.rs

Lines changed: 97 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ pub struct SkillInfo {
66
pub name: String,
77
pub source: String,
88
pub enabled: bool,
9+
pub version: Option<String>,
910
}
1011

1112
#[derive(Debug, Clone, Serialize)]
1213
pub struct MarketplaceSource {
13-
pub url: String,
14+
pub name: String,
15+
pub source: String,
1416
}
1517

1618
/// List installed plugins/skills for a given provider.
@@ -159,96 +161,123 @@ pub async fn add_marketplace(provider: String, source: String) -> Result<String,
159161
}
160162
}
161163

162-
/// Parse `claude plugin list` output into structured skill info.
164+
/// Parse `claude plugin list` output.
165+
///
166+
/// Format:
167+
/// ```text
168+
/// Installed plugins:
169+
///
170+
/// ❯ frontend-design@claude-plugins-official
171+
/// Version: unknown
172+
/// Scope: user
173+
/// Status: ✔ enabled
174+
///
175+
/// ❯ rust-analyzer-lsp@claude-plugins-official
176+
/// Version: 1.0.0
177+
/// Scope: user
178+
/// Status: ✔ enabled
179+
/// ```
163180
fn parse_skills_list(output: &str) -> Vec<SkillInfo> {
164181
let mut skills = Vec::new();
182+
let mut current_name = String::new();
183+
let mut current_source = String::new();
184+
let mut current_enabled = true;
185+
let mut current_version = String::new();
186+
let mut in_entry = false;
165187

166188
for line in output.lines() {
167-
let line = line.trim();
168-
if line.is_empty()
169-
|| line.starts_with("Plugin")
170-
|| line.starts_with("---")
171-
|| line.starts_with("No ")
172-
|| line.starts_with("Installed")
173-
{
174-
continue;
175-
}
176-
177-
// Lines typically look like:
178-
// "- skill-name@marketplace (enabled)"
179-
// "- skill-name (disabled)"
180-
// " skill-name marketplace-source enabled"
181-
let clean = line.trim_start_matches(['-', '*', ' '].as_ref()).trim();
182-
if clean.is_empty() {
183-
continue;
184-
}
189+
let trimmed = line.trim();
185190

186-
let enabled = !clean.contains("disabled");
187-
188-
if let Some((name_part, rest)) = clean.split_once('@') {
189-
let name = name_part.trim().to_string();
190-
let source = rest
191-
.split_whitespace()
192-
.next()
193-
.unwrap_or("")
194-
.trim_end_matches(')')
195-
.to_string();
196-
skills.push(SkillInfo {
197-
name,
198-
source,
199-
enabled,
200-
});
201-
} else if let Some((name_part, _)) = clean.split_once(char::is_whitespace) {
202-
let name = name_part.trim().to_string();
203-
if !name.is_empty() && !name.starts_with('(') {
191+
// Entry header: "❯ name@source" or "❯ name"
192+
if trimmed.starts_with('❯') || trimmed.starts_with('>') {
193+
// Save previous entry
194+
if in_entry && !current_name.is_empty() {
204195
skills.push(SkillInfo {
205-
name,
206-
source: String::new(),
207-
enabled,
196+
name: current_name.clone(),
197+
source: current_source.clone(),
198+
enabled: current_enabled,
199+
version: if current_version.is_empty() { None } else { Some(current_version.clone()) },
208200
});
209201
}
210-
} else {
211-
// Single word — just a name
212-
let name = clean
213-
.trim_end_matches("(enabled)")
214-
.trim_end_matches("(disabled)")
215-
.trim()
216-
.to_string();
217-
if !name.is_empty() {
218-
skills.push(SkillInfo {
219-
name,
220-
source: String::new(),
221-
enabled,
222-
});
202+
203+
let entry = trimmed.trim_start_matches('❯').trim_start_matches('>').trim();
204+
if let Some((name, source)) = entry.split_once('@') {
205+
current_name = name.trim().to_string();
206+
current_source = source.trim().to_string();
207+
} else {
208+
current_name = entry.to_string();
209+
current_source = String::new();
210+
}
211+
current_enabled = true;
212+
current_version = String::new();
213+
in_entry = true;
214+
} else if in_entry {
215+
if let Some(val) = trimmed.strip_prefix("Status:") {
216+
let val = val.trim();
217+
current_enabled = val.contains("enabled") || val.contains("✔");
218+
} else if let Some(val) = trimmed.strip_prefix("Version:") {
219+
current_version = val.trim().to_string();
223220
}
224221
}
225222
}
226223

224+
// Don't forget the last entry
225+
if in_entry && !current_name.is_empty() {
226+
skills.push(SkillInfo {
227+
name: current_name,
228+
source: current_source,
229+
enabled: current_enabled,
230+
version: if current_version.is_empty() { None } else { Some(current_version) },
231+
});
232+
}
233+
227234
skills
228235
}
229236

230237
/// Parse `claude plugin marketplace list` output.
238+
///
239+
/// Format:
240+
/// ```text
241+
/// Configured marketplaces:
242+
///
243+
/// ❯ claude-plugins-official
244+
/// Source: GitHub (anthropics/claude-plugins-official)
245+
///
246+
/// ❯ rust-skills
247+
/// Source: GitHub (actionbook/rust-skills)
248+
/// ```
231249
fn parse_marketplaces(output: &str) -> Vec<MarketplaceSource> {
232250
let mut sources = Vec::new();
251+
let mut current_name = String::new();
252+
let mut current_source = String::new();
253+
let mut in_entry = false;
233254

234255
for line in output.lines() {
235-
let line = line.trim();
236-
if line.is_empty()
237-
|| line.starts_with("Marketplace")
238-
|| line.starts_with("---")
239-
|| line.starts_with("No ")
240-
|| line.starts_with("Configured")
241-
{
242-
continue;
243-
}
256+
let trimmed = line.trim();
244257

245-
let clean = line.trim_start_matches(['-', '*', ' '].as_ref()).trim();
246-
if !clean.is_empty() {
247-
sources.push(MarketplaceSource {
248-
url: clean.to_string(),
249-
});
258+
if trimmed.starts_with('❯') || trimmed.starts_with('>') {
259+
if in_entry && !current_name.is_empty() {
260+
sources.push(MarketplaceSource {
261+
name: current_name.clone(),
262+
source: current_source.clone(),
263+
});
264+
}
265+
current_name = trimmed.trim_start_matches('❯').trim_start_matches('>').trim().to_string();
266+
current_source = String::new();
267+
in_entry = true;
268+
} else if in_entry {
269+
if let Some(val) = trimmed.strip_prefix("Source:") {
270+
current_source = val.trim().to_string();
271+
}
250272
}
251273
}
252274

275+
if in_entry && !current_name.is_empty() {
276+
sources.push(MarketplaceSource {
277+
name: current_name,
278+
source: current_source,
279+
});
280+
}
281+
253282
sources
254283
}

0 commit comments

Comments
 (0)