Skip to main content

forge_agent/userdata/plugins/
cli.rs

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    // The component scan reads the same config dir the CLI calls
171    // above resolved against (process-level env), so both halves of
172    // the snapshot describe one installation.
173    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
185/// The disk-only inventory: registry, settings and manifests, no
186/// `claude` spawn. The TTL refresh path runs this; `None` when no
187/// plugins root resolves.
188pub 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
212/// The disk scan over the resolved plugins root. A root that cannot
213/// resolve warns: an empty scan must never read as a clean install.
214fn 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
243/// `parse_json_command` with a per-call duration line, so the
244/// "why is the pane slow" question is answerable per spawn.
245fn 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
309/// Run one `claude plugin ...` action without the trailing inventory
310/// refresh, so a section-level run can interleave per-plugin work with
311/// progress updates and refresh once at the end. Returns the resolved
312/// claude path (reuse it across a run) plus the CLI's combined
313/// stdout+stderr - the update classifier needs the output, because the
314/// CLI exits 0 on some failures.
315pub 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
342/// The `claude plugin update` invocation for one installed entry.
343pub 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
353/// The marketplace clone's HEAD, or `None` when the location is not a
354/// git checkout (a directory-sourced marketplace) or git fails. This
355/// ref is what a rollback later restores.
356pub 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
375/// The two git steps of a rollback, in order: fetch the pre-update
376/// ref, then detach the marketplace clone onto it so `claude plugin
377/// update` resolves the version that manifest points at.
378fn 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
397/// The two `claude plugin` steps of a rollback, in order: install the
398/// version the rolled-back manifest points at, then move the
399/// marketplace clone forward again so other plugins keep tracking the
400/// latest. Swapping them would silently re-pin the clone at the old
401/// ref.
402fn 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/// How a rollback's CLI steps landed.
415#[derive(Debug, Clone, PartialEq, Eq)]
416pub enum PluginRollbackOutcome {
417    /// The plugin reinstalled and the marketplace clone moved forward.
418    RolledBack,
419    /// The plugin reinstalled but the clone could not move forward;
420    /// it is still parked at the old ref. A later `claude plugin
421    /// marketplace update <name>` repairs it.
422    RolledBackCloneParked(String),
423}
424
425/// Roll one plugin back to its recorded previous version: restore the
426/// marketplace clone to the pre-update ref, let `claude plugin update`
427/// install the version that manifest points at, then move the clone
428/// forward again so other plugins keep tracking the latest. A failed
429/// git or plugin-update step aborts with `Err`; only the trailing
430/// clone-restore failure lands as [`PluginRollbackOutcome::
431/// RolledBackCloneParked`].
432pub 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        // The plugin update decides the rollback; only the trailing
457        // clone restore is allowed to half-land.
458        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
468/// A plugin's `claude plugin details` projection: the always-on token
469/// cost every session pays for the plugin being installed. The shape
470/// lives in forge_primitives::plugins beside the snapshot that
471/// carries it.
472pub use forge_primitives::plugins::PluginDetails;
473
474/// The always-on token cost parsed from a details output, recorded
475/// from the CLI ("  Always-on:   ~450 tok   added to every session").
476fn 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
484/// The CLI's own contract: an applied update lands only after a
485/// restart, stated in the update output.
486pub fn output_states_restart_required(output: &str) -> bool {
487    output.to_ascii_lowercase().contains("restart required")
488}
489
490/// One `claude plugin update` call: the combined CLI output (the
491/// update classifier reads it, the CLI exits 0 on some failures) plus
492/// whether the output states the restart contract.
493#[derive(Debug, Clone, PartialEq, Eq)]
494pub struct UpdateOutcome {
495    pub output: String,
496    pub restart_required: bool,
497}
498
499/// Update one installed plugin in one scope.
500pub 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
512/// One plugin's `claude plugin details` projection.
513pub 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
546/// Fetch the always-on cost for `(plugin id, version)` requests,
547/// sequentially, one `claude plugin details` call each. A plugin whose
548/// details fail is simply absent from the map - a missing cost reads
549/// as no badge, never as an error on the pane.
550pub 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
566/// The requested version travels with the cost: an update landing
567/// between the request and the merge must not pin the old cost under
568/// the new version's key.
569async 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
611/// One future handed back by the [`UpdateRunner`] seams.
612///
613/// `Send` so a runner can drive a batch from a task that moves across
614/// threads, which is how `forge-workspace` runs one on the socket's
615/// behalf.
616pub type RunnerFut<T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send>>;
617/// The update seam's result: the resolved claude path plus the CLI's
618/// combined stdout+stderr.
619pub type RunnerUpdateResult = Result<(PathBuf, String), String>;
620/// The refresh seam's result: the inventory plus a resolved claude path.
621pub 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/// The CLI surface one update batch runs through; injectable so tests
629/// drive a whole batch without shelling out. The production instance
630/// wraps the `claude` subprocess calls.
631#[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    /// The real runner: one `claude plugin update` call per row plus
645    /// the closing inventory refresh.
646    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
656/// Everything one update batch needs beyond its queued rows. The
657/// caller owns plan capture: which plugins, their per-row cwd, and the
658/// marketplace clone HEADs a later rollback restores.
659pub struct BatchPlan {
660    pub cwd_context: String,
661    pub claude_path: Option<PathBuf>,
662    /// Upper bound on one CLI call inside the batch; a hung call fails
663    /// its row instead of pinning the batch forever.
664    pub call_timeout: std::time::Duration,
665    pub marketplace_refs: std::collections::HashMap<String, String>,
666    pub run: PluginUpdateRun,
667}
668
669/// The batch's outcome: the finished run, the rollback records for the
670/// rows that moved, the plugins whose update stated the restart
671/// contract, and the post-batch inventory when the refresh landed.
672pub 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
680/// Run a queued update batch: one CLI call per row, sequentially,
681/// continuing past failures. Every row reaches a terminal status in
682/// plan order; `on_progress` observes each intermediate run state.
683pub 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            // An entry that vanished from the inventory has no
769            // observable outcome and must not yield a rollback record
770            // naming a version nobody can see.
771            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        // The classifier drops the output on an applied update; state
793        // the restart contract back on the row so the pane can show it
794        // where the action was.
795        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        // The captured CLI output is the only evidence the update may
825        // have applied; keep it on the row.
826        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    // -- sequential batch -------------------------------------------------
848
849    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    /// A runner whose update calls fail for plugins named in `failing`
859    /// and otherwise report an applied update carrying the restart
860    /// contract; the refresh reports every plugin moved one version.
861    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    /// Install-from-the-old-manifest must run BEFORE the marketplace
1069    /// clone moves forward again; swapped, the rollback would silently
1070    /// re-update the plugin to the new version.
1071    #[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}