Skip to main content

tapes_harnesses/plugin/codex_app/
manager.rs

1//! Codex's own plugin manager: the source layout it consumes and the `codex`
2//! subcommands that register and install from it.
3//!
4//! [`super`] renders the two manifests a hook plugin *is*. Neither is
5//! installable on its own: Codex installs a plugin from a **marketplace**, a
6//! directory whose `.agents/plugins/marketplace.json` offers one or more
7//! plugins as local sources, and it installs *by running its own CLI*. So a
8//! consumer that only had the templates could write a tree and then had to
9//! tell the user to finish the job by hand.
10//!
11//! Everything needed to finish it lives here, and it is all Codex knowledge
12//! rather than any consumer's:
13//!
14//! * **The wrapper.** [`MARKETPLACE_MANIFEST_TEMPLATE`] and the paths under
15//!   [`plugin_source_dir`] are the layout `codex plugin marketplace add`
16//!   walks. A consumer supplies two names and gets the tree Codex reads.
17//! * **The invocation.** [`PluginManager::register`] runs the two commands in
18//!   order and interprets the answers.
19//! * **The quirks.** Codex reports "this is already done" as a *failure* with
20//!   a distinguishing phrase on stderr, and it refuses a same-named
21//!   marketplace pointing at a different directory. Recognising those answers
22//!   is the difference between an install that completes and one that reports
23//!   a spurious error, and it is the bulk of what this module knows.
24//!
25//! # Observed behaviour, and why it is guarded
26//!
27//! The collision and refresh semantics below were verified against
28//! codex-cli 0.146.0. They are matched on stderr *phrases* because the CLI
29//! offers nothing better — no machine-readable status, no distinct exit code.
30//! Every phrase check therefore only ever reinterprets a **failure**, and only
31//! against the specific phrasings observed, so a CLI whose wording moves
32//! degrades to an honest failure plus [`PluginManager::manual_commands`]
33//! rather than to a silent wrong answer.
34//!
35//! # What stays with the consumer
36//!
37//! Bytes on disk and words on a terminal. This module never writes a file,
38//! never reads one, and never prints: it takes a marketplace root that already
39//! exists and hands back outcomes. Whether a consumer extracts an embedded
40//! bundle or renders one, how it records what it has delivered, and how it
41//! narrates any of that are its own.
42
43use std::path::{Path, PathBuf};
44
45use super::{render_slots, shell_quote};
46
47/// Slot in [`MARKETPLACE_MANIFEST_TEMPLATE`] for the marketplace name — the
48/// name `codex plugin marketplace remove` takes and the right-hand side of a
49/// `<plugin>@<marketplace>` spec.
50pub const MARKETPLACE_NAME_SLOT: &str = "__TAPES_MARKETPLACE_NAME__";
51
52/// Slot for the marketplace's display name, shown when the app lists sources.
53pub const MARKETPLACE_DISPLAY_NAME_SLOT: &str = "__TAPES_MARKETPLACE_DISPLAY_NAME__";
54
55/// Slot for the offered plugin's name. The same spelling
56/// [`super::PLUGIN_MANIFEST_TEMPLATE`] uses, because it must hold the same
57/// value: Codex resolves the offer against the plugin manifest's `name`.
58pub const MARKETPLACE_PLUGIN_NAME_SLOT: &str = "__TAPES_PLUGIN_NAME__";
59
60/// Slot for the offered plugin's source path, relative to the marketplace
61/// root. Its own slot rather than text spliced around
62/// [`MARKETPLACE_PLUGIN_NAME_SLOT`] so substitution stays whole-value and
63/// JSON-escaped, exactly as every other slot in this crate is.
64pub const MARKETPLACE_PLUGIN_SOURCE_PATH_SLOT: &str = "__TAPES_PLUGIN_SOURCE_PATH__";
65
66/// The marketplace manifest template — [`MARKETPLACE_MANIFEST_PATH`] in the
67/// packaged tree.
68///
69/// One local-source plugin, installable on request and authenticated when it
70/// is installed. A marketplace may offer several plugins; this template offers
71/// exactly one, which is the shape a capture client needs and the only shape
72/// the path helpers here describe.
73pub const MARKETPLACE_MANIFEST_TEMPLATE: &str = include_str!(concat!(
74    env!("CARGO_MANIFEST_DIR"),
75    "/assets/codex-app/marketplace.json"
76));
77
78/// Where [`MARKETPLACE_MANIFEST_TEMPLATE`] is written, relative to the
79/// marketplace root a consumer hands `codex plugin marketplace add`.
80pub const MARKETPLACE_MANIFEST_PATH: &str = ".agents/plugins/marketplace.json";
81
82/// The two names a marketplace manifest carries, plus the display string the
83/// app shows for the source.
84///
85/// `#[non_exhaustive]` for the reason [`super::HookPluginIdentity`] is; build
86/// one with [`MarketplaceIdentity::new`].
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88#[non_exhaustive]
89pub struct MarketplaceIdentity<'a> {
90    /// The marketplace name. Codex keys a registered source on it, so two
91    /// consumers choosing the same name collide on one machine — see
92    /// [`MarketplaceOutcome::Replaced`].
93    pub name: &'a str,
94    /// The offered plugin's name, which must equal the `name` in the plugin
95    /// manifest rendered by [`super::render_plugin_manifest`].
96    pub plugin_name: &'a str,
97    /// Display name for the source in the app's marketplace list.
98    pub display_name: &'a str,
99}
100
101impl<'a> MarketplaceIdentity<'a> {
102    /// A marketplace offering exactly `plugin_name`, displayed under `name`
103    /// until [`Self::with_display_name`] says otherwise.
104    #[must_use]
105    pub const fn new(name: &'a str, plugin_name: &'a str) -> Self {
106        Self {
107            name,
108            plugin_name,
109            display_name: name,
110        }
111    }
112
113    /// Set the display name shown for the source.
114    #[must_use]
115    pub const fn with_display_name(mut self, display_name: &'a str) -> Self {
116        self.display_name = display_name;
117        self
118    }
119}
120
121/// The plugin's source directory, relative to the marketplace root.
122#[must_use]
123pub fn plugin_source_dir(plugin_name: &str) -> PathBuf {
124    Path::new("plugins").join(plugin_name)
125}
126
127/// Where [`super::render_plugin_manifest`]'s output is written, relative to
128/// the marketplace root.
129#[must_use]
130pub fn plugin_manifest_path(plugin_name: &str) -> PathBuf {
131    plugin_source_dir(plugin_name)
132        .join(".codex-plugin")
133        .join("plugin.json")
134}
135
136/// Where [`super::render_hooks_manifest`]'s output is written, relative to the
137/// marketplace root. This is Codex's *default* hooks location, which is why a
138/// rendered plugin manifest declares no `hooks` override.
139#[must_use]
140pub fn hooks_manifest_path(plugin_name: &str) -> PathBuf {
141    plugin_source_dir(plugin_name)
142        .join("hooks")
143        .join("hooks.json")
144}
145
146/// The `<plugin>@<marketplace>` spec `codex plugin add` and
147/// `codex plugin remove` take, and the key Codex records enablement under in
148/// its `config.toml`.
149#[must_use]
150pub fn plugin_spec(plugin_name: &str, marketplace_name: &str) -> String {
151    format!("{plugin_name}@{marketplace_name}")
152}
153
154/// Render the marketplace manifest around a consumer's names.
155///
156/// The source path is derived from the plugin name rather than accepted as a
157/// parameter: it must agree with [`plugin_source_dir`], and a manifest whose
158/// path points anywhere else installs nothing.
159#[must_use]
160pub fn render_marketplace_manifest(identity: &MarketplaceIdentity) -> String {
161    let source_path = format!("./{}", plugin_source_dir(identity.plugin_name).display());
162    render_slots(
163        MARKETPLACE_MANIFEST_TEMPLATE,
164        &[
165            (MARKETPLACE_NAME_SLOT, identity.name),
166            (MARKETPLACE_DISPLAY_NAME_SLOT, identity.display_name),
167            (MARKETPLACE_PLUGIN_NAME_SLOT, identity.plugin_name),
168            (MARKETPLACE_PLUGIN_SOURCE_PATH_SLOT, &source_path),
169        ],
170    )
171}
172
173/// Whether Codex's `config.toml` marks `plugin_spec` explicitly disabled.
174///
175/// Deliberately forgiving: unparseable text, an absent table, or an absent key
176/// all read as "not disabled", because the question this answers is only
177/// "would installing override a choice the user made in the app", and the
178/// cost of guessing wrong toward `false` is an install the user asked for.
179///
180/// Text in, no filesystem — resolving `$CODEX_HOME` and reading the file stay
181/// with the consumer, as they do for [`crate::config::codex`].
182#[must_use]
183pub fn plugin_disabled_in_config(config_text: &str, plugin_spec: &str) -> bool {
184    use toml_edit::{Document, Item};
185
186    let Ok(document) = config_text.parse::<Document>() else {
187        return false;
188    };
189    document
190        .get("plugins")
191        .and_then(Item::as_table_like)
192        .and_then(|plugins| plugins.get(plugin_spec))
193        .and_then(Item::as_table_like)
194        .and_then(|plugin| plugin.get("enabled"))
195        .and_then(Item::as_bool)
196        == Some(false)
197}
198
199/// What a registration run must accomplish on Codex's side.
200///
201/// The distinction exists because an "already installed" answer is only
202/// trustworthy when the caller knows the *current* bytes are what Codex
203/// cached. Which of these applies is the consumer's bookkeeping; what each
204/// one makes the CLI do is this module's.
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206#[non_exhaustive]
207pub enum InstallGoal {
208    /// Nothing is known to have been delivered. An existing install is of
209    /// unknown provenance — a copy from someone's source checkout, or an
210    /// older release — so "already installed" cannot be believed and the
211    /// cached copy is forced fresh.
212    Install,
213    /// A *different* set of bytes was delivered before. Codex's cached copy
214    /// is known stale and must be re-copied.
215    Refresh,
216    /// These exact bytes were delivered and confirmed. "Already installed" is
217    /// trustworthy and nothing is forced.
218    Verify,
219}
220
221/// Outcome of registering the marketplace source.
222#[derive(Debug, Clone, PartialEq, Eq)]
223#[non_exhaustive]
224pub enum MarketplaceOutcome {
225    /// `codex plugin marketplace add` exited 0.
226    Added,
227    /// It failed, saying this source is already registered.
228    AlreadyAdded,
229    /// A marketplace of the same name pointed at a **different** directory
230    /// (the state of any machine that registered the plugin from a source
231    /// checkout). It was removed and re-added from the caller's root.
232    ///
233    /// Replacing is safe in Codex's model: removing a marketplace does not
234    /// uninstall the plugins installed from it, so the install survives and
235    /// the following `plugin add` re-copies it from the new root.
236    Replaced {
237        /// The name whose previous registration was replaced.
238        marketplace_name: String,
239    },
240    /// The step failed for a real reason.
241    Failed {
242        /// The CLI's own words, flattened and bounded.
243        detail: String,
244    },
245}
246
247impl MarketplaceOutcome {
248    /// One line naming what happened, for a consumer's summary.
249    #[must_use]
250    pub fn describe(&self) -> String {
251        match self {
252            Self::Added => "added".to_owned(),
253            Self::AlreadyAdded => "already added".to_owned(),
254            Self::Replaced { marketplace_name } => format!(
255                "replaced an existing '{marketplace_name}' marketplace that pointed at a \
256                 different source"
257            ),
258            Self::Failed { detail } => format!("failed: {detail}"),
259        }
260    }
261
262    /// Whether the plugin step must be skipped: there is no source to install
263    /// from.
264    #[must_use]
265    pub fn failed(&self) -> bool {
266        matches!(self, Self::Failed { .. })
267    }
268}
269
270/// Outcome of installing or refreshing the plugin.
271#[derive(Debug, Clone, PartialEq, Eq)]
272#[non_exhaustive]
273pub enum InstallOutcome {
274    /// `codex plugin add` succeeded with nothing known to be stale.
275    Installed,
276    /// The caller knew the current bytes were delivered and the CLI agrees an
277    /// install exists.
278    AlreadyInstalled,
279    /// Codex's cached copy was re-copied from the marketplace root.
280    Refreshed,
281    /// The step failed; whatever was installed before is still installed.
282    Failed {
283        /// The CLI's own words, flattened and bounded.
284        detail: String,
285    },
286    /// The forced refresh removed the untrusted install and then failed to
287    /// re-add it: the plugin is currently **not** installed, which is the one
288    /// outcome a consumer must say out loud.
289    RemovedNotReinstalled {
290        /// The CLI's own words, flattened and bounded.
291        detail: String,
292    },
293    /// The step never ran because the marketplace step failed.
294    Skipped,
295}
296
297impl InstallOutcome {
298    /// One line naming what happened, for a consumer's summary.
299    #[must_use]
300    pub fn describe(&self) -> String {
301        match self {
302            Self::Installed => "installed".to_owned(),
303            Self::AlreadyInstalled => "already installed".to_owned(),
304            Self::Refreshed => "refreshed to the new bundled version".to_owned(),
305            Self::Failed { detail } | Self::RemovedNotReinstalled { detail } => {
306                format!("failed: {detail}")
307            }
308            Self::Skipped => "skipped (marketplace registration failed)".to_owned(),
309        }
310    }
311
312    /// Whether the consumer's summary should print
313    /// [`PluginManager::manual_commands`].
314    #[must_use]
315    pub fn needs_manual_retry(&self) -> bool {
316        matches!(
317            self,
318            Self::Failed { .. } | Self::RemovedNotReinstalled { .. } | Self::Skipped
319        )
320    }
321
322    /// Whether this run **confirmed** that Codex's cache now holds the bytes
323    /// under the marketplace root — the only outcomes a consumer may record as
324    /// delivered.
325    #[must_use]
326    pub fn confirmed_delivery(&self) -> bool {
327        matches!(self, Self::Installed | Self::Refreshed)
328    }
329}
330
331/// What one [`PluginManager::register`] run found, distinguishing "there is no
332/// CLI here at all" from per-step outcomes so a summary never fakes success.
333///
334/// Deliberately *not* `#[non_exhaustive]`, unlike the outcome enums it holds:
335/// the variants here are the closed set of reasons a run ends, and every
336/// consumer must branch on all of them. Forcing a wildcard arm would only
337/// invite one that silently swallowed a case with real consequences — adding
338/// [`Self::SkippedDisabled`] here deliberately broke both consumers rather
339/// than letting them keep reporting an install that no longer happens. The
340/// outcomes inside [`Self::Steps`] stay open, because Codex can always give a
341/// new answer.
342#[derive(Debug, Clone, PartialEq, Eq)]
343pub enum ManagerRun {
344    /// The `codex` program does not exist. Nothing ran and nothing is known;
345    /// a consumer prints [`PluginManager::manual_commands`] and leaves its
346    /// own delivery bookkeeping untouched.
347    CliAbsent,
348    /// The caller reported the plugin disabled in Codex's config, so nothing
349    /// ran at all — see [`SKIPPED_DISABLED_REASON`].
350    ///
351    /// A sibling of [`Self::CliAbsent`] rather than an install outcome,
352    /// because "disabled" is not a fact about the install step. Registering
353    /// the marketplace can *replace* a same-named source belonging to someone
354    /// else, and doing that to serve an install that is then not performed is
355    /// a destructive act taken behind the back of a user who already said no.
356    /// Nothing runs, so nothing — not even the existence of the CLI — is
357    /// learned.
358    SkippedDisabled,
359    /// The CLI ran. Each step reports its own outcome.
360    Steps {
361        /// Registering the marketplace source.
362        marketplace: MarketplaceOutcome,
363        /// Installing or refreshing the plugin.
364        install: InstallOutcome,
365    },
366}
367
368/// Why [`ManagerRun::SkippedDisabled`] happened, in words a consumer can print.
369///
370/// Shared so both clients say the same thing about the same Codex behaviour:
371/// `codex plugin add` sets `enabled = true`, so installing over a disabled
372/// plugin would silently reverse a choice the user made in the app.
373pub const SKIPPED_DISABLED_REASON: &str = "the plugin is disabled in Codex config; enable it in the app, then install again \
374     (installing now would force-re-enable it)";
375
376/// One packaged plugin, and the `codex` binary that manages it.
377#[derive(Debug, Clone, PartialEq, Eq)]
378pub struct PluginManager {
379    codex_program: PathBuf,
380    marketplace_root: PathBuf,
381    marketplace_name: String,
382    plugin_name: String,
383}
384
385impl PluginManager {
386    /// Manage `plugin_name`, offered by the marketplace at `marketplace_root`
387    /// under `marketplace_name`, through the `codex` binary at
388    /// `codex_program`.
389    ///
390    /// `codex_program` is a parameter rather than the literal `codex` so a
391    /// test can inject a shim; production callers pass the bare name and let
392    /// `PATH` resolve it. Child processes inherit the caller's environment, so
393    /// `$CODEX_HOME` resolves the same for the CLI as for the caller — pinning
394    /// it here would only risk desynchronising from a future CLI change.
395    #[must_use]
396    pub fn new(
397        codex_program: impl Into<PathBuf>,
398        marketplace_root: impl Into<PathBuf>,
399        marketplace_name: impl Into<String>,
400        plugin_name: impl Into<String>,
401    ) -> Self {
402        Self {
403            codex_program: codex_program.into(),
404            marketplace_root: marketplace_root.into(),
405            marketplace_name: marketplace_name.into(),
406            plugin_name: plugin_name.into(),
407        }
408    }
409
410    /// The directory `codex plugin marketplace add` is pointed at.
411    #[must_use]
412    pub fn marketplace_root(&self) -> &Path {
413        &self.marketplace_root
414    }
415
416    /// The `<plugin>@<marketplace>` spec this manager installs.
417    #[must_use]
418    pub fn plugin_spec(&self) -> String {
419        plugin_spec(&self.plugin_name, &self.marketplace_name)
420    }
421
422    /// The two commands, in order, that [`Self::register`] runs — the exact
423    /// text to print when there is no CLI to run them with, or when a step
424    /// failed and the user must retry by hand.
425    /// Every argument is shell-quoted, because this text exists to be pasted
426    /// into a shell: a marketplace root under a home directory with a space in
427    /// it would otherwise register a different — probably nonexistent —
428    /// directory, without saying so.
429    #[must_use]
430    pub fn manual_commands(&self) -> [String; 2] {
431        [
432            format!(
433                "codex plugin marketplace add {}",
434                shell_quote(&self.marketplace_root.to_string_lossy())
435            ),
436            self.install_command(),
437        ]
438    }
439
440    /// Just the install command — what a consumer prints to recover from
441    /// [`InstallOutcome::RemovedNotReinstalled`]. Shell-quoted for the reason
442    /// [`Self::manual_commands`] gives.
443    #[must_use]
444    pub fn install_command(&self) -> String {
445        format!("codex plugin add {}", shell_quote(&self.plugin_spec()))
446    }
447
448    /// Register the marketplace and install (or refresh) the plugin.
449    ///
450    /// `plugin_disabled` is the caller's answer from
451    /// [`plugin_disabled_in_config`], passed in rather than read here because
452    /// locating `config.toml` is deployment. It gates the **whole** run: a
453    /// disabled plugin means no `codex` command is issued, not merely that the
454    /// install step is skipped. Registering the marketplace is not a read —
455    /// against a same-named source pointing elsewhere it removes that
456    /// registration and redirects the name here, which is not something to do
457    /// on behalf of an install that will not happen.
458    #[must_use]
459    pub fn register(&self, goal: InstallGoal, plugin_disabled: bool) -> ManagerRun {
460        if plugin_disabled {
461            return ManagerRun::SkippedDisabled;
462        }
463        let Some(marketplace) = self.register_marketplace() else {
464            return ManagerRun::CliAbsent;
465        };
466        let install = if marketplace.failed() {
467            InstallOutcome::Skipped
468        } else {
469            self.install(goal)
470        };
471        ManagerRun::Steps {
472            marketplace,
473            install,
474        }
475    }
476
477    /// Register the marketplace, replacing a same-named one that points
478    /// elsewhere. `None` means the `codex` program does not exist.
479    ///
480    /// The collision check runs before the generic "already" check because the
481    /// collision error *also* contains "already added": ordering them the
482    /// other way would report a stale registration as a success and install
483    /// from the wrong directory.
484    fn register_marketplace(&self) -> Option<MarketplaceOutcome> {
485        match self.run_marketplace_add() {
486            Invocation::Missing => None,
487            Invocation::Ran { success: true, .. } => Some(MarketplaceOutcome::Added),
488            Invocation::Ran { detail, .. } => {
489                let lowered = detail.to_ascii_lowercase();
490                if lowered.contains("different source") {
491                    Some(self.replace_marketplace())
492                } else if says_already(&lowered) {
493                    Some(MarketplaceOutcome::AlreadyAdded)
494                } else {
495                    Some(MarketplaceOutcome::Failed { detail })
496                }
497            }
498        }
499    }
500
501    fn replace_marketplace(&self) -> MarketplaceOutcome {
502        let name = &self.marketplace_name;
503        match self.run(&["plugin", "marketplace", "remove", name]) {
504            Invocation::Missing => {
505                return MarketplaceOutcome::Failed {
506                    detail: CLI_VANISHED.to_owned(),
507                };
508            }
509            Invocation::Ran {
510                success: false,
511                detail,
512            } => {
513                return MarketplaceOutcome::Failed {
514                    detail: format!(
515                        "an existing '{name}' marketplace points at a different source and \
516                         `codex plugin marketplace remove {name}` failed: {detail}"
517                    ),
518                };
519            }
520            Invocation::Ran { success: true, .. } => {}
521        }
522        match self.run_marketplace_add() {
523            Invocation::Ran { success: true, .. } => MarketplaceOutcome::Replaced {
524                marketplace_name: name.clone(),
525            },
526            Invocation::Ran { detail, .. } => MarketplaceOutcome::Failed {
527                detail: format!(
528                    "removed the previous '{name}' marketplace but re-adding the managed one \
529                     failed: {detail}"
530                ),
531            },
532            Invocation::Missing => MarketplaceOutcome::Failed {
533                detail: CLI_VANISHED.to_owned(),
534            },
535        }
536    }
537
538    /// Install the plugin, forcing a cache refresh whenever the goal says the
539    /// cached copy cannot be trusted.
540    ///
541    /// codex-cli 0.146.0 has no `plugin update` subcommand, and
542    /// `plugin marketplace upgrade` only refreshes Git-sourced snapshots — but
543    /// `plugin add` against a *local* marketplace exits 0 and re-copies the
544    /// cached plugin on every run, so a plain re-`add` is the native refresh.
545    /// The remove-then-re-add fallback below exists for CLI versions that
546    /// instead report the existing install without re-copying.
547    fn install(&self, goal: InstallGoal) -> InstallOutcome {
548        match self.run_plugin_add() {
549            Invocation::Missing => InstallOutcome::Failed {
550                detail: CLI_VANISHED.to_owned(),
551            },
552            Invocation::Ran { success: true, .. } => {
553                if goal == InstallGoal::Refresh {
554                    InstallOutcome::Refreshed
555                } else {
556                    InstallOutcome::Installed
557                }
558            }
559            Invocation::Ran { detail, .. } => {
560                if says_already(&detail.to_ascii_lowercase()) {
561                    match goal {
562                        InstallGoal::Verify => InstallOutcome::AlreadyInstalled,
563                        InstallGoal::Install | InstallGoal::Refresh => self.force_refresh(),
564                    }
565                } else {
566                    InstallOutcome::Failed { detail }
567                }
568            }
569        }
570    }
571
572    /// Remove the untrusted install, then re-add it from the marketplace root.
573    ///
574    /// Failure ordering carries the whole meaning: a failed *remove* leaves
575    /// the stale plugin installed and is a plain failure, while a successful
576    /// remove followed by a failed *re-add* leaves the plugin uninstalled —
577    /// strictly worse than doing nothing, and the only case a consumer must
578    /// hand the user a recovery command for.
579    fn force_refresh(&self) -> InstallOutcome {
580        let spec = self.plugin_spec();
581        match self.run(&["plugin", "remove", &spec]) {
582            Invocation::Missing => {
583                return InstallOutcome::Failed {
584                    detail: CLI_VANISHED.to_owned(),
585                };
586            }
587            Invocation::Ran {
588                success: false,
589                detail,
590            } => {
591                // Nothing to remove is a fine starting point for the re-add.
592                if !says_nothing_to_remove(&detail.to_ascii_lowercase()) {
593                    return InstallOutcome::Failed {
594                        detail: format!(
595                            "the installed plugin is stale and `codex plugin remove` failed: \
596                             {detail}"
597                        ),
598                    };
599                }
600            }
601            Invocation::Ran { success: true, .. } => {}
602        }
603        match self.run_plugin_add() {
604            Invocation::Ran { success: true, .. } => InstallOutcome::Refreshed,
605            Invocation::Ran { detail, .. } => {
606                if says_already(&detail.to_ascii_lowercase()) {
607                    InstallOutcome::Failed {
608                        detail: "codex plugin add still reports an existing install after \
609                                 remove; refresh manually"
610                            .to_owned(),
611                    }
612                } else {
613                    InstallOutcome::RemovedNotReinstalled { detail }
614                }
615            }
616            Invocation::Missing => InstallOutcome::RemovedNotReinstalled {
617                detail: CLI_VANISHED.to_owned(),
618            },
619        }
620    }
621
622    fn run_marketplace_add(&self) -> Invocation {
623        let root = self.marketplace_root.clone();
624        let mut command = std::process::Command::new(&self.codex_program);
625        command.args(["plugin", "marketplace", "add"]).arg(root);
626        run_invocation(command)
627    }
628
629    fn run_plugin_add(&self) -> Invocation {
630        self.run(&["plugin", "add", &self.plugin_spec()])
631    }
632
633    fn run(&self, args: &[&str]) -> Invocation {
634        let mut command = std::process::Command::new(&self.codex_program);
635        command.args(args);
636        run_invocation(command)
637    }
638}
639
640/// Detail for the narrow window where the `codex` binary existed for one
641/// command and not the next.
642const CLI_VANISHED: &str = "codex CLI disappeared between commands";
643
644/// One `codex` invocation, uninterpreted.
645enum Invocation {
646    /// The `codex` program does not exist.
647    Missing,
648    /// It ran; exit status plus flattened output.
649    Ran { success: bool, detail: String },
650}
651
652/// Whether a **failed** invocation's lowercased output says the work was
653/// already done.
654///
655/// Matches the specific phrasings the CLI uses rather than a bare "already",
656/// so unrelated errors that happen to contain the word (a file "already in
657/// use") stay failures.
658fn says_already(lowered_detail: &str) -> bool {
659    ["already added", "already installed", "already exists"]
660        .iter()
661        .any(|phrase| lowered_detail.contains(phrase))
662}
663
664/// Whether a **failed** remove's lowercased output says there was nothing to
665/// remove.
666fn says_nothing_to_remove(lowered_detail: &str) -> bool {
667    ["not installed", "not configured", "already removed"]
668        .iter()
669        .any(|phrase| lowered_detail.contains(phrase))
670}
671
672/// Run one invocation with stdin closed and output captured.
673///
674/// Stdin is closed because a plugin manager that decides to prompt would
675/// otherwise hang a non-interactive install forever. Only
676/// [`std::io::ErrorKind::NotFound`] is [`Invocation::Missing`]; any other
677/// spawn failure is a failed run carrying the OS error, so a permission
678/// problem reads as a failure rather than as an absent CLI.
679fn run_invocation(mut command: std::process::Command) -> Invocation {
680    let output = match command.stdin(std::process::Stdio::null()).output() {
681        Ok(output) => output,
682        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
683            return Invocation::Missing;
684        }
685        Err(error) => {
686            return Invocation::Ran {
687                success: false,
688                detail: error.to_string(),
689            };
690        }
691    };
692    if output.status.success() {
693        return Invocation::Ran {
694            success: true,
695            detail: String::new(),
696        };
697    }
698    Invocation::Ran {
699        success: false,
700        detail: summarize_output(&output),
701    }
702}
703
704/// Flatten a failed invocation's stderr and stdout into one bounded line.
705///
706/// Bounded because the result is both matched on and printed: an unbounded
707/// CLI dump would push a consumer's own summary off the screen.
708fn summarize_output(output: &std::process::Output) -> String {
709    let stderr = String::from_utf8_lossy(&output.stderr);
710    let stdout = String::from_utf8_lossy(&output.stdout);
711    let mut detail = stderr
712        .lines()
713        .chain(stdout.lines())
714        .map(str::trim)
715        .filter(|line| !line.is_empty())
716        .collect::<Vec<_>>()
717        .join("; ");
718    if detail.chars().count() > MAX_DETAIL_CHARS {
719        detail = detail.chars().take(MAX_DETAIL_CHARS).collect::<String>() + "…";
720    }
721    if detail.is_empty() {
722        detail = format!("exited with {}", output.status);
723    }
724    detail
725}
726
727/// Cap on a flattened CLI detail, in characters.
728const MAX_DETAIL_CHARS: usize = 200;
729
730#[cfg(test)]
731#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
732mod tests {
733    use super::*;
734    use crate::plugin::codex_app::{HookPluginIdentity, render_plugin_manifest};
735
736    fn identity() -> MarketplaceIdentity<'static> {
737        MarketplaceIdentity::new("acme", "acme-codex").with_display_name("Acme")
738    }
739
740    fn manager(codex_program: PathBuf, root: &Path) -> PluginManager {
741        PluginManager::new(
742            codex_program,
743            root.join("marketplace"),
744            "acme",
745            "acme-codex",
746        )
747    }
748
749    fn missing_codex(root: &Path) -> PathBuf {
750        root.join("codex-not-installed")
751    }
752
753    /// A `codex` shim that appends its arguments to `invocations.log` and
754    /// scripts per-subcommand behaviour, so a test asserts exact invocations
755    /// without touching `PATH`.
756    ///
757    /// The shim is executed once before it is returned, retrying `ETXTBSY`,
758    /// because a freshly written executable is momentarily unrunnable in a
759    /// multithreaded test binary. While `fs::write` holds the file open for
760    /// writing, any other test thread that spawns ITS shim forks this
761    /// process, and the child inherits a duplicate of the open descriptor
762    /// until its own exec closes it (`O_CLOEXEC` closes at exec, not at
763    /// fork). Linux refuses to exec a file any process holds open for
764    /// writing, so a spawn that lands in that window fails with
765    /// `Text file busy`. Closing our handle before returning — which
766    /// `fs::write` already does — cannot retract the duplicates, and neither
767    /// can a write-then-rename, since the duplicates name the inode rather
768    /// than the path. The duplicates are only ever created during the write,
769    /// though, and each dies at its holder's exec — so once one exec of the
770    /// shim succeeds, none remain and every later spawn is safe. That first
771    /// exec happens here, with no arguments — no shim body changes any state
772    /// on an argument list that names no subcommand — and the log line it
773    /// appends is deleted so each test still observes exactly its own
774    /// invocations.
775    #[cfg(unix)]
776    fn write_codex_shim(root: &Path, body: &str) -> PathBuf {
777        use std::os::unix::fs::PermissionsExt;
778
779        let log = root.join("invocations.log");
780        let path = root.join("codex");
781        std::fs::write(
782            &path,
783            format!("#!/bin/sh\necho \"$@\" >> \"{}\"\n{body}\n", log.display()),
784        )
785        .unwrap();
786        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
787
788        for attempt in 1.. {
789            match std::process::Command::new(&path).output() {
790                Ok(_) => break,
791                Err(err)
792                    if err.kind() == std::io::ErrorKind::ExecutableFileBusy && attempt < 100 =>
793                {
794                    std::thread::sleep(std::time::Duration::from_millis(5));
795                }
796                Err(err) => panic!("warm-up exec of {} failed: {err}", path.display()),
797            }
798        }
799        let _ = std::fs::remove_file(&log);
800        path
801    }
802
803    #[cfg(unix)]
804    fn shim_log(root: &Path) -> Vec<String> {
805        std::fs::read_to_string(root.join("invocations.log"))
806            .unwrap_or_default()
807            .lines()
808            .map(str::to_owned)
809            .collect()
810    }
811
812    #[cfg(unix)]
813    fn add_marketplace(root: &Path) -> String {
814        format!(
815            "plugin marketplace add {}",
816            root.join("marketplace").display()
817        )
818    }
819
820    #[test]
821    fn the_rendered_marketplace_offers_the_plugin_at_the_path_the_helpers_name() {
822        let rendered = render_marketplace_manifest(&identity());
823        let parsed: serde_json::Value = serde_json::from_str(&rendered).unwrap();
824
825        assert!(!rendered.contains("__TAPES_"), "{rendered}");
826        assert_eq!(parsed["name"], "acme");
827        assert_eq!(parsed["interface"]["displayName"], "Acme");
828        let plugins = parsed["plugins"].as_array().unwrap();
829        assert_eq!(plugins.len(), 1);
830        assert_eq!(plugins[0]["name"], "acme-codex");
831        assert_eq!(plugins[0]["source"]["source"], "local");
832
833        // The offered path must cover the files the path helpers place, or the
834        // marketplace advertises a plugin Codex cannot find.
835        let offered = plugins[0]["source"]["path"].as_str().unwrap();
836        let offered = Path::new(offered.trim_start_matches("./"));
837        assert_eq!(offered, plugin_source_dir("acme-codex"));
838        for path in [
839            plugin_manifest_path("acme-codex"),
840            hooks_manifest_path("acme-codex"),
841        ] {
842            assert!(
843                path.starts_with(offered),
844                "{} escapes {offered:?}",
845                path.display()
846            );
847        }
848    }
849
850    /// The marketplace's plugin name and the plugin manifest's `name` are how
851    /// Codex resolves an offer to a directory; a drift between them installs
852    /// nothing. The spec a consumer hands `plugin add` is built from the same
853    /// pair.
854    #[test]
855    fn the_offered_name_the_manifest_name_and_the_spec_agree() {
856        let marketplace: serde_json::Value =
857            serde_json::from_str(&render_marketplace_manifest(&identity())).unwrap();
858        let manifest: serde_json::Value = serde_json::from_str(&render_plugin_manifest(
859            &HookPluginIdentity::new("acme-codex", "1.0.0"),
860        ))
861        .unwrap();
862
863        assert_eq!(marketplace["plugins"][0]["name"], manifest["name"]);
864        assert_eq!(
865            plugin_spec("acme-codex", "acme"),
866            format!(
867                "{}@{}",
868                marketplace["plugins"][0]["name"].as_str().unwrap(),
869                marketplace["name"].as_str().unwrap()
870            )
871        );
872    }
873
874    /// A minimal identity leaves no slot behind, and the display name falls
875    /// back to the marketplace name rather than to an empty string.
876    #[test]
877    fn a_minimal_marketplace_identity_fills_every_slot() {
878        let rendered = render_marketplace_manifest(&MarketplaceIdentity::new("bare", "bare-codex"));
879        let parsed: serde_json::Value = serde_json::from_str(&rendered).unwrap();
880
881        assert!(!rendered.contains("__TAPES_"), "{rendered}");
882        assert_eq!(parsed["interface"]["displayName"], "bare");
883    }
884
885    /// The de-branding bar every crate-owned asset meets.
886    #[test]
887    fn the_marketplace_template_carries_no_vendor_branding() {
888        let lowered = MARKETPLACE_MANIFEST_TEMPLATE.to_ascii_lowercase();
889        for token in ["paper", "papercompute", "tapesctl"] {
890            assert!(!lowered.contains(token), "the template mentions {token:?}");
891        }
892    }
893
894    #[test]
895    fn every_marketplace_slot_is_filled_and_none_is_unknown() {
896        for slot in [
897            MARKETPLACE_NAME_SLOT,
898            MARKETPLACE_DISPLAY_NAME_SLOT,
899            MARKETPLACE_PLUGIN_NAME_SLOT,
900            MARKETPLACE_PLUGIN_SOURCE_PATH_SLOT,
901        ] {
902            assert!(
903                MARKETPLACE_MANIFEST_TEMPLATE.contains(&format!("\"{slot}\"")),
904                "template is missing slot {slot}"
905            );
906        }
907        assert_eq!(MARKETPLACE_MANIFEST_TEMPLATE.matches("__TAPES_").count(), 4);
908    }
909
910    #[test]
911    fn an_absent_cli_is_reported_rather_than_failed() {
912        let root = tempfile::tempdir().unwrap();
913        let manager = manager(missing_codex(root.path()), root.path());
914
915        for goal in [
916            InstallGoal::Install,
917            InstallGoal::Refresh,
918            InstallGoal::Verify,
919        ] {
920            assert_eq!(manager.register(goal, false), ManagerRun::CliAbsent);
921        }
922    }
923
924    #[cfg(unix)]
925    #[test]
926    fn a_clean_run_adds_the_marketplace_then_the_plugin() {
927        let root = tempfile::tempdir().unwrap();
928        let manager = manager(write_codex_shim(root.path(), "exit 0"), root.path());
929
930        let run = manager.register(InstallGoal::Install, false);
931
932        assert_eq!(
933            run,
934            ManagerRun::Steps {
935                marketplace: MarketplaceOutcome::Added,
936                install: InstallOutcome::Installed,
937            }
938        );
939        assert_eq!(
940            shim_log(root.path()),
941            vec![
942                add_marketplace(root.path()),
943                "plugin add acme-codex@acme".to_owned(),
944            ]
945        );
946    }
947
948    #[cfg(unix)]
949    #[test]
950    fn already_wording_is_trusted_only_when_the_caller_confirmed_delivery() {
951        let root = tempfile::tempdir().unwrap();
952        let manager = manager(
953            write_codex_shim(
954                root.path(),
955                "echo 'error: marketplace already exists' >&2\nexit 1",
956            ),
957            root.path(),
958        );
959
960        let run = manager.register(InstallGoal::Verify, false);
961
962        assert_eq!(
963            run,
964            ManagerRun::Steps {
965                marketplace: MarketplaceOutcome::AlreadyAdded,
966                install: InstallOutcome::AlreadyInstalled,
967            }
968        );
969    }
970
971    /// Unrecognised failure wording must stay a failure: reinterpreting it
972    /// would report an install that never happened.
973    #[cfg(unix)]
974    #[test]
975    fn unrecognised_failure_wording_skips_the_install() {
976        let root = tempfile::tempdir().unwrap();
977        let manager = manager(
978            write_codex_shim(root.path(), "echo 'boom: no permission' >&2\nexit 2"),
979            root.path(),
980        );
981
982        let run = manager.register(InstallGoal::Install, false);
983
984        assert_eq!(
985            run,
986            ManagerRun::Steps {
987                marketplace: MarketplaceOutcome::Failed {
988                    detail: "boom: no permission".to_owned()
989                },
990                install: InstallOutcome::Skipped,
991            }
992        );
993        assert_eq!(
994            shim_log(root.path()).len(),
995            1,
996            "the plugin add must not run"
997        );
998    }
999
1000    /// codex-cli 0.146.0's own refresh path: `plugin add` exits 0 and
1001    /// re-copies, so the fallback must not fire.
1002    #[cfg(unix)]
1003    #[test]
1004    fn a_cooperative_add_refreshes_without_the_remove_fallback() {
1005        let root = tempfile::tempdir().unwrap();
1006        let manager = manager(write_codex_shim(root.path(), "exit 0"), root.path());
1007
1008        let run = manager.register(InstallGoal::Refresh, false);
1009
1010        assert_eq!(
1011            run,
1012            ManagerRun::Steps {
1013                marketplace: MarketplaceOutcome::Added,
1014                install: InstallOutcome::Refreshed,
1015            }
1016        );
1017        assert_eq!(
1018            shim_log(root.path()),
1019            vec![
1020                add_marketplace(root.path()),
1021                "plugin add acme-codex@acme".to_owned(),
1022            ]
1023        );
1024    }
1025
1026    #[cfg(unix)]
1027    fn add_is_sticky_until_removed(root: &Path) -> PathBuf {
1028        write_codex_shim(
1029            root,
1030            &format!(
1031                "case \"$*\" in\n  \
1032                 *'plugin remove'*) touch \"{removed}\"; exit 0 ;;\n  \
1033                 *'plugin add'*) if [ -f \"{removed}\" ]; then exit 0; \
1034                 else echo 'plugin is already installed' >&2; exit 1; fi ;;\n  \
1035                 *) exit 0 ;;\nesac",
1036                removed = root.join("removed.sentinel").display()
1037            ),
1038        )
1039    }
1040
1041    #[cfg(unix)]
1042    #[test]
1043    fn an_uncooperative_add_falls_back_to_remove_then_re_add() {
1044        let root = tempfile::tempdir().unwrap();
1045        let manager = manager(add_is_sticky_until_removed(root.path()), root.path());
1046
1047        let run = manager.register(InstallGoal::Refresh, false);
1048
1049        assert_eq!(
1050            run,
1051            ManagerRun::Steps {
1052                marketplace: MarketplaceOutcome::Added,
1053                install: InstallOutcome::Refreshed,
1054            }
1055        );
1056        assert_eq!(
1057            shim_log(root.path()),
1058            vec![
1059                add_marketplace(root.path()),
1060                "plugin add acme-codex@acme".to_owned(),
1061                "plugin remove acme-codex@acme".to_owned(),
1062                "plugin add acme-codex@acme".to_owned(),
1063            ],
1064            "fallback order must be add, remove, re-add"
1065        );
1066    }
1067
1068    /// An install of unknown provenance is forced fresh even though nothing is
1069    /// known to be stale: the cached copy could be anyone's.
1070    #[cfg(unix)]
1071    #[test]
1072    fn an_unconfirmed_existing_install_is_forced_fresh() {
1073        let root = tempfile::tempdir().unwrap();
1074        let manager = manager(add_is_sticky_until_removed(root.path()), root.path());
1075
1076        let run = manager.register(InstallGoal::Install, false);
1077
1078        assert!(
1079            matches!(
1080                run,
1081                ManagerRun::Steps {
1082                    install: InstallOutcome::Refreshed,
1083                    ..
1084                }
1085            ),
1086            "{run:?}"
1087        );
1088    }
1089
1090    #[cfg(unix)]
1091    #[test]
1092    fn a_failed_remove_keeps_the_stale_install_in_place() {
1093        let root = tempfile::tempdir().unwrap();
1094        let manager = manager(
1095            write_codex_shim(
1096                root.path(),
1097                "case \"$*\" in\n  \
1098                 *'plugin remove'*) echo 'remove blew up' >&2; exit 2 ;;\n  \
1099                 *'plugin add'*) echo 'plugin is already installed' >&2; exit 1 ;;\n  \
1100                 *) exit 0 ;;\nesac",
1101            ),
1102            root.path(),
1103        );
1104
1105        let run = manager.register(InstallGoal::Refresh, false);
1106
1107        let ManagerRun::Steps { install, .. } = run else {
1108            panic!("expected steps");
1109        };
1110        let InstallOutcome::Failed { detail } = install else {
1111            panic!("expected a plain failure, got {install:?}");
1112        };
1113        assert!(detail.contains("codex plugin remove"), "{detail}");
1114        assert!(detail.contains("remove blew up"), "{detail}");
1115        assert_eq!(
1116            shim_log(root.path())
1117                .iter()
1118                .filter(|line| line.starts_with("plugin add"))
1119                .count(),
1120            1,
1121            "no re-add may follow a failed remove"
1122        );
1123    }
1124
1125    /// Nothing-to-remove wording is not a failure: it is the state the re-add
1126    /// wants anyway.
1127    #[cfg(unix)]
1128    #[test]
1129    fn a_remove_that_had_nothing_to_remove_proceeds_to_the_re_add() {
1130        let root = tempfile::tempdir().unwrap();
1131        let manager = manager(
1132            write_codex_shim(
1133                root.path(),
1134                &format!(
1135                    "case \"$*\" in\n  \
1136                     *'plugin remove'*) touch \"{done}\"; echo 'plugin is not installed' >&2; \
1137                     exit 1 ;;\n  \
1138                     *'plugin add'*) if [ -f \"{done}\" ]; then exit 0; \
1139                     else echo 'plugin is already installed' >&2; exit 1; fi ;;\n  \
1140                     *) exit 0 ;;\nesac",
1141                    done = root.path().join("removed.sentinel").display()
1142                ),
1143            ),
1144            root.path(),
1145        );
1146
1147        let run = manager.register(InstallGoal::Refresh, false);
1148
1149        assert!(
1150            matches!(
1151                run,
1152                ManagerRun::Steps {
1153                    install: InstallOutcome::Refreshed,
1154                    ..
1155                }
1156            ),
1157            "{run:?}"
1158        );
1159    }
1160
1161    /// The one outcome that leaves the machine worse off than doing nothing.
1162    #[cfg(unix)]
1163    #[test]
1164    fn a_failed_re_add_after_a_successful_remove_says_so() {
1165        let root = tempfile::tempdir().unwrap();
1166        let manager = manager(
1167            write_codex_shim(
1168                root.path(),
1169                &format!(
1170                    "case \"$*\" in\n  \
1171                     *'plugin remove'*) touch \"{removed}\"; exit 0 ;;\n  \
1172                     *'plugin add'*) if [ -f \"{removed}\" ]; then echo 'network exploded' >&2; \
1173                     exit 2; else echo 'plugin is already installed' >&2; exit 1; fi ;;\n  \
1174                     *) exit 0 ;;\nesac",
1175                    removed = root.path().join("removed.sentinel").display()
1176                ),
1177            ),
1178            root.path(),
1179        );
1180
1181        let run = manager.register(InstallGoal::Refresh, false);
1182
1183        let ManagerRun::Steps { install, .. } = run else {
1184            panic!("expected steps");
1185        };
1186        assert_eq!(
1187            install,
1188            InstallOutcome::RemovedNotReinstalled {
1189                detail: "network exploded".to_owned()
1190            }
1191        );
1192        assert!(install.needs_manual_retry());
1193        assert!(!install.confirmed_delivery());
1194    }
1195
1196    /// Collision wording verified live against codex-cli 0.146.0. The
1197    /// collision error also contains "already added", so the ordering inside
1198    /// [`PluginManager::register_marketplace`] is what this pins.
1199    #[cfg(unix)]
1200    #[test]
1201    fn a_same_named_marketplace_at_another_source_is_replaced() {
1202        let root = tempfile::tempdir().unwrap();
1203        let manager = manager(
1204            write_codex_shim(
1205                root.path(),
1206                &format!(
1207                    "case \"$*\" in\n  \
1208                     *'plugin marketplace remove'*) touch \"{removed}\"; exit 0 ;;\n  \
1209                     *'plugin marketplace add'*) if [ -f \"{removed}\" ]; then exit 0; \
1210                     else echo \"Error: marketplace 'acme' is already added from a different \
1211                     source; remove it before adding this source\" >&2; exit 1; fi ;;\n  \
1212                     *) exit 0 ;;\nesac",
1213                    removed = root.path().join("mkt-removed.sentinel").display()
1214                ),
1215            ),
1216            root.path(),
1217        );
1218
1219        let run = manager.register(InstallGoal::Install, false);
1220
1221        assert_eq!(
1222            run,
1223            ManagerRun::Steps {
1224                marketplace: MarketplaceOutcome::Replaced {
1225                    marketplace_name: "acme".to_owned()
1226                },
1227                install: InstallOutcome::Installed,
1228            }
1229        );
1230        assert_eq!(
1231            shim_log(root.path()),
1232            vec![
1233                add_marketplace(root.path()),
1234                "plugin marketplace remove acme".to_owned(),
1235                add_marketplace(root.path()),
1236                "plugin add acme-codex@acme".to_owned(),
1237            ],
1238            "collision order must be add, remove, re-add, plugin add"
1239        );
1240    }
1241
1242    #[cfg(unix)]
1243    #[test]
1244    fn a_collision_whose_removal_fails_is_reported_with_both_reasons() {
1245        let root = tempfile::tempdir().unwrap();
1246        let manager = manager(
1247            write_codex_shim(
1248                root.path(),
1249                "case \"$*\" in\n  \
1250                 *'plugin marketplace remove'*) echo 'permission denied' >&2; exit 2 ;;\n  \
1251                 *'plugin marketplace add'*) echo \"Error: marketplace 'acme' is already added \
1252                 from a different source; remove it before adding this source\" >&2; exit 1 ;;\n  \
1253                 *) exit 0 ;;\nesac",
1254            ),
1255            root.path(),
1256        );
1257
1258        let run = manager.register(InstallGoal::Install, false);
1259
1260        let ManagerRun::Steps {
1261            marketplace,
1262            install,
1263        } = run
1264        else {
1265            panic!("expected steps");
1266        };
1267        let MarketplaceOutcome::Failed { detail } = marketplace else {
1268            panic!("expected a failure, got {marketplace:?}");
1269        };
1270        assert!(detail.contains("different source"), "{detail}");
1271        assert!(detail.contains("permission denied"), "{detail}");
1272        assert_eq!(install, InstallOutcome::Skipped);
1273    }
1274
1275    /// A disabled plugin must leave the machine exactly as it found it — and
1276    /// the sharpest case is a same-named marketplace registered from someone
1277    /// else's source. Replacing that is destructive, it is done to serve an
1278    /// install that is then not performed, and it happens behind the back of a
1279    /// user who already said no.
1280    ///
1281    /// Asserting the invocation log is EMPTY rather than "no plugin add" is
1282    /// the point: the earlier spelling of this test watched only the install
1283    /// step and so could not see the marketplace being rewritten underneath.
1284    #[cfg(unix)]
1285    #[test]
1286    fn a_disabled_plugin_leaves_someone_elses_marketplace_alone() {
1287        let root = tempfile::tempdir().unwrap();
1288        let manager = manager(
1289            write_codex_shim(
1290                root.path(),
1291                &format!(
1292                    "case \"$*\" in\n  \
1293                     *'plugin marketplace remove'*) touch \"{removed}\"; exit 0 ;;\n  \
1294                     *'plugin marketplace add'*) if [ -f \"{removed}\" ]; then exit 0; \
1295                     else echo \"Error: marketplace 'acme' is already added from a different \
1296                     source; remove it before adding this source\" >&2; exit 1; fi ;;\n  \
1297                     *) exit 0 ;;\nesac",
1298                    removed = root.path().join("mkt-removed.sentinel").display()
1299                ),
1300            ),
1301            root.path(),
1302        );
1303
1304        let run = manager.register(InstallGoal::Install, true);
1305
1306        assert_eq!(run, ManagerRun::SkippedDisabled);
1307        assert!(
1308            shim_log(root.path()).is_empty(),
1309            "a disabled plugin must run no codex command at all, got {:?}",
1310            shim_log(root.path())
1311        );
1312    }
1313
1314    /// Every printed command is copy-paste text, so each of its arguments must
1315    /// survive `/bin/sh` word splitting as exactly one word. A marketplace root
1316    /// under a home directory with a space in it is ordinary, not exotic.
1317    ///
1318    /// The round trip is real: the command is handed to `sh` via `set --`,
1319    /// which performs the same splitting a user's paste would, and each
1320    /// resulting word is printed on its own line.
1321    #[cfg(unix)]
1322    #[test]
1323    fn a_printed_command_survives_shell_word_splitting() {
1324        let awkward = std::path::PathBuf::from("/tmp/two words/it's here/$HOME`x`;rm -rf/plugin");
1325        let manager = PluginManager::new("codex", &awkward, "acme", "acme-codex");
1326
1327        let words = shell_words(&manager.manual_commands()[0]);
1328
1329        assert_eq!(
1330            words,
1331            vec![
1332                "codex".to_owned(),
1333                "plugin".to_owned(),
1334                "marketplace".to_owned(),
1335                "add".to_owned(),
1336                awkward.display().to_string(),
1337            ],
1338            "the marketplace path did not survive as one word"
1339        );
1340    }
1341
1342    /// Split a command string the way a shell would, by letting a shell do it.
1343    #[cfg(unix)]
1344    fn shell_words(command: &str) -> Vec<String> {
1345        let output = std::process::Command::new("/bin/sh")
1346            .arg("-c")
1347            .arg(format!("set -- {command}\nprintf '%s\\n' \"$@\""))
1348            .output()
1349            .unwrap();
1350        assert!(
1351            output.status.success(),
1352            "the printed command is not even parseable by /bin/sh: {}",
1353            String::from_utf8_lossy(&output.stderr)
1354        );
1355        String::from_utf8(output.stdout)
1356            .unwrap()
1357            .lines()
1358            .map(str::to_owned)
1359            .collect()
1360    }
1361
1362    /// A CLI that fails with no output at all still produces a detail a user
1363    /// can act on, rather than an empty "failed: ".
1364    #[cfg(unix)]
1365    #[test]
1366    fn a_silent_failure_still_carries_a_detail() {
1367        let root = tempfile::tempdir().unwrap();
1368        let manager = manager(write_codex_shim(root.path(), "exit 3"), root.path());
1369
1370        let run = manager.register(InstallGoal::Install, false);
1371
1372        let ManagerRun::Steps { marketplace, .. } = run else {
1373            panic!("expected steps");
1374        };
1375        let MarketplaceOutcome::Failed { detail } = marketplace else {
1376            panic!("expected a failure, got {marketplace:?}");
1377        };
1378        assert!(detail.contains("exited with"), "{detail}");
1379    }
1380
1381    #[cfg(unix)]
1382    #[test]
1383    fn a_long_failure_detail_is_bounded() {
1384        let root = tempfile::tempdir().unwrap();
1385        let manager = manager(
1386            write_codex_shim(
1387                root.path(),
1388                "yes x | head -c 5000 | tr -d '\\n' >&2; exit 1",
1389            ),
1390            root.path(),
1391        );
1392
1393        let run = manager.register(InstallGoal::Install, false);
1394
1395        let ManagerRun::Steps { marketplace, .. } = run else {
1396            panic!("expected steps");
1397        };
1398        let MarketplaceOutcome::Failed { detail } = marketplace else {
1399            panic!("expected a failure, got {marketplace:?}");
1400        };
1401        assert_eq!(detail.chars().count(), MAX_DETAIL_CHARS + 1, "{detail}");
1402        assert!(detail.ends_with('…'), "{detail}");
1403    }
1404
1405    #[test]
1406    fn the_manual_commands_are_the_commands_a_run_would_have_issued() {
1407        let root = tempfile::tempdir().unwrap();
1408        let manager = manager(missing_codex(root.path()), root.path());
1409
1410        // An ordinary path and an ordinary spec print bare: quoting is applied
1411        // where it is needed, not everywhere, so the common case stays
1412        // readable.
1413        assert_eq!(
1414            manager.manual_commands(),
1415            [
1416                format!(
1417                    "codex plugin marketplace add {}",
1418                    manager.marketplace_root().display()
1419                ),
1420                "codex plugin add acme-codex@acme".to_owned(),
1421            ]
1422        );
1423        assert_eq!(manager.install_command(), manager.manual_commands()[1]);
1424    }
1425
1426    #[test]
1427    fn only_an_explicit_false_reads_as_disabled() {
1428        let spec = plugin_spec("acme-codex", "acme");
1429
1430        assert!(plugin_disabled_in_config(
1431            &format!("[plugins.\"{spec}\"]\nenabled = false\n"),
1432            &spec
1433        ));
1434        assert!(!plugin_disabled_in_config(
1435            &format!("[plugins.\"{spec}\"]\nenabled = true\n"),
1436            &spec
1437        ));
1438        // Absent table, absent key, another plugin's entry, and unparseable
1439        // text all mean "the user has not said no".
1440        assert!(!plugin_disabled_in_config("", &spec));
1441        assert!(!plugin_disabled_in_config(
1442            &format!("[plugins.\"{spec}\"]\n"),
1443            &spec
1444        ));
1445        assert!(!plugin_disabled_in_config(
1446            "[plugins.\"other@acme\"]\nenabled = false\n",
1447            &spec
1448        ));
1449        assert!(!plugin_disabled_in_config("not = [valid\n", &spec));
1450    }
1451
1452    #[test]
1453    fn describes_cover_every_outcome_without_leaking_a_debug_shape() {
1454        for outcome in [
1455            MarketplaceOutcome::Added,
1456            MarketplaceOutcome::AlreadyAdded,
1457            MarketplaceOutcome::Replaced {
1458                marketplace_name: "acme".to_owned(),
1459            },
1460            MarketplaceOutcome::Failed {
1461                detail: "boom".to_owned(),
1462            },
1463        ] {
1464            let described = outcome.describe();
1465            assert!(!described.is_empty());
1466            assert!(!described.contains('{'), "{described}");
1467        }
1468        for outcome in [
1469            InstallOutcome::Installed,
1470            InstallOutcome::AlreadyInstalled,
1471            InstallOutcome::Refreshed,
1472            InstallOutcome::Failed {
1473                detail: "boom".to_owned(),
1474            },
1475            InstallOutcome::RemovedNotReinstalled {
1476                detail: "boom".to_owned(),
1477            },
1478            InstallOutcome::Skipped,
1479        ] {
1480            let described = outcome.describe();
1481            assert!(!described.is_empty());
1482            assert!(!described.contains('{'), "{described}");
1483        }
1484    }
1485
1486    /// Only the two outcomes that prove Codex re-copied the bytes may advance
1487    /// a consumer's delivered record; everything else must leave delivery
1488    /// pending so a later run retries.
1489    #[test]
1490    fn only_a_proven_copy_counts_as_delivered() {
1491        assert!(InstallOutcome::Installed.confirmed_delivery());
1492        assert!(InstallOutcome::Refreshed.confirmed_delivery());
1493        for outcome in [
1494            InstallOutcome::AlreadyInstalled,
1495            InstallOutcome::Failed {
1496                detail: String::new(),
1497            },
1498            InstallOutcome::RemovedNotReinstalled {
1499                detail: String::new(),
1500            },
1501            InstallOutcome::Skipped,
1502        ] {
1503            assert!(!outcome.confirmed_delivery(), "{outcome:?}");
1504        }
1505    }
1506}