Skip to main content

forge_workspace/
plugins.rs

1//! Plugin and marketplace actions over the command bus.
2//!
3//! The work is reachable through [`crate::Command`] so that any view can
4//! act, rather than one view reaching for the `forge-agent` CLI functions
5//! this module calls from inside its own layer (rule 24's case). The CLI
6//! calls, the batch runner and the disk scan are `forge-agent`'s; what
7//! lives here is the orchestration: resolving each action's working
8//! directory from `forge.toml` rather than the launch directory (hard
9//! rule 14), serializing actions one at a time, and reporting through the
10//! `Plugins*` updates a view already listens to.
11//!
12//! One run at a time: an update-all, a check and an action all
13//! serialize through [`Workspace::plugin_action_running`], and the latest
14//! run stays readable mid-flight so a view attaching during one draws it.
15
16use std::path::PathBuf;
17use std::sync::Arc;
18use std::sync::atomic::Ordering;
19use std::time::Duration;
20
21use forge_agent::userdata::plugins::cli::{self, UpdateRunner};
22use forge_primitives::plugins::{
23    InstalledPluginEntry, MarketplaceEntry, MarketplaceSourceEntry, PluginRunRowStatus,
24    PluginUpdateRun, PluginUpdateRunRow, PluginUpdateTrigger, PluginsCliActionSuccess,
25    PluginsInventorySnapshot, update_availability,
26};
27
28use crate::protocol::{DispatchError, SessionUpdate};
29use crate::workspace::Workspace;
30
31/// Upper bound on one CLI call inside a batch; a hung call fails its row
32/// instead of pinning the run forever.
33const PLUGIN_CALL_TIMEOUT: Duration = Duration::from_secs(180);
34
35/// One future handed back by the plugin CLI seams.
36type PluginCliFut<T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send>>;
37type RunAndRefresh = Arc<
38    dyn Fn(String, Vec<String>) -> PluginCliFut<Result<(PluginsInventorySnapshot, PathBuf), String>>
39        + Send
40        + Sync,
41>;
42type RefreshFn = Arc<
43    dyn Fn(String) -> PluginCliFut<Result<(PluginsInventorySnapshot, PathBuf), String>>
44        + Send
45        + Sync,
46>;
47type ScanFn = Arc<dyn Fn() -> Option<PluginsInventorySnapshot> + Send + Sync>;
48
49/// How the plugin actions reach `claude` and the disk. Real in every
50/// production build.
51#[derive(Clone)]
52pub(crate) struct PluginCli {
53    /// Run one `claude plugin ...` action and refresh the inventory after
54    /// it, both in the given working directory.
55    pub run_and_refresh: RunAndRefresh,
56    /// Refresh the inventory without an action (the update check).
57    pub refresh: RefreshFn,
58    /// The batch runner one `PluginUpdateAll` drives.
59    pub runner: UpdateRunner,
60    /// The disk-only scan: registry, manifests and the plugin cache, no
61    /// `claude` spawn. `None` when no plugins root resolves.
62    pub scan: ScanFn,
63}
64
65impl PluginCli {
66    pub(crate) fn real() -> Self {
67        Self {
68            run_and_refresh: Arc::new(|cwd, args| {
69                Box::pin(cli::run_cli_command_and_refresh(cwd, None, args))
70            }),
71            refresh: Arc::new(|cwd| Box::pin(cli::refresh_inventory(cwd, None))),
72            runner: UpdateRunner::real(),
73            scan: Arc::new(cli::inventory_from_disk_blocking),
74        }
75    }
76}
77
78/// Clears the single-action gate however its task ends, panic included: a
79/// crashed task that left the gate shut would refuse every later press
80/// with no way back.
81struct PluginActionGuard(Arc<Workspace>);
82
83impl Drop for PluginActionGuard {
84    fn drop(&mut self) {
85        self.0.plugin_action_running.store(false, Ordering::Release);
86    }
87}
88
89/// The working directory one plugin CLI call runs in.
90///
91/// `project`/`local` scopes resolve against a declared project - the
92/// entry's own `project_path` for an update, the named project for an
93/// install - so the call always runs somewhere `forge.toml` names. Every
94/// other scope (the CLI's `user`, and anything a future CLI adds) runs in
95/// the home directory. Never the process cwd: what a plugin call does must
96/// not vary with where forge was launched (hard rule 14).
97fn plugin_call_cwd(scope: &str, project_path: Option<&str>, home: &str) -> String {
98    match scope {
99        "local" | "project" => {
100            project_path.filter(|path| !path.is_empty()).unwrap_or(home).to_owned()
101        }
102        _ => home.to_owned(),
103    }
104}
105
106/// The disk scan, off the runtime.
107///
108/// The scan walks the plugin tree, so it runs on a blocking thread exactly
109/// as the read path's own does - a dispatch reached from the connection
110/// must never stall a shared worker on it. The two failures stay distinct:
111/// a scan that could not run names the join, and a scan with no root to
112/// walk says so rather than reading as an empty machine.
113async fn scan_inventory(cli: &PluginCli) -> Result<PluginsInventorySnapshot, String> {
114    let scan = Arc::clone(&cli.scan);
115    tokio::task::spawn_blocking(move || scan())
116        .await
117        .map_err(|error| format!("the plugin disk scan failed to join: {error}"))?
118        .ok_or_else(|| {
119            "no plugins root resolves (no CLAUDE_CONFIG_DIR or HOME); the inventory cannot be read"
120                .to_owned()
121        })
122}
123
124/// Report a failed action, or a refusal found before one could run, on the
125/// frame the page already listens for.
126fn fail_action(ws: &Arc<Workspace>, cwd: String, message: String, dispatch_id: Option<u64>) {
127    tracing::warn!(
128        event_name = "plugin_cli_action_failed",
129        cwd = %cwd,
130        error = %message,
131        "a plugin action failed",
132    );
133    let _ = ws.update_sender().send(SessionUpdate::PluginsCliActionFailed {
134        cwd_raw: cwd,
135        message,
136        dispatch_id,
137    });
138}
139
140/// Run one CLI action and report its outcome on the `Plugins` frames.
141async fn run_action(
142    ws: &Arc<Workspace>,
143    cli: &PluginCli,
144    cwd: String,
145    args: Vec<String>,
146    message: String,
147    dispatch_id: Option<u64>,
148) {
149    match (cli.run_and_refresh)(cwd.clone(), args).await {
150        Ok((snapshot, claude_path)) => {
151            let _ = ws.update_sender().send(SessionUpdate::PluginsCliActionSucceeded {
152                cwd_raw: cwd,
153                result: PluginsCliActionSuccess { snapshot, message, claude_path },
154                dispatch_id,
155            });
156        }
157        Err(message) => fail_action(ws, cwd, message, dispatch_id),
158    }
159}
160
161/// The run rows one update-all/check queues: exactly the stale installed
162/// entries, one per registry entry, each with the cwd its own scope runs
163/// in.
164fn stale_rows(
165    installed: &[InstalledPluginEntry],
166    marketplace: &[MarketplaceEntry],
167    home: &str,
168) -> Vec<PluginUpdateRunRow> {
169    update_availability(installed, marketplace)
170        .into_iter()
171        .map(|stale| {
172            let project_path = installed
173                .iter()
174                .find(|entry| entry.id == stale.plugin_id && entry.scope == stale.scope)
175                .and_then(|entry| entry.project_path.as_deref());
176            let mut row = PluginUpdateRunRow::queued(
177                stale.plugin_id,
178                stale.scope.clone(),
179                plugin_call_cwd(&stale.scope, project_path, home),
180                stale.installed_version,
181            );
182            row.available_version = stale.available_version;
183            row
184        })
185        .collect()
186}
187
188impl Workspace {
189    /// The last plugin update run or check, in flight or finished, for the
190    /// Extensions read. `None` until one has happened this process.
191    pub fn plugin_update_run(&self) -> Option<PluginUpdateRun> {
192        self.plugin_run.lock().clone()
193    }
194
195    /// The disk-only plugin inventory, scanned on the call: registry,
196    /// marketplace manifests and the plugin cache, no `claude` spawn.
197    /// `None` when no plugins root resolves.
198    pub async fn plugins_inventory(&self) -> Option<PluginsInventorySnapshot> {
199        let cli = self.plugin_cli();
200        tokio::task::spawn_blocking(move || (cli.scan)()).await.ok().flatten()
201    }
202
203    #[cfg(test)]
204    pub(crate) fn set_plugin_cli(&self, cli: PluginCli) {
205        *self.plugin_cli.lock() = cli;
206    }
207
208    /// Replace the disk scan, so a cross-crate test can answer the
209    /// Extensions read with a fixture instead of this machine's plugins
210    /// root. Test-only. Takes a closure rather than the seam, so the
211    /// crate's own types stay private.
212    #[cfg(any(test, feature = "testing"))]
213    pub fn set_plugin_scan_for_test(
214        &self,
215        scan: impl Fn() -> Option<PluginsInventorySnapshot> + Send + Sync + 'static,
216    ) {
217        self.plugin_cli.lock().scan = Arc::new(scan);
218    }
219
220    fn plugin_cli(&self) -> PluginCli {
221        self.plugin_cli.lock().clone()
222    }
223
224    /// Take the single-action gate, or refuse naming the one in flight.
225    fn begin_plugin_action(&self) -> Result<(), DispatchError> {
226        if self.plugin_action_running.swap(true, Ordering::AcqRel) {
227            return Err(DispatchError::PluginActionRunning);
228        }
229        Ok(())
230    }
231
232    /// The home directory plugin calls with no project context run in.
233    /// Unavailable means the action fails rather than substituting another
234    /// directory (hard rule 14).
235    fn plugin_home() -> Result<String, DispatchError> {
236        dirs::home_dir()
237            .map(|home| home.to_string_lossy().into_owned())
238            .ok_or(DispatchError::PluginHomeUnavailable)
239    }
240
241    /// The declared project's path, by its `forge.toml` name.
242    fn declared_project_path(&self, name: &str) -> Result<String, DispatchError> {
243        self.list_projects()
244            .into_iter()
245            .find(|project| project.name == name)
246            .map(|project| project.path.to_string_lossy().into_owned())
247            .ok_or_else(|| DispatchError::ProjectNotFound { name: name.to_owned() })
248    }
249
250    /// Install one plugin at a scope.
251    pub(crate) fn start_plugin_install(
252        self: &Arc<Self>,
253        plugin_id: String,
254        scope: String,
255        project: Option<&str>,
256    ) -> Result<(), DispatchError> {
257        let home = Self::plugin_home()?;
258        let cwd = match scope.as_str() {
259            "local" | "project" => match project {
260                Some(name) => self.declared_project_path(name)?,
261                None => {
262                    return Err(DispatchError::PluginProjectRequired);
263                }
264            },
265            _ => home,
266        };
267        let message = match scope.as_str() {
268            "project" | "local" => format!("Installed {plugin_id} for {scope} scope"),
269            _ => format!("Installed {plugin_id} for user scope"),
270        };
271        let args =
272            vec!["plugin".to_owned(), "install".to_owned(), plugin_id, "--scope".to_owned(), scope];
273        // An install names no dispatch: only the add's press holds a draft
274        // that its answer disposes.
275        self.start_plugin_cli_action(cwd, args, message, None)
276    }
277
278    /// Update one installed plugin, in the scope and project the registry
279    /// records for it.
280    ///
281    /// The registry lookup is a disk scan, so it runs inside the spawned
282    /// task: the dispatch is fire-and-forget and its outcome rides the
283    /// `Plugins` frames, a refusal included - a plugin the registry does
284    /// not hold in that scope reports as a failed action naming it.
285    pub(crate) fn start_plugin_update(
286        self: &Arc<Self>,
287        plugin_id: String,
288        scope: String,
289    ) -> Result<(), DispatchError> {
290        let home = Self::plugin_home()?;
291        self.begin_plugin_action()?;
292        let ws = Arc::clone(self);
293        tokio::spawn(async move {
294            let _guard = PluginActionGuard(Arc::clone(&ws));
295            let cli = ws.plugin_cli();
296            let snapshot = match scan_inventory(&cli).await {
297                Ok(snapshot) => snapshot,
298                Err(message) => return fail_action(&ws, home, message, None),
299            };
300            let Some(entry) = snapshot
301                .installed
302                .iter()
303                .find(|entry| entry.id == plugin_id && entry.scope == scope)
304                .cloned()
305            else {
306                return fail_action(
307                    &ws,
308                    home,
309                    format!(
310                        "plugin '{plugin_id}' is not installed in {scope} scope; refresh the page"
311                    ),
312                    None,
313                );
314            };
315            let cwd = plugin_call_cwd(&scope, entry.project_path.as_deref(), &home);
316            let message = format!("Updated {plugin_id} in {scope} scope");
317            let args = vec![
318                "plugin".to_owned(),
319                "update".to_owned(),
320                plugin_id,
321                "--scope".to_owned(),
322                scope,
323            ];
324            run_action(&ws, &cli, cwd, args, message, None).await;
325        });
326        Ok(())
327    }
328
329    /// One generic CLI action: gate, spawn, report. The outcome rides
330    /// [`SessionUpdate::PluginsCliActionSucceeded`] / `Failed`.
331    fn start_plugin_cli_action(
332        self: &Arc<Self>,
333        cwd: String,
334        args: Vec<String>,
335        message: String,
336        dispatch_id: Option<u64>,
337    ) -> Result<(), DispatchError> {
338        self.begin_plugin_action()?;
339        let ws = Arc::clone(self);
340        tokio::spawn(async move {
341            let _guard = PluginActionGuard(Arc::clone(&ws));
342            let cli = ws.plugin_cli();
343            run_action(&ws, &cli, cwd, args, message, dispatch_id).await;
344        });
345        Ok(())
346    }
347
348    /// Update exactly the stale installed plugins, one CLI call per entry,
349    /// continuing past failures. The set is computed from a disk scan taken
350    /// inside the run's own task, so the dispatch never walks the tree on
351    /// the runtime and the run updates what the marketplace reports now.
352    ///
353    /// Nothing stale is not a refusal: the run lands finished with no rows,
354    /// exactly as a check with nothing to report does, and the page draws
355    /// its ordinary state.
356    pub(crate) fn start_plugin_update_all(self: &Arc<Self>) -> Result<(), DispatchError> {
357        let home = Self::plugin_home()?;
358        self.begin_plugin_action()?;
359        let ws = Arc::clone(self);
360        tokio::spawn(async move {
361            let _guard = PluginActionGuard(Arc::clone(&ws));
362            let cli = ws.plugin_cli();
363            let snapshot = match scan_inventory(&cli).await {
364                Ok(snapshot) => snapshot,
365                Err(message) => return fail_action(&ws, home, message, None),
366            };
367            let rows = stale_rows(&snapshot.installed, &snapshot.marketplace, &home);
368            if rows.is_empty() {
369                let run =
370                    PluginUpdateRun { trigger: PluginUpdateTrigger::Manual, finished: true, rows };
371                *ws.plugin_run.lock() = Some(run.clone());
372                let _ = ws.update_sender().send(SessionUpdate::PluginsUpdateRunFinished {
373                    cwd_raw: home,
374                    run,
375                    snapshot: Some(snapshot),
376                    claude_path: None,
377                });
378                return;
379            }
380            let run =
381                PluginUpdateRun { trigger: PluginUpdateTrigger::Manual, finished: false, rows };
382            *ws.plugin_run.lock() = Some(run.clone());
383            let marketplaces = snapshot.marketplaces;
384            run_update_batch(ws, cli, home, marketplaces, run).await;
385        });
386        Ok(())
387    }
388
389    /// Refresh the inventory through the CLI and report which installed
390    /// plugins have a newer marketplace version, applying nothing.
391    pub(crate) fn start_plugin_check(self: &Arc<Self>) -> Result<(), DispatchError> {
392        let home = Self::plugin_home()?;
393        self.begin_plugin_action()?;
394        let ws = Arc::clone(self);
395        tokio::spawn(async move {
396            let _guard = PluginActionGuard(Arc::clone(&ws));
397            let cli = ws.plugin_cli();
398            match (cli.refresh)(home.clone()).await {
399                Ok((snapshot, claude_path)) => {
400                    let rows = update_availability(&snapshot.installed, &snapshot.marketplace)
401                        .into_iter()
402                        .map(|stale| PluginUpdateRunRow {
403                            plugin_id: stale.plugin_id,
404                            scope: stale.scope,
405                            cwd_raw: String::new(),
406                            marketplace: stale.marketplace,
407                            status: PluginRunRowStatus::UpdateAvailable,
408                            installed_version: stale.installed_version,
409                            available_version: stale.available_version,
410                            detail: None,
411                        })
412                        .collect();
413                    let run = PluginUpdateRun {
414                        trigger: PluginUpdateTrigger::Manual,
415                        finished: true,
416                        rows,
417                    };
418                    *ws.plugin_run.lock() = Some(run.clone());
419                    let _ = ws.update_sender().send(SessionUpdate::PluginsUpdateRunFinished {
420                        cwd_raw: home,
421                        run,
422                        snapshot: Some(snapshot),
423                        claude_path: Some(claude_path),
424                    });
425                }
426                Err(message) => {
427                    tracing::warn!(
428                        event_name = "plugin_update_check_failed",
429                        error = %message,
430                        "the plugin update check could not refresh the inventory",
431                    );
432                    let _ = ws.update_sender().send(SessionUpdate::PluginsInventoryRefreshFailed {
433                        cwd_raw: home,
434                        message,
435                        trigger: PluginUpdateTrigger::Manual,
436                    });
437                }
438            }
439        });
440        Ok(())
441    }
442
443    /// Add a marketplace by `owner/repo` or URL, at user scope.
444    pub(crate) fn start_marketplace_add(
445        self: &Arc<Self>,
446        source: String,
447        dispatch_id: Option<u64>,
448    ) -> Result<(), DispatchError> {
449        let home = Self::plugin_home()?;
450        let message = format!("Added marketplace {source}");
451        let args = vec![
452            "plugin".to_owned(),
453            "marketplace".to_owned(),
454            "add".to_owned(),
455            source,
456            "--scope".to_owned(),
457            "user".to_owned(),
458        ];
459        self.start_plugin_cli_action(home, args, message, dispatch_id)
460    }
461
462    /// Repair a drifted marketplace with the CLI's remove-and-re-add pair.
463    /// The re-add source is the repo for a git marketplace and the clone
464    /// location for a directory one.
465    /// The registry lookup is a disk scan, so it runs inside the task.
466    pub(crate) fn start_marketplace_repair(
467        self: &Arc<Self>,
468        name: String,
469    ) -> Result<(), DispatchError> {
470        let home = Self::plugin_home()?;
471        self.begin_plugin_action()?;
472        let ws = Arc::clone(self);
473        tokio::spawn(async move {
474            let _guard = PluginActionGuard(Arc::clone(&ws));
475            let cli = ws.plugin_cli();
476            let snapshot = match scan_inventory(&cli).await {
477                Ok(snapshot) => snapshot,
478                Err(message) => return fail_action(&ws, home, message, None),
479            };
480            let Some(marketplace) =
481                snapshot.marketplaces.iter().find(|marketplace| marketplace.name == name).cloned()
482            else {
483                return fail_action(
484                    &ws,
485                    home,
486                    format!("no marketplace named '{name}' is configured; refresh the page"),
487                    None,
488                );
489            };
490            let readd_source = match marketplace.source.as_deref() {
491                Some("directory") => marketplace
492                    .install_location
493                    .clone()
494                    .or_else(|| marketplace.repo.clone())
495                    .unwrap_or_else(|| name.clone()),
496                _ => marketplace.repo.clone().unwrap_or_else(|| name.clone()),
497            };
498            let remove = vec![
499                "plugin".to_owned(),
500                "marketplace".to_owned(),
501                "remove".to_owned(),
502                name.clone(),
503            ];
504            let add = vec![
505                "plugin".to_owned(),
506                "marketplace".to_owned(),
507                "add".to_owned(),
508                readd_source,
509                "--scope".to_owned(),
510                "user".to_owned(),
511            ];
512            let outcome = match (cli.run_and_refresh)(home.clone(), remove).await {
513                Ok(_) => (cli.run_and_refresh)(home.clone(), add).await,
514                Err(message) => Err(message),
515            };
516            match outcome {
517                Ok((snapshot, claude_path)) => {
518                    let _ = ws.update_sender().send(SessionUpdate::PluginsCliActionSucceeded {
519                        cwd_raw: home,
520                        result: PluginsCliActionSuccess {
521                            snapshot,
522                            message: format!("Repaired {name} marketplace"),
523                            claude_path,
524                        },
525                        dispatch_id: None,
526                    });
527                }
528                Err(message) => fail_action(&ws, home, message, None),
529            }
530        });
531        Ok(())
532    }
533}
534
535/// Run one update batch, reporting progress per row, persisting the
536/// rollback records it earns, and finishing with the refreshed inventory.
537async fn run_update_batch(
538    ws: Arc<Workspace>,
539    cli: PluginCli,
540    home: String,
541    marketplaces: Vec<MarketplaceSourceEntry>,
542    run: PluginUpdateRun,
543) {
544    // The clone HEADs before the run, so an applied update records the ref
545    // a rollback later restores. Captured once, from the dispatch's own
546    // scan, before any plugin moves.
547    let mut marketplace_refs = std::collections::HashMap::new();
548    for marketplace in marketplaces {
549        if let Some(location) = marketplace.install_location
550            && let Some(head) = cli::marketplace_head(location).await
551        {
552            marketplace_refs.insert(marketplace.name, head);
553        }
554    }
555    let plan = cli::BatchPlan {
556        cwd_context: home.clone(),
557        claude_path: None,
558        call_timeout: PLUGIN_CALL_TIMEOUT,
559        marketplace_refs,
560        run,
561    };
562    let progress_ws = Arc::clone(&ws);
563    let progress_home = home.clone();
564    let out = cli::execute_update_batch(plan, cli.runner, move |run| {
565        *progress_ws.plugin_run.lock() = Some(run.clone());
566        let _ = progress_ws
567            .update_sender()
568            .send(SessionUpdate::PluginsUpdateRunProgress { cwd_raw: progress_home.clone(), run });
569    })
570    .await;
571    if !out.records.is_empty() {
572        ws.record_plugin_updates(&out.records);
573    }
574    *ws.plugin_run.lock() = Some(out.run.clone());
575    let _ = ws.update_sender().send(SessionUpdate::PluginsUpdateRunFinished {
576        cwd_raw: home,
577        run: out.run,
578        snapshot: out.snapshot,
579        claude_path: out.claude_path,
580    });
581}
582
583#[cfg(test)]
584mod tests {
585    use std::sync::{Arc, Mutex};
586
587    use forge_primitives::plugins::{
588        InstalledPluginEntry, MarketplaceSourceEntry, PluginCapability, PluginsInventorySnapshot,
589    };
590
591    use super::*;
592    use crate::SeatUpdate;
593    use crate::protocol::Command;
594    use crate::workspace::Workspace;
595
596    /// One recorded call into the fake CLI.
597    #[derive(Debug, Clone, PartialEq)]
598    struct CliCall {
599        cwd: String,
600        args: Vec<String>,
601    }
602
603    /// What the fake CLI records, shared with the test.
604    #[derive(Default)]
605    struct FakeCli {
606        run: Mutex<Vec<CliCall>>,
607        refresh: Mutex<Vec<CliCall>>,
608        updates: Mutex<Vec<CliCall>>,
609    }
610
611    fn marketplace(
612        name: &str,
613        source: &str,
614        repo: Option<&str>,
615        location: Option<&str>,
616    ) -> MarketplaceSourceEntry {
617        MarketplaceSourceEntry {
618            name: name.to_owned(),
619            source: Some(source.to_owned()),
620            repo: repo.map(str::to_owned),
621            install_location: location.map(str::to_owned),
622        }
623    }
624
625    fn installed(
626        id: &str,
627        version: &str,
628        scope: &str,
629        project_path: Option<&str>,
630    ) -> InstalledPluginEntry {
631        InstalledPluginEntry {
632            id: id.to_owned(),
633            version: Some(version.to_owned()),
634            scope: scope.to_owned(),
635            enabled: true,
636            installed_at: None,
637            last_updated: None,
638            project_path: project_path.map(str::to_owned),
639            capability: PluginCapability::Skill,
640        }
641    }
642
643    fn marketplace_entry(id: &str, version: &str) -> MarketplaceEntry {
644        MarketplaceEntry {
645            plugin_id: id.to_owned(),
646            name: id.split('@').next().unwrap_or(id).to_owned(),
647            description: None,
648            marketplace_name: None,
649            version: Some(version.to_owned()),
650            install_count: None,
651            source: None,
652        }
653    }
654
655    /// A fake CLI whose scan answers `scan`, whose every action and
656    /// refresh answers `refreshed`, and which records every call.
657    fn fake_cli(
658        scan: &PluginsInventorySnapshot,
659        refreshed: &PluginsInventorySnapshot,
660    ) -> (PluginCli, Arc<FakeCli>) {
661        let calls = Arc::new(FakeCli::default());
662        let run = {
663            let calls = Arc::clone(&calls);
664            let refreshed = refreshed.clone();
665            Arc::new(move |cwd: String, args: Vec<String>| {
666                calls.run.lock().expect("lock").push(CliCall { cwd, args });
667                let refreshed = refreshed.clone();
668                Box::pin(async move { Ok((refreshed, PathBuf::from("/fake/claude"))) })
669                    as PluginCliFut<Result<(PluginsInventorySnapshot, PathBuf), String>>
670            })
671        };
672        let refresh = {
673            let calls = Arc::clone(&calls);
674            let refreshed = refreshed.clone();
675            Arc::new(move |cwd: String| {
676                calls.refresh.lock().expect("lock").push(CliCall { cwd, args: vec![] });
677                let refreshed = refreshed.clone();
678                Box::pin(async move { Ok((refreshed, PathBuf::from("/fake/claude"))) })
679                    as PluginCliFut<Result<(PluginsInventorySnapshot, PathBuf), String>>
680            })
681        };
682        let updates = {
683            let calls = Arc::clone(&calls);
684            Arc::new(move |_cached: Option<PathBuf>, cwd: String, args: Vec<String>| {
685                calls.updates.lock().expect("lock").push(CliCall { cwd, args });
686                Box::pin(async move {
687                    Ok((PathBuf::from("/fake/claude"), "the CLI said so".to_owned()))
688                })
689                    as forge_agent::userdata::plugins::cli::RunnerFut<
690                        Result<(PathBuf, String), String>,
691                    >
692            })
693        };
694        let batch_refresh = {
695            let refreshed = refreshed.clone();
696            std::sync::Arc::new(move |_cached: Option<PathBuf>, _cwd: String| {
697                let refreshed = refreshed.clone();
698                Box::pin(async move { Ok((refreshed, PathBuf::from("/fake/claude"))) })
699                    as forge_agent::userdata::plugins::cli::RunnerFut<
700                        Result<(PluginsInventorySnapshot, PathBuf), String>,
701                    >
702            })
703        };
704        let scan_snapshot = scan.clone();
705        let cli = PluginCli {
706            run_and_refresh: run,
707            refresh,
708            runner: UpdateRunner { run_update: updates, refresh: batch_refresh },
709            scan: Arc::new(move || Some(scan_snapshot.clone())),
710        };
711        (cli, calls)
712    }
713
714    /// Wait for the update a dispatched action emits.
715    async fn next_update(
716        rx: &mut tokio::sync::mpsc::UnboundedReceiver<SeatUpdate>,
717    ) -> SessionUpdate {
718        std::sync::Arc::unwrap_or_clone(
719            tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
720                .await
721                .expect("an update lands")
722                .expect("the stream is open")
723                .update,
724        )
725    }
726
727    /// The home directory every user-scope call runs in.
728    fn home() -> String {
729        dirs::home_dir().expect("home").to_string_lossy().into_owned()
730    }
731
732    /// **The working directory comes from the declaration, never the
733    /// launch directory** (hard rule 14): a project-scoped install runs in
734    /// the declared project's own path, and a name `forge.toml` does not
735    /// declare is refused rather than substituted.
736    #[tokio::test]
737    async fn a_project_install_runs_in_the_declared_project() {
738        let dir = tempfile::tempdir().expect("tempdir");
739        let forge = dir.path().join("forge");
740        std::fs::create_dir_all(&forge).expect("forge/");
741        std::fs::write(
742            forge.join("forge.toml"),
743            "\
744[[orgs]]
745name = \"Busytools\"
746accounts = [\"acct-a\"]
747
748[[orgs.projects]]
749name = \"proj\"
750path = \"/tmp/plugin-cwd-probe\"
751model = \"claude-sonnet-5\"
752
753[[accounts]]
754display_name = \"acct-a\"
755token = \"t\"
756models = [\"claude-sonnet-5\"]
757provider = \"anthropic\"
758",
759        )
760        .expect("write forge.toml");
761        let ws = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("workspace"));
762        let (cli, calls) =
763            fake_cli(&PluginsInventorySnapshot::default(), &PluginsInventorySnapshot::default());
764        ws.set_plugin_cli(cli);
765        let declared = ws.list_projects()[0].path.to_string_lossy().into_owned();
766        let mut rx = ws.subscribe_answerer();
767
768        ws.dispatch(Command::PluginInstall {
769            plugin_id: "demo@probe".to_owned(),
770            scope: "project".to_owned(),
771            project: Some("proj".to_owned()),
772        })
773        .expect("the install dispatches");
774        let _ = next_update(&mut rx).await;
775
776        let call = calls.run.lock().expect("lock")[0].clone();
777        assert_eq!(call.cwd, declared, "the install runs in the declared project's path");
778        assert_eq!(
779            call.args,
780            vec!["plugin", "install", "demo@probe", "--scope", "project"],
781            "the CLI call carries the scope"
782        );
783
784        let refusal = ws
785            .dispatch(Command::PluginInstall {
786                plugin_id: "demo@probe".to_owned(),
787                scope: "local".to_owned(),
788                project: Some("nowhere".to_owned()),
789            })
790            .expect_err("an undeclared project is refused");
791        assert!(
792            matches!(&refusal, DispatchError::ProjectNotFound { name } if name == "nowhere"),
793            "the refusal names the project, got: {refusal:?}"
794        );
795        assert_eq!(calls.run.lock().expect("lock").len(), 1, "and nothing ran for it");
796
797        let refusal = ws
798            .dispatch(Command::PluginInstall {
799                plugin_id: "demo@probe".to_owned(),
800                scope: "local".to_owned(),
801                project: None,
802            })
803            .expect_err("a project-scoped install with no project is refused");
804        assert!(
805            matches!(refusal, DispatchError::PluginProjectRequired),
806            "the refusal says the project is required, got: {refusal:?}"
807        );
808    }
809
810    /// User scope runs in the home directory: the one fixed place a plugin
811    /// call with no project context can run.
812    #[tokio::test]
813    async fn a_user_install_runs_in_the_home_directory() {
814        let (ws, mut rx) = Workspace::testing_stub();
815        let (cli, calls) =
816            fake_cli(&PluginsInventorySnapshot::default(), &PluginsInventorySnapshot::default());
817        ws.set_plugin_cli(cli);
818
819        ws.dispatch(Command::PluginInstall {
820            plugin_id: "demo@probe".to_owned(),
821            scope: "user".to_owned(),
822            project: None,
823        })
824        .expect("the install dispatches");
825        let _ = next_update(&mut rx).await;
826
827        assert_eq!(
828            calls.run.lock().expect("lock")[0].cwd,
829            home(),
830            "user scope runs in the home directory, not the launch directory"
831        );
832    }
833
834    /// Update-all queues **exactly the stale set**, one row per stale
835    /// entry, each in the cwd its own scope runs in; the batch records
836    /// what a rollback restores, and the finished frame carries the
837    /// refreshed inventory.
838    #[tokio::test]
839    async fn update_all_queues_exactly_the_stale_set() {
840        let (ws, mut rx) = Workspace::testing_stub();
841        let scan = PluginsInventorySnapshot {
842            installed: vec![
843                installed("stale@probe", "1.0.0", "user", None),
844                installed("current@probe", "2.0.0", "user", None),
845                installed("local@probe", "1.0.0", "local", Some("/tmp/wc-issues")),
846            ],
847            marketplace: vec![
848                marketplace_entry("stale@probe", "1.1.0"),
849                marketplace_entry("current@probe", "2.0.0"),
850                marketplace_entry("local@probe", "1.2.0"),
851            ],
852            ..PluginsInventorySnapshot::default()
853        };
854        let refreshed = PluginsInventorySnapshot {
855            installed: vec![
856                installed("stale@probe", "1.1.0", "user", None),
857                installed("current@probe", "2.0.0", "user", None),
858                installed("local@probe", "1.2.0", "local", Some("/tmp/wc-issues")),
859            ],
860            ..PluginsInventorySnapshot::default()
861        };
862        let (cli, calls) = fake_cli(&scan, &refreshed);
863        ws.set_plugin_cli(cli);
864        // The rollback records land in the durable store, so the run needs
865        // one open to persist into.
866        let dir = tempfile::tempdir().expect("tempdir");
867        ws.install_db_for_test(
868            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
869        );
870
871        ws.dispatch(Command::PluginUpdateAll).expect("the run dispatches");
872
873        let finished = loop {
874            match next_update(&mut rx).await {
875                SessionUpdate::PluginsUpdateRunFinished { run, .. } => break run,
876                SessionUpdate::PluginsUpdateRunProgress { .. } => {}
877                other => panic!("unexpected update: {other:?}"),
878            }
879        };
880        assert!(finished.finished, "the run is over");
881        let ids: Vec<&str> = finished.rows.iter().map(|row| row.plugin_id.as_str()).collect();
882        assert_eq!(ids, vec!["stale@probe", "local@probe"], "only the stale entries are queued");
883        assert_eq!(
884            finished.rows[0].cwd_raw,
885            home(),
886            "a user-scoped row runs in the home directory"
887        );
888        assert_eq!(
889            finished.rows[1].cwd_raw, "/tmp/wc-issues",
890            "a local-scoped row runs in its own project"
891        );
892        assert_eq!(
893            finished.rows[0].status,
894            PluginRunRowStatus::Updated,
895            "a version that moved reads updated"
896        );
897
898        let updated: Vec<Vec<String>> =
899            calls.updates.lock().expect("lock").iter().map(|call| call.args.clone()).collect();
900        assert_eq!(
901            updated,
902            vec![
903                vec!["plugin", "update", "stale@probe", "--scope", "user"],
904                vec!["plugin", "update", "local@probe", "--scope", "local"],
905            ],
906            "one update call per stale row, in plan order"
907        );
908        let records = ws.plugin_update_records();
909        assert_eq!(records.len(), 2, "each applied update recorded what a rollback restores");
910        let stale = records
911            .iter()
912            .find(|record| record.plugin_id == "stale@probe")
913            .expect("the applied update recorded its own row");
914        assert_eq!(stale.from_version.as_deref(), Some("1.0.0"));
915        assert_eq!(stale.to_version.as_deref(), Some("1.1.0"));
916    }
917
918    /// Nothing stale is not a refusal: the run lands finished with no rows
919    /// (the check's own shape when it finds nothing), no CLI call goes out,
920    /// and the gate opens again.
921    #[tokio::test]
922    async fn nothing_stale_lands_as_a_finished_empty_run() {
923        let (ws, mut rx) = Workspace::testing_stub();
924        let scan = PluginsInventorySnapshot {
925            installed: vec![installed("current@probe", "2.0.0", "user", None)],
926            marketplace: vec![marketplace_entry("current@probe", "2.0.0")],
927            ..PluginsInventorySnapshot::default()
928        };
929        let (cli, calls) = fake_cli(&scan, &PluginsInventorySnapshot::default());
930        ws.set_plugin_cli(cli);
931
932        ws.dispatch(Command::PluginUpdateAll).expect("the run dispatches");
933
934        let SessionUpdate::PluginsUpdateRunFinished { run, .. } = next_update(&mut rx).await else {
935            panic!("the run reports its finish");
936        };
937        assert!(run.finished, "an empty run is a finished one");
938        assert!(run.rows.is_empty(), "nothing stale is nothing to do");
939        assert!(calls.updates.lock().expect("lock").is_empty(), "and no update call went out");
940        assert!(
941            !ws.plugin_action_running.load(Ordering::Acquire),
942            "the gate opened when the run ended"
943        );
944    }
945
946    /// A scan whose thread cannot run reports the join by name, distinct
947    /// from a missing root: the two failure modes collapse silently under a
948    /// `unwrap_or_default()` or a dropped `?`, and the page would then say
949    /// "no plugins root resolves" about a machine that has one.
950    #[tokio::test]
951    async fn a_scan_that_cannot_run_reports_the_join() {
952        let (ws, mut rx) = Workspace::testing_stub();
953        let (cli, _calls) =
954            fake_cli(&PluginsInventorySnapshot::default(), &PluginsInventorySnapshot::default());
955        let panicking = PluginCli { scan: Arc::new(|| panic!("the scan thread died")), ..cli };
956        ws.set_plugin_cli(panicking);
957
958        ws.dispatch(Command::PluginUpdateAll).expect("the dispatch is accepted");
959
960        let SessionUpdate::PluginsCliActionFailed { message, .. } = next_update(&mut rx).await
961        else {
962            panic!("the failure reports as a frame");
963        };
964        assert!(message.contains("failed to join"), "the join is named, got: {message}");
965        assert!(
966            !message.contains("no plugins root resolves"),
967            "and not as a missing root, got: {message}"
968        );
969    }
970
971    /// A scan with no root to walk reports its own words rather than reading
972    /// as an empty machine, and it does so as a frame: the dispatch is
973    /// fire-and-forget, so a refusal found inside the task lands the same
974    /// way a failed action does.
975    #[tokio::test]
976    async fn a_missing_plugins_root_reports_as_a_failed_action() {
977        let (ws, mut rx) = Workspace::testing_stub();
978        let (cli, _calls) =
979            fake_cli(&PluginsInventorySnapshot::default(), &PluginsInventorySnapshot::default());
980        let no_root = PluginCli { scan: Arc::new(|| None), ..cli };
981        ws.set_plugin_cli(no_root);
982
983        ws.dispatch(Command::PluginUpdateAll).expect("the dispatch is accepted");
984
985        let SessionUpdate::PluginsCliActionFailed { message, .. } = next_update(&mut rx).await
986        else {
987            panic!("the failure reports as a frame");
988        };
989        assert!(
990            message.contains("no plugins root resolves"),
991            "the message names the missing root, got: {message}"
992        );
993        assert!(
994            !ws.plugin_action_running.load(Ordering::Acquire),
995            "the gate opened when the task ended"
996        );
997    }
998
999    /// A second action waits: one plugin CLI call at a time, refused by
1000    /// name while the first runs.
1001    #[test]
1002    fn a_second_action_is_refused_while_one_runs() {
1003        let (ws, _rx) = Workspace::testing_stub();
1004        ws.plugin_action_running.store(true, Ordering::Release);
1005        let refusal = ws
1006            .dispatch(Command::PluginCheckUpdates)
1007            .expect_err("a running action refuses a second");
1008        assert!(
1009            matches!(refusal, DispatchError::PluginActionRunning),
1010            "the refusal names what is running, got: {refusal:?}"
1011        );
1012    }
1013
1014    /// An update names a plugin the registry does not hold in that scope:
1015    /// the view's snapshot is stale, and the failure names it rather than
1016    /// running a call that would fail at the CLI.
1017    #[tokio::test]
1018    async fn an_update_of_an_uninstalled_plugin_fails_naming_it() {
1019        let (ws, mut rx) = Workspace::testing_stub();
1020        let (cli, calls) =
1021            fake_cli(&PluginsInventorySnapshot::default(), &PluginsInventorySnapshot::default());
1022        ws.set_plugin_cli(cli);
1023
1024        ws.dispatch(Command::PluginUpdate {
1025            plugin_id: "ghost@probe".to_owned(),
1026            scope: "user".to_owned(),
1027        })
1028        .expect("the dispatch is accepted");
1029
1030        let SessionUpdate::PluginsCliActionFailed { message, .. } = next_update(&mut rx).await
1031        else {
1032            panic!("the failure reports as a frame");
1033        };
1034        assert!(
1035            message.contains("ghost@probe") && message.contains("user"),
1036            "the failure names the plugin and the scope, got: {message}"
1037        );
1038        assert!(calls.run.lock().expect("lock").is_empty(), "and no CLI call went out");
1039    }
1040
1041    /// The outcome of a landed action is the refreshed inventory plus the
1042    /// message the view draws - both on the frame the page listens for -
1043    /// and the gate opens again for the next press.
1044    #[tokio::test]
1045    async fn a_landed_install_reports_the_refreshed_inventory() {
1046        let (ws, mut rx) = Workspace::testing_stub();
1047        let refreshed = PluginsInventorySnapshot {
1048            installed: vec![installed("demo@probe", "1.0.0", "user", None)],
1049            ..PluginsInventorySnapshot::default()
1050        };
1051        let (cli, _calls) = fake_cli(&PluginsInventorySnapshot::default(), &refreshed);
1052        ws.set_plugin_cli(cli);
1053
1054        ws.dispatch(Command::PluginInstall {
1055            plugin_id: "demo@probe".to_owned(),
1056            scope: "user".to_owned(),
1057            project: None,
1058        })
1059        .expect("the install dispatches");
1060
1061        let SessionUpdate::PluginsCliActionSucceeded { result, .. } = next_update(&mut rx).await
1062        else {
1063            panic!("the action reports its outcome");
1064        };
1065        assert_eq!(result.message, "Installed demo@probe for user scope");
1066        assert_eq!(result.snapshot.installed.len(), 1, "the frame carries the refreshed inventory");
1067        assert!(
1068            !ws.plugin_action_running.load(Ordering::Acquire),
1069            "the gate opened when the action ended"
1070        );
1071    }
1072
1073    /// The add's outcome names the dispatch its press made - the identity a
1074    /// page holding a draft until its press answers keys on, so a frame that
1075    /// answers anything else never disposes of it.
1076    #[tokio::test]
1077    async fn an_adds_outcome_names_the_dispatch_it_answers() {
1078        let (ws, mut rx) = Workspace::testing_stub();
1079        let (cli, _calls) =
1080            fake_cli(&PluginsInventorySnapshot::default(), &PluginsInventorySnapshot::default());
1081        ws.set_plugin_cli(cli);
1082
1083        ws.dispatch(Command::MarketplaceAdd {
1084            source: "owner/repo".to_owned(),
1085            dispatch_id: Some(41),
1086        })
1087        .expect("the add dispatches");
1088
1089        let SessionUpdate::PluginsCliActionSucceeded { dispatch_id, .. } =
1090            next_update(&mut rx).await
1091        else {
1092            panic!("the add reports its outcome");
1093        };
1094        assert_eq!(dispatch_id, Some(41), "the frame names the dispatch the press made");
1095    }
1096
1097    /// A repair is the CLI's remove-and-re-add pair, and the re-add source
1098    /// is the repo for a git marketplace and the clone location for a
1099    /// directory one.
1100    #[tokio::test]
1101    async fn a_repair_is_the_remove_then_readd_pair() {
1102        let (ws, mut rx) = Workspace::testing_stub();
1103        let scan = PluginsInventorySnapshot {
1104            marketplaces: vec![
1105                marketplace("git-mp", "github", Some("owner/repo"), Some("/tmp/clone")),
1106                marketplace("dir-mp", "directory", None, Some("/tmp/dir-clone")),
1107            ],
1108            ..PluginsInventorySnapshot::default()
1109        };
1110        let (cli, calls) = fake_cli(&scan, &PluginsInventorySnapshot::default());
1111        ws.set_plugin_cli(cli);
1112
1113        ws.dispatch(Command::MarketplaceRepair { name: "git-mp".to_owned() })
1114            .expect("the repair dispatches");
1115        let _ = next_update(&mut rx).await;
1116        let pair: Vec<Vec<String>> =
1117            calls.run.lock().expect("lock").iter().map(|call| call.args.clone()).collect();
1118        assert_eq!(
1119            pair,
1120            vec![
1121                vec!["plugin", "marketplace", "remove", "git-mp"],
1122                vec!["plugin", "marketplace", "add", "owner/repo", "--scope", "user"],
1123            ],
1124            "a git marketplace re-adds from its repo"
1125        );
1126        calls.run.lock().expect("lock").clear();
1127
1128        ws.dispatch(Command::MarketplaceRepair { name: "dir-mp".to_owned() })
1129            .expect("the repair dispatches");
1130        let _ = next_update(&mut rx).await;
1131        let pair: Vec<Vec<String>> =
1132            calls.run.lock().expect("lock").iter().map(|call| call.args.clone()).collect();
1133        assert_eq!(
1134            pair[1],
1135            vec!["plugin", "marketplace", "add", "/tmp/dir-clone", "--scope", "user"],
1136            "a directory marketplace re-adds from its clone location"
1137        );
1138
1139        ws.dispatch(Command::MarketplaceRepair { name: "absent".to_owned() })
1140            .expect("the dispatch is accepted");
1141        let SessionUpdate::PluginsCliActionFailed { message, .. } = next_update(&mut rx).await
1142        else {
1143            panic!("the unknown marketplace reports as a failure frame");
1144        };
1145        assert!(message.contains("absent"), "the failure names it, got: {message}");
1146    }
1147
1148    /// The check applies nothing: no update call goes out, and the run it
1149    /// reports is the stale set as check-only rows.
1150    #[tokio::test]
1151    async fn the_check_applies_nothing() {
1152        let (ws, mut rx) = Workspace::testing_stub();
1153        let refreshed = PluginsInventorySnapshot {
1154            installed: vec![installed("stale@probe", "1.0.0", "user", None)],
1155            marketplace: vec![marketplace_entry("stale@probe", "1.1.0")],
1156            ..PluginsInventorySnapshot::default()
1157        };
1158        let (cli, calls) = fake_cli(&PluginsInventorySnapshot::default(), &refreshed);
1159        ws.set_plugin_cli(cli);
1160
1161        ws.dispatch(Command::PluginCheckUpdates).expect("the check dispatches");
1162
1163        let SessionUpdate::PluginsUpdateRunFinished { run, snapshot, .. } =
1164            next_update(&mut rx).await
1165        else {
1166            panic!("the check reports a finished run");
1167        };
1168        assert!(run.finished);
1169        assert_eq!(run.rows.len(), 1, "one check-only row for the stale entry");
1170        assert_eq!(
1171            run.rows[0].status,
1172            PluginRunRowStatus::UpdateAvailable,
1173            "a check reports availability and applies nothing"
1174        );
1175        assert!(snapshot.is_some(), "the check's refreshed inventory rides the frame");
1176        assert!(calls.updates.lock().expect("lock").is_empty(), "no update call went out");
1177        assert_eq!(
1178            calls.refresh.lock().expect("lock").len(),
1179            1,
1180            "the refresh is what the check runs"
1181        );
1182    }
1183}