1use super::components::scan_extensions;
2use super::{
3 InstalledPluginEntry, MarketplaceEntry, MarketplaceSourceEntry, PluginCapability,
4 PluginRunRowStatus, PluginUpdateRecord, PluginUpdateRun, PluginsInventorySnapshot,
5 classify_update_row,
6};
7use crate::env::git_command;
8use serde::Deserialize;
9use serde_json::Value;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12
13#[derive(Debug, Deserialize)]
14struct InstalledPluginJson {
15 id: String,
16 version: Option<String>,
17 scope: String,
18 enabled: bool,
19 #[serde(rename = "installedAt")]
20 installed_at: Option<String>,
21 #[serde(rename = "lastUpdated")]
22 last_updated: Option<String>,
23 #[serde(rename = "projectPath")]
24 project_path: Option<String>,
25 #[serde(rename = "mcpServers")]
26 mcp_servers: Option<Value>,
27}
28
29#[derive(Debug, Deserialize)]
30struct MarketplaceListJson {
31 available: Vec<AvailablePluginJson>,
32}
33
34#[derive(Debug, Deserialize)]
35struct AvailablePluginJson {
36 #[serde(rename = "pluginId")]
37 plugin_id: String,
38 name: String,
39 description: Option<String>,
40 #[serde(rename = "marketplaceName")]
41 marketplace_name: Option<String>,
42 version: Option<String>,
43 #[serde(rename = "installCount")]
44 install_count: Option<u64>,
45 source: Option<Value>,
46}
47
48#[derive(Debug, Deserialize)]
49struct MarketplaceSourceJson {
50 name: String,
51 source: Option<String>,
52 repo: Option<String>,
53 #[serde(rename = "installLocation")]
54 install_location: Option<String>,
55}
56
57pub async fn refresh_inventory(
58 cwd_raw: String,
59 cached_claude_path: Option<PathBuf>,
60) -> Result<(PluginsInventorySnapshot, PathBuf), String> {
61 tokio::task::spawn_blocking(move || {
62 let claude_path = resolve_claude_path(cached_claude_path)?;
63 let snapshot = refresh_inventory_blocking(&claude_path, &cwd_raw)?;
64 Ok((snapshot, claude_path))
65 })
66 .await
67 .map_err(|error| format!("Plugin inventory task failed: {error}"))?
68}
69
70pub async fn run_cli_command_and_refresh(
71 cwd_raw: String,
72 cached_claude_path: Option<PathBuf>,
73 args: Vec<String>,
74) -> Result<(PluginsInventorySnapshot, PathBuf), String> {
75 tokio::task::spawn_blocking(move || {
76 let claude_path = resolve_claude_path(cached_claude_path)?;
77 run_command(&claude_path, &cwd_raw, &args)?;
78 let snapshot = refresh_inventory_blocking(&claude_path, &cwd_raw)?;
79 Ok((snapshot, claude_path))
80 })
81 .await
82 .map_err(|error| format!("Plugin CLI action task failed: {error}"))?
83}
84
85fn resolve_claude_path(cached_claude_path: Option<PathBuf>) -> Result<PathBuf, String> {
86 if let Some(path) = cached_claude_path
87 && path.is_file()
88 {
89 return Ok(path);
90 }
91 which::which("claude").map_err(|_| "claude CLI not found in PATH".to_owned())
92}
93
94fn refresh_inventory_blocking(
95 claude_path: &Path,
96 cwd_raw: &str,
97) -> Result<PluginsInventorySnapshot, String> {
98 let started = std::time::Instant::now();
99 let installed = timed_json_command::<Vec<InstalledPluginJson>>(
100 claude_path,
101 cwd_raw,
102 &["plugin", "list", "--json"],
103 )?;
104 let available = timed_json_command::<MarketplaceListJson>(
105 claude_path,
106 cwd_raw,
107 &["plugin", "list", "--available", "--json"],
108 )?;
109 let marketplaces = timed_json_command::<Vec<MarketplaceSourceJson>>(
110 claude_path,
111 cwd_raw,
112 &["plugin", "marketplace", "list", "--json"],
113 )?;
114 tracing::info!(
115 target: "forge_agent::userdata::plugins",
116 duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
117 "plugin CLI inventory spawns (list, list --available, marketplace list)",
118 );
119
120 let mut installed_entries = installed
121 .into_iter()
122 .map(|entry| InstalledPluginEntry {
123 id: entry.id,
124 version: entry.version,
125 scope: entry.scope,
126 enabled: entry.enabled,
127 installed_at: entry.installed_at,
128 last_updated: entry.last_updated,
129 project_path: entry.project_path,
130 capability: if entry.mcp_servers.is_some() {
131 PluginCapability::Mcp
132 } else {
133 PluginCapability::Skill
134 },
135 })
136 .collect::<Vec<_>>();
137 installed_entries.sort_by_cached_key(|entry| entry.id.to_ascii_lowercase());
138
139 let mut marketplace_entries = available
140 .available
141 .into_iter()
142 .map(|entry| MarketplaceEntry {
143 plugin_id: entry.plugin_id,
144 name: entry.name,
145 description: entry.description,
146 marketplace_name: entry.marketplace_name,
147 version: entry.version,
148 install_count: entry.install_count,
149 source: entry.source,
150 })
151 .collect::<Vec<_>>();
152 marketplace_entries.sort_by_cached_key(|entry| {
153 (
154 entry.marketplace_name.as_deref().unwrap_or_default().to_ascii_lowercase(),
155 entry.name.to_ascii_lowercase(),
156 )
157 });
158
159 let mut marketplace_sources = marketplaces
160 .into_iter()
161 .map(|entry| MarketplaceSourceEntry {
162 name: entry.name,
163 source: entry.source,
164 repo: entry.repo,
165 install_location: entry.install_location,
166 })
167 .collect::<Vec<_>>();
168 marketplace_sources.sort_by_cached_key(|entry| entry.name.to_ascii_lowercase());
169
170 let (components, marketplace_health) = scan_components_dir(super::components::plugins_root());
174
175 Ok(PluginsInventorySnapshot {
176 installed: installed_entries,
177 marketplace: marketplace_entries,
178 marketplaces: marketplace_sources,
179 components,
180 marketplace_health,
181 token_costs: std::collections::BTreeMap::new(),
182 })
183}
184
185pub fn inventory_from_disk_blocking() -> Option<PluginsInventorySnapshot> {
189 let root = super::components::plugins_root()?;
190 let started = std::time::Instant::now();
191 let marketplaces_root = root.join("marketplaces");
192 let config_dir = root.parent().unwrap_or(root.as_path()).to_path_buf();
193 let scan = scan_extensions(&root, &marketplaces_root, &config_dir);
194 tracing::info!(
195 target: "forge_agent::userdata::plugins",
196 duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
197 installed = scan.installed.len(),
198 components = scan.components.len(),
199 marketplaces = scan.marketplace_health.len(),
200 "extension disk inventory scan",
201 );
202 Some(PluginsInventorySnapshot {
203 installed: scan.installed,
204 marketplace: scan.marketplace,
205 marketplaces: scan.marketplace_sources,
206 components: scan.components,
207 marketplace_health: scan.marketplace_health,
208 token_costs: std::collections::BTreeMap::new(),
209 })
210}
211
212fn scan_components_dir(
215 root: Option<std::path::PathBuf>,
216) -> (
217 Vec<forge_primitives::plugins::PluginComponents>,
218 Vec<forge_primitives::plugins::MarketplaceHealth>,
219) {
220 let Some(root) = root else {
221 tracing::warn!(
222 target: "forge_agent::userdata::plugins",
223 "no CLAUDE_CONFIG_DIR or HOME resolves; the extension disk scan is empty",
224 );
225 return (Vec::new(), Vec::new());
226 };
227 let started = std::time::Instant::now();
228 let marketplaces_root = root.join("marketplaces");
229 let config_dir = root.parent().unwrap_or(root.as_path()).to_path_buf();
230 let scan = scan_extensions(&root, &marketplaces_root, &config_dir);
231 tracing::info!(
232 target: "forge_agent::userdata::plugins",
233 duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
234 installed = scan.components.iter().filter(|row| row.installed).count(),
235 available = scan.components.iter().filter(|row| !row.installed).count(),
236 skills = scan.components.iter().map(|row| row.skills.len()).sum::<usize>(),
237 marketplaces = scan.marketplace_health.len(),
238 "extension inventory scan"
239 );
240 (scan.components, scan.marketplace_health)
241}
242
243fn timed_json_command<T>(claude_path: &Path, cwd_raw: &str, args: &[&str]) -> Result<T, String>
246where
247 T: for<'de> Deserialize<'de>,
248{
249 let started = std::time::Instant::now();
250 let result = parse_json_command(claude_path, cwd_raw, args);
251 tracing::info!(
252 target: "forge_agent::userdata::plugins",
253 duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
254 args = %args.join(" "),
255 outcome = if result.is_ok() { "ok" } else { "error" },
256 "plugin CLI spawn",
257 );
258 result
259}
260
261fn parse_json_command<T>(claude_path: &Path, cwd_raw: &str, args: &[&str]) -> Result<T, String>
262where
263 T: for<'de> Deserialize<'de>,
264{
265 let output = Command::new(claude_path)
266 .args(args)
267 .current_dir(cwd_raw)
268 .output()
269 .map_err(|error| format!("Failed to run `claude {}`: {error}", args.join(" ")))?;
270
271 if !output.status.success() {
272 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
273 let exit_code =
274 output.status.code().map_or_else(|| "unknown".to_owned(), |code| code.to_string());
275 let detail = if stderr.is_empty() {
276 format!("exit code {exit_code}")
277 } else {
278 format!("exit code {exit_code}: {stderr}")
279 };
280 return Err(format!("`claude {}` failed: {detail}", args.join(" ")));
281 }
282
283 serde_json::from_slice(&output.stdout)
284 .map_err(|error| format!("Failed to parse JSON from `claude {}`: {error}", args.join(" ")))
285}
286
287fn run_command(claude_path: &Path, cwd_raw: &str, args: &[String]) -> Result<(), String> {
288 let output = Command::new(claude_path)
289 .args(args)
290 .current_dir(cwd_raw)
291 .output()
292 .map_err(|error| format!("Failed to run `claude {}`: {error}", args.join(" ")))?;
293
294 if output.status.success() {
295 return Ok(());
296 }
297
298 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
299 let exit_code =
300 output.status.code().map_or_else(|| "unknown".to_owned(), |code| code.to_string());
301 let detail = if stderr.is_empty() {
302 format!("exit code {exit_code}")
303 } else {
304 format!("exit code {exit_code}: {stderr}")
305 };
306 Err(format!("`claude {}` failed: {detail}", args.join(" ")))
307}
308
309pub async fn run_cli_command(
316 cwd_raw: String,
317 cached_claude_path: Option<PathBuf>,
318 args: Vec<String>,
319) -> Result<(PathBuf, String), String> {
320 tokio::task::spawn_blocking(move || {
321 let claude_path = resolve_claude_path(cached_claude_path)?;
322 let output = Command::new(&claude_path)
323 .args(&args)
324 .current_dir(&cwd_raw)
325 .output()
326 .map_err(|error| format!("Failed to run `claude {}`: {error}", args.join(" ")))?;
327 let mut combined = String::from_utf8_lossy(&output.stdout).into_owned();
328 combined.push_str(&String::from_utf8_lossy(&output.stderr));
329 if output.status.success() {
330 return Ok((claude_path, combined));
331 }
332 let stderr = combined.trim().to_owned();
333 let exit_code =
334 output.status.code().map_or_else(|| "unknown".to_owned(), |code| code.to_string());
335 let detail = if stderr.is_empty() { format!("exit code {exit_code}") } else { stderr };
336 Err(format!("`claude {}` failed: {detail}", args.join(" ")))
337 })
338 .await
339 .map_err(|error| format!("Plugin CLI task failed: {error}"))?
340}
341
342pub fn plugin_update_args(plugin_id: &str, scope: &str) -> Vec<String> {
344 vec![
345 "plugin".to_owned(),
346 "update".to_owned(),
347 plugin_id.to_owned(),
348 "--scope".to_owned(),
349 scope.to_owned(),
350 ]
351}
352
353pub async fn marketplace_head(install_location: String) -> Option<String> {
357 tokio::task::spawn_blocking(move || {
358 let output = git_command::command("git")
359 .args(["rev-parse", "HEAD"])
360 .current_dir(&install_location)
361 .output()
362 .ok()?;
363 if !output.status.success() {
364 return None;
365 }
366 let head = String::from_utf8(output.stdout).ok()?;
367 let head = head.trim().to_owned();
368 (!head.is_empty()).then_some(head)
369 })
370 .await
371 .ok()
372 .flatten()
373}
374
375fn rollback_git_args(install_location: &str, ref_before: &str) -> Vec<Vec<String>> {
379 vec![
380 vec![
381 "-C".to_owned(),
382 install_location.to_owned(),
383 "fetch".to_owned(),
384 "origin".to_owned(),
385 ref_before.to_owned(),
386 ],
387 vec![
388 "-C".to_owned(),
389 install_location.to_owned(),
390 "checkout".to_owned(),
391 "--detach".to_owned(),
392 ref_before.to_owned(),
393 ],
394 ]
395}
396
397fn rollback_claude_args(record: &PluginUpdateRecord) -> Vec<Vec<String>> {
403 vec![
404 plugin_update_args(&record.plugin_id, &record.scope),
405 vec![
406 "plugin".to_owned(),
407 "marketplace".to_owned(),
408 "update".to_owned(),
409 record.marketplace.clone(),
410 ],
411 ]
412}
413
414#[derive(Debug, Clone, PartialEq, Eq)]
416pub enum PluginRollbackOutcome {
417 RolledBack,
419 RolledBackCloneParked(String),
423}
424
425pub async fn run_plugin_rollback(
433 claude_path: Option<PathBuf>,
434 cwd_raw: String,
435 record: PluginUpdateRecord,
436 install_location: String,
437) -> Result<PluginRollbackOutcome, String> {
438 let ref_before = record
439 .marketplace_ref_before
440 .clone()
441 .ok_or_else(|| "no pre-update marketplace ref recorded for this plugin".to_owned())?;
442 let git_steps = rollback_git_args(&install_location, &ref_before);
443 let claude_steps = rollback_claude_args(&record);
444 tokio::task::spawn_blocking(move || {
445 let claude_path = resolve_claude_path(claude_path)?;
446 for args in &git_steps {
447 let output = git_command::command("git")
448 .args(args)
449 .output()
450 .map_err(|error| format!("Failed to run git {}: {error}", args.join(" ")))?;
451 if !output.status.success() {
452 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
453 return Err(format!("git {} failed: {stderr}", args.join(" ")));
454 }
455 }
456 run_command(&claude_path, &cwd_raw, &claude_steps[0])?;
459 match run_command(&claude_path, &cwd_raw, &claude_steps[1]) {
460 Ok(()) => Ok(PluginRollbackOutcome::RolledBack),
461 Err(message) => Ok(PluginRollbackOutcome::RolledBackCloneParked(message)),
462 }
463 })
464 .await
465 .map_err(|error| format!("Plugin rollback task failed: {error}"))?
466}
467
468pub use forge_primitives::plugins::PluginDetails;
473
474fn parse_always_on_tokens(output: &str) -> Option<u64> {
477 let line = output.lines().find(|line| line.trim_start().starts_with("Always-on:"))?;
478 line.split(':')
479 .nth(1)?
480 .split_whitespace()
481 .find_map(|part| part.trim_start_matches('~').parse::<u64>().ok())
482}
483
484pub fn output_states_restart_required(output: &str) -> bool {
487 output.to_ascii_lowercase().contains("restart required")
488}
489
490#[derive(Debug, Clone, PartialEq, Eq)]
494pub struct UpdateOutcome {
495 pub output: String,
496 pub restart_required: bool,
497}
498
499pub async fn update_plugin(
501 claude_path: Option<PathBuf>,
502 cwd_raw: String,
503 plugin_id: &str,
504 scope: &str,
505) -> Result<UpdateOutcome, String> {
506 let args = plugin_update_args(plugin_id, scope);
507 let (_, output) = run_cli_command(cwd_raw, claude_path, args).await?;
508 let restart_required = output_states_restart_required(&output);
509 Ok(UpdateOutcome { output, restart_required })
510}
511
512pub async fn plugin_details(
514 claude_path: Option<PathBuf>,
515 cwd_raw: String,
516 plugin_id: String,
517) -> Result<PluginDetails, String> {
518 tokio::task::spawn_blocking(move || {
519 let claude_path = resolve_claude_path(claude_path)?;
520 plugin_details_blocking(&claude_path, &cwd_raw, &plugin_id)
521 })
522 .await
523 .map_err(|error| format!("Plugin details task failed: {error}"))?
524}
525
526fn plugin_details_blocking(
527 claude_path: &Path,
528 cwd_raw: &str,
529 plugin_id: &str,
530) -> Result<PluginDetails, String> {
531 let output = Command::new(claude_path)
532 .args(["plugin", "details", plugin_id])
533 .current_dir(cwd_raw)
534 .output()
535 .map_err(|error| format!("Failed to run `claude plugin details`: {error}"))?;
536 if !output.status.success() {
537 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
538 return Err(format!("`claude plugin details` failed: {stderr}"));
539 }
540 let text = String::from_utf8_lossy(&output.stdout).into_owned();
541 let token_cost_always_on = parse_always_on_tokens(&text)
542 .ok_or_else(|| "no Always-on cost in the `claude plugin details` output".to_owned())?;
543 Ok(PluginDetails { token_cost_always_on })
544}
545
546pub async fn fetch_plugin_details(
551 cwd_raw: String,
552 cached_claude_path: Option<PathBuf>,
553 requests: Vec<(String, String)>,
554) -> std::collections::BTreeMap<String, (String, PluginDetails)> {
555 let started = std::time::Instant::now();
556 let result = fetch_plugin_details_inner(cwd_raw, cached_claude_path, requests).await;
557 tracing::info!(
558 target: "forge_agent::userdata::plugins",
559 duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
560 costs = result.len(),
561 "plugin token-cost fetch",
562 );
563 result
564}
565
566async fn fetch_plugin_details_inner(
570 cwd_raw: String,
571 cached_claude_path: Option<PathBuf>,
572 requests: Vec<(String, String)>,
573) -> std::collections::BTreeMap<String, (String, PluginDetails)> {
574 if requests.is_empty() {
575 return std::collections::BTreeMap::new();
576 }
577 tokio::task::spawn_blocking(move || {
578 let mut costs = std::collections::BTreeMap::new();
579 let Ok(claude_path) = resolve_claude_path(cached_claude_path) else {
580 return costs;
581 };
582 for (plugin_id, version) in requests {
583 match plugin_details_blocking(&claude_path, &cwd_raw, &plugin_id) {
584 Ok(details) => {
585 costs.insert(plugin_id, (version, details));
586 }
587 Err(error) => {
588 tracing::warn!(
589 target: "forge_agent::userdata::plugins",
590 plugin = %plugin_id,
591 error = %error,
592 "plugin token-cost fetch failed; the row shows no cost",
593 );
594 }
595 }
596 }
597 costs
598 })
599 .await
600 .map_err(|error| {
601 tracing::warn!(
602 target: "forge_agent::userdata::plugins",
603 error = %error,
604 "plugin token-cost fetch task failed to join",
605 );
606 error
607 })
608 .unwrap_or_default()
609}
610
611pub type RunnerFut<T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send>>;
617pub type RunnerUpdateResult = Result<(PathBuf, String), String>;
620pub type RunnerRefreshResult = Result<(PluginsInventorySnapshot, PathBuf), String>;
622type SharedUpdateFn = std::sync::Arc<
623 dyn Fn(Option<PathBuf>, String, Vec<String>) -> RunnerFut<RunnerUpdateResult> + Send + Sync,
624>;
625type SharedRefreshFn =
626 std::sync::Arc<dyn Fn(Option<PathBuf>, String) -> RunnerFut<RunnerRefreshResult> + Send + Sync>;
627
628#[derive(Clone)]
632pub struct UpdateRunner {
633 pub run_update: SharedUpdateFn,
634 pub refresh: SharedRefreshFn,
635}
636
637impl std::fmt::Debug for UpdateRunner {
638 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
639 f.debug_struct("UpdateRunner").finish_non_exhaustive()
640 }
641}
642
643impl UpdateRunner {
644 pub fn real() -> Self {
647 Self {
648 run_update: std::sync::Arc::new(|cached, cwd, args| {
649 Box::pin(run_cli_command(cwd, cached, args))
650 }),
651 refresh: std::sync::Arc::new(|cached, cwd| Box::pin(refresh_inventory(cwd, cached))),
652 }
653 }
654}
655
656pub struct BatchPlan {
660 pub cwd_context: String,
661 pub claude_path: Option<PathBuf>,
662 pub call_timeout: std::time::Duration,
665 pub marketplace_refs: std::collections::HashMap<String, String>,
666 pub run: PluginUpdateRun,
667}
668
669pub struct BatchOut {
673 pub run: PluginUpdateRun,
674 pub records: Vec<PluginUpdateRecord>,
675 pub restart_required: Vec<String>,
676 pub snapshot: Option<PluginsInventorySnapshot>,
677 pub claude_path: Option<PathBuf>,
678}
679
680pub async fn execute_update_batch(
684 mut plan: BatchPlan,
685 runner: UpdateRunner,
686 mut on_progress: impl FnMut(PluginUpdateRun),
687) -> BatchOut {
688 let row_count = plan.run.rows.len();
689 for index in 0..row_count {
690 if plan.run.rows[index].status != PluginRunRowStatus::Queued {
691 continue;
692 }
693 plan.run.rows[index].status = PluginRunRowStatus::Updating;
694 on_progress(plan.run.clone());
695 let row = &plan.run.rows[index];
696 let call = (runner.run_update)(
697 plan.claude_path.clone(),
698 row.cwd_raw.clone(),
699 plugin_update_args(&row.plugin_id, &row.scope),
700 );
701 let result = match tokio::time::timeout(plan.call_timeout, call).await {
702 Ok(result) => result,
703 Err(_) => Err(format!(
704 "`claude plugin update` timed out after {}s",
705 plan.call_timeout.as_secs()
706 )),
707 };
708 match result {
709 Ok((path, output)) => {
710 plan.claude_path = Some(path);
711 plan.run.rows[index].detail = Some(output);
712 }
713 Err(message) => {
714 tracing::warn!(
715 target: "forge_agent::userdata::plugins",
716 plugin = %plan.run.rows[index].plugin_id,
717 error = %message,
718 "plugin update call failed",
719 );
720 plan.run.rows[index].status = PluginRunRowStatus::Failed;
721 plan.run.rows[index].detail = Some(message);
722 }
723 }
724 }
725
726 let refresh = (runner.refresh)(plan.claude_path.clone(), plan.cwd_context.clone());
727 let snapshot = match tokio::time::timeout(plan.call_timeout, refresh).await {
728 Ok(Ok((snapshot, path))) => {
729 plan.claude_path = Some(path);
730 Some(snapshot)
731 }
732 Ok(Err(message)) => {
733 fail_still_running(
734 &mut plan.run,
735 &format!("post-update inventory refresh failed: {message}"),
736 );
737 None
738 }
739 Err(_) => {
740 fail_still_running(
741 &mut plan.run,
742 &format!(
743 "post-update inventory refresh timed out after {}s",
744 plan.call_timeout.as_secs()
745 ),
746 );
747 None
748 }
749 };
750
751 let mut records = Vec::new();
752 let mut restart_required = Vec::new();
753 for row in &mut plan.run.rows {
754 if row.status != PluginRunRowStatus::Updating {
755 continue;
756 }
757 if row.detail.as_deref().is_some_and(output_states_restart_required) {
758 restart_required.push(row.plugin_id.clone());
759 }
760 let version_after = snapshot.as_ref().and_then(|snapshot| {
761 snapshot
762 .installed
763 .iter()
764 .find(|entry| entry.id == row.plugin_id && entry.scope == row.scope)
765 .and_then(|entry| entry.version.as_deref())
766 });
767 let Some(version_after) = version_after else {
768 tracing::warn!(
772 target: "forge_agent::userdata::plugins",
773 plugin = %row.plugin_id,
774 "updated plugin missing from the post-update inventory; its row reads failed",
775 );
776 row.status = PluginRunRowStatus::Failed;
777 row.detail = Some("not found in post-update inventory".to_owned());
778 continue;
779 };
780 let before = row.installed_version.clone();
781 let output = row.detail.take().unwrap_or_default();
782 let outcome = classify_update_row(
783 &row.plugin_id,
784 &row.scope,
785 before.as_deref(),
786 Some(version_after),
787 &output,
788 );
789 row.status = outcome.status;
790 row.installed_version.clone_from(&outcome.installed_version);
791 row.detail = outcome.detail;
792 if restart_required.contains(&row.plugin_id) && row.detail.is_none() {
796 row.detail = Some("restart required to apply".to_owned());
797 }
798 if outcome.status == PluginRunRowStatus::Updated {
799 records.push(PluginUpdateRecord {
800 plugin_id: row.plugin_id.clone(),
801 marketplace: row.marketplace.clone(),
802 scope: row.scope.clone(),
803 cwd_raw: row.cwd_raw.clone(),
804 from_version: before,
805 to_version: row.installed_version.clone(),
806 marketplace_ref_before: plan.marketplace_refs.get(&row.marketplace).cloned(),
807 updated_at: now_rfc3339(),
808 trigger: plan.run.trigger,
809 });
810 }
811 }
812
813 plan.run.finished = true;
814 on_progress(plan.run.clone());
815 BatchOut { run: plan.run, records, restart_required, snapshot, claude_path: plan.claude_path }
816}
817
818fn fail_still_running(run: &mut PluginUpdateRun, message: &str) {
819 for row in &mut run.rows {
820 if row.status != PluginRunRowStatus::Updating {
821 continue;
822 }
823 row.status = PluginRunRowStatus::Failed;
824 let output = row.detail.take().unwrap_or_default();
827 row.detail = Some(if output.is_empty() {
828 message.to_owned()
829 } else {
830 format!("{output} | {message}")
831 });
832 }
833}
834
835fn now_rfc3339() -> String {
836 time::OffsetDateTime::now_utc()
837 .format(&time::format_description::well_known::Rfc3339)
838 .unwrap_or_default()
839}
840
841#[cfg(test)]
842mod tests {
843 use super::*;
844
845 use forge_primitives::plugins::{PluginUpdateRunRow, PluginUpdateTrigger};
846
847 fn queued_row(id: &str, version: &str) -> PluginUpdateRunRow {
850 PluginUpdateRunRow::queued(
851 format!("{id}@probe-market"),
852 "user".to_owned(),
853 String::new(),
854 Some(version.to_owned()),
855 )
856 }
857
858 fn fake_runner(
862 failing: &[&str],
863 ) -> (UpdateRunner, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
864 let failing: Vec<String> = failing.iter().map(|s| format!("{s}@probe-market")).collect();
865 let calls: std::sync::Arc<std::sync::Mutex<Vec<String>>> =
866 std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
867 let calls_update = std::sync::Arc::clone(&calls);
868 let runner = UpdateRunner {
869 run_update: std::sync::Arc::new(move |_, _, args| {
870 let id = args[2].clone();
871 let failing = failing.clone();
872 let calls = std::sync::Arc::clone(&calls_update);
873 Box::pin(async move {
874 calls.lock().expect("call log").push(id.clone());
875 if failing.contains(&id) {
876 Err(format!("`claude plugin update` failed for {id}"))
877 } else {
878 Ok((
879 PathBuf::from("claude"),
880 format!(
881 "Plugin \"{id}\" updated from 1.0.0 to 2.0.0 for scope user. \
882 Restart required to apply."
883 ),
884 ))
885 }
886 })
887 }),
888 refresh: std::sync::Arc::new(|_, _| {
889 Box::pin(async {
890 let entry =
891 |id: &str, version: &str| forge_primitives::plugins::InstalledPluginEntry {
892 id: format!("{id}@probe-market"),
893 version: Some(version.to_owned()),
894 scope: "user".to_owned(),
895 enabled: true,
896 installed_at: None,
897 last_updated: None,
898 project_path: None,
899 capability: forge_primitives::plugins::PluginCapability::Skill,
900 };
901 let snapshot = forge_primitives::plugins::PluginsInventorySnapshot {
902 installed: vec![
903 entry("first", "2.0.0"),
904 entry("second", "1.0.0"),
905 entry("third", "2.0.0"),
906 ],
907 ..Default::default()
908 };
909 Ok((snapshot, PathBuf::from("claude")))
910 })
911 }),
912 };
913 (runner, calls)
914 }
915
916 fn three_row_plan() -> BatchPlan {
917 BatchPlan {
918 cwd_context: "/proj".to_owned(),
919 claude_path: None,
920 call_timeout: std::time::Duration::from_secs(5),
921 marketplace_refs: std::collections::HashMap::new(),
922 run: PluginUpdateRun {
923 trigger: PluginUpdateTrigger::Manual,
924 finished: false,
925 rows: vec![
926 queued_row("first", "1.0.0"),
927 queued_row("second", "1.0.0"),
928 queued_row("third", "1.0.0"),
929 ],
930 },
931 }
932 }
933
934 #[tokio::test]
935 async fn a_mid_batch_failure_does_not_stop_the_batch() {
936 let (runner, calls) = fake_runner(&["second"]);
937 let progress: std::sync::Arc<std::sync::Mutex<Vec<Vec<PluginRunRowStatus>>>> =
938 std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
939 let sink = std::sync::Arc::clone(&progress);
940
941 let out = execute_update_batch(three_row_plan(), runner, move |run| {
942 sink.lock()
943 .expect("progress log")
944 .push(run.rows.iter().map(|row| row.status).collect());
945 })
946 .await;
947
948 assert!(out.run.finished);
949 let statuses: Vec<PluginRunRowStatus> = out.run.rows.iter().map(|row| row.status).collect();
950 assert_eq!(
951 statuses,
952 vec![
953 PluginRunRowStatus::Updated,
954 PluginRunRowStatus::Failed,
955 PluginRunRowStatus::Updated
956 ],
957 "every row reached a terminal status in plan order; got {statuses:?}"
958 );
959 assert_eq!(
960 *calls.lock().expect("call log"),
961 vec![
962 "first@probe-market".to_owned(),
963 "second@probe-market".to_owned(),
964 "third@probe-market".to_owned(),
965 ],
966 "the later rows were still attempted"
967 );
968 assert_eq!(out.run.summary(), "2 updated, 1 failed, 0 current");
969 assert_eq!(out.run.finished_count(), 2, "the panel's done count for the failed batch");
970 let last = progress.lock().expect("progress frames").last().cloned().unwrap_or_default();
971 assert_eq!(last.len(), 3, "the final progress frame carries all rows");
972 assert!(out.snapshot.is_some(), "the post-batch refresh landed");
973 }
974
975 #[tokio::test]
976 async fn restart_required_is_parsed_onto_the_row() {
977 let (runner, _) = fake_runner(&[]);
978 let out = execute_update_batch(three_row_plan(), runner, |_| {}).await;
979 let mut ids = out.restart_required.clone();
980 ids.sort();
981 assert_eq!(
982 ids,
983 vec![
984 "first@probe-market".to_owned(),
985 "second@probe-market".to_owned(),
986 "third@probe-market".to_owned()
987 ],
988 "every applied update in the fake states the contract; got {:?}",
989 out.restart_required
990 );
991 }
992
993 #[tokio::test]
994 async fn a_refresh_failure_fails_only_the_still_running_rows() {
995 let runner = UpdateRunner {
996 run_update: std::sync::Arc::new(|_, _, args| {
997 let id = args[2].clone();
998 Box::pin(async move {
999 Ok((
1000 PathBuf::from("claude"),
1001 format!(
1002 "Plugin \"{id}\" updated from 1.0.0 to 2.0.0 for scope user. \
1003 Restart required to apply."
1004 ),
1005 ))
1006 })
1007 }),
1008 refresh: std::sync::Arc::new(|_, _| {
1009 Box::pin(async { Err("claude not found".to_owned()) })
1010 }),
1011 };
1012 let out = execute_update_batch(three_row_plan(), runner, |_| {}).await;
1013 assert!(out.snapshot.is_none());
1014 assert!(
1015 out.run.rows.iter().all(|row| row.status == PluginRunRowStatus::Failed),
1016 "with no post-run inventory every row is undecidable and reads as failed: {:?}",
1017 out.run.rows
1018 );
1019 assert!(
1020 out.run
1021 .rows
1022 .iter()
1023 .all(|row| row.detail.as_deref().is_some_and(|d| d.contains("refresh failed"))),
1024 "the failure names the refresh: {:?}",
1025 out.run.rows.iter().map(|row| row.detail.clone()).collect::<Vec<_>>()
1026 );
1027 assert_eq!(out.run.finished_count(), 0);
1028 }
1029
1030 const DETAILS_OUTPUT: &str = "superpowers 6.3.0\n Description: Core skills library\n Source: superpowers@claude-plugins-official\n\nComponent inventory\n Skills (14) brainstorming, test-driven-development\n Agents (0)\n Hooks (1) SessionStart (harness-only - no model context cost)\n MCP servers (0)\n LSP servers (0)\n\nProjected token cost\n Always-on: ~450 tok added to every session\n\nPer-component (rounded)\n component always-on on-invoke\n using-git-worktrees ~40 ~1.5k\n";
1031
1032 #[test]
1033 fn always_on_tokens_parse_from_recorded_details_output() {
1034 assert_eq!(parse_always_on_tokens(DETAILS_OUTPUT), Some(450));
1035 assert_eq!(parse_always_on_tokens("no cost section"), None);
1036 }
1037
1038 #[test]
1039 fn restart_required_reads_the_cli_contract_line() {
1040 assert!(output_states_restart_required(
1041 "Plugin \"hello\" updated from 0.2.0 to 0.3.0 for scope user. Restart required to apply."
1042 ));
1043 assert!(!output_states_restart_required("hello is already at the latest version (0.2.0)."));
1044 }
1045
1046 #[test]
1047 fn plugin_update_args_pin_the_cli_invocation() {
1048 assert_eq!(
1049 plugin_update_args("hello@probe-market", "user"),
1050 vec![
1051 "plugin".to_owned(),
1052 "update".to_owned(),
1053 "hello@probe-market".to_owned(),
1054 "--scope".to_owned(),
1055 "user".to_owned(),
1056 ]
1057 );
1058 }
1059
1060 #[test]
1061 fn rollback_git_steps_fetch_then_detach_the_recorded_ref() {
1062 let steps = rollback_git_args("/clone/path", "2d7d4c6");
1063 assert_eq!(steps.len(), 2);
1064 assert_eq!(steps[0], vec!["-C", "/clone/path", "fetch", "origin", "2d7d4c6"]);
1065 assert_eq!(steps[1], vec!["-C", "/clone/path", "checkout", "--detach", "2d7d4c6"]);
1066 }
1067
1068 #[test]
1072 fn rollback_claude_steps_update_then_restore_the_clone() {
1073 let record = PluginUpdateRecord {
1074 plugin_id: "hello@probe-market".to_owned(),
1075 marketplace: "probe-market".to_owned(),
1076 scope: "user".to_owned(),
1077 cwd_raw: "/proj".to_owned(),
1078 from_version: Some("0.1.0".to_owned()),
1079 to_version: Some("0.2.0".to_owned()),
1080 marketplace_ref_before: Some("2d7d4c6".to_owned()),
1081 updated_at: String::new(),
1082 trigger: forge_primitives::plugins::PluginUpdateTrigger::Manual,
1083 };
1084 let steps = rollback_claude_args(&record);
1085 assert_eq!(steps.len(), 2);
1086 assert_eq!(
1087 steps[0],
1088 vec![
1089 "plugin".to_owned(),
1090 "update".to_owned(),
1091 "hello@probe-market".to_owned(),
1092 "--scope".to_owned(),
1093 "user".to_owned(),
1094 ]
1095 );
1096 assert_eq!(
1097 steps[1],
1098 vec![
1099 "plugin".to_owned(),
1100 "marketplace".to_owned(),
1101 "update".to_owned(),
1102 "probe-market".to_owned(),
1103 ]
1104 );
1105 }
1106
1107 #[test]
1108 fn parses_installed_plugin_entries() {
1109 let json = r#"
1110[
1111 {
1112 "id": "frontend-design@claude-plugins-official",
1113 "version": "55b58ec6e564",
1114 "scope": "local",
1115 "enabled": false,
1116 "installedAt": "2026-02-05T15:37:39.555Z",
1117 "lastUpdated": "2026-03-02T18:10:00.820Z",
1118 "projectPath": "C:\\work"
1119 }
1120]
1121"#;
1122
1123 let parsed = serde_json::from_str::<Vec<InstalledPluginJson>>(json).expect("parse json");
1124 assert_eq!(parsed.len(), 1);
1125 assert_eq!(parsed[0].id, "frontend-design@claude-plugins-official");
1126 assert_eq!(parsed[0].scope, "local");
1127 assert!(!parsed[0].enabled);
1128 assert_eq!(parsed[0].project_path.as_deref(), Some("C:\\work"));
1129 }
1130
1131 #[test]
1132 fn detects_mcp_plugins_from_installed_payload() {
1133 let json = r#"
1134[
1135 {
1136 "id": "supabase@claude-plugins-official",
1137 "scope": "local",
1138 "enabled": true,
1139 "mcpServers": {
1140 "supabase": {
1141 "type": "http",
1142 "url": "https://mcp.supabase.com/mcp"
1143 }
1144 }
1145 }
1146]
1147"#;
1148
1149 let parsed = serde_json::from_str::<Vec<InstalledPluginJson>>(json).expect("parse json");
1150 let entry = InstalledPluginEntry {
1151 id: parsed[0].id.clone(),
1152 version: parsed[0].version.clone(),
1153 scope: parsed[0].scope.clone(),
1154 enabled: parsed[0].enabled,
1155 installed_at: parsed[0].installed_at.clone(),
1156 last_updated: parsed[0].last_updated.clone(),
1157 project_path: parsed[0].project_path.clone(),
1158 capability: if parsed[0].mcp_servers.is_some() {
1159 PluginCapability::Mcp
1160 } else {
1161 PluginCapability::Skill
1162 },
1163 };
1164
1165 assert_eq!(entry.capability, PluginCapability::Mcp);
1166 }
1167
1168 #[test]
1169 fn parses_marketplace_entries_and_sources() {
1170 let available_json = r#"
1171{
1172 "installed": [],
1173 "available": [
1174 {
1175 "pluginId": "frontend-design@claude-plugins-official",
1176 "name": "frontend-design",
1177 "description": "Create distinctive interfaces",
1178 "marketplaceName": "claude-plugins-official",
1179 "version": "1.0.0",
1180 "source": "./plugins/frontend-design",
1181 "installCount": 42
1182 }
1183 ]
1184}
1185"#;
1186 let source_json = r#"
1187[
1188 {
1189 "name": "claude-plugins-official",
1190 "source": "github",
1191 "repo": "anthropics/claude-plugins-official",
1192 "installLocation": "/tmp/claude/plugins/marketplaces/claude-plugins-official"
1193 }
1194]
1195"#;
1196
1197 let parsed_available =
1198 serde_json::from_str::<MarketplaceListJson>(available_json).expect("parse available");
1199 let parsed_sources =
1200 serde_json::from_str::<Vec<MarketplaceSourceJson>>(source_json).expect("parse sources");
1201
1202 assert_eq!(parsed_available.available.len(), 1);
1203 assert_eq!(
1204 parsed_available.available[0].marketplace_name.as_deref(),
1205 Some("claude-plugins-official")
1206 );
1207 assert_eq!(parsed_available.available[0].install_count, Some(42));
1208 assert_eq!(parsed_sources[0].repo.as_deref(), Some("anthropics/claude-plugins-official"));
1209 assert_eq!(
1210 parsed_sources[0].install_location.as_deref(),
1211 Some("/tmp/claude/plugins/marketplaces/claude-plugins-official"),
1212 "rollback and ref recording need the clone location"
1213 );
1214 }
1215}