@@ -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 ) ]
1213pub 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+ /// ```
163180fn 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+ /// ```
231249fn 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