From 753957c167fe17793631ff45213ba87ef3088894 Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Fri, 22 May 2026 16:10:02 +0530 Subject: [PATCH 1/6] feat(actions): per-plugin update/toggle/uninstall via claude CLI - shell out to `claude plugin `, 60s timeout - bindings u/t/x; ConfirmModal for uninstall, ActionResultModal on failure - --actions flag (off by default; flipped in a later commit) --- src/agentpeek/actions/__init__.py | 0 src/agentpeek/actions/runner.py | 104 +++++++++++++++++ src/agentpeek/cli.py | 12 +- src/agentpeek/tui/app.py | 17 ++- src/agentpeek/tui/screens/action_result.py | 62 +++++++++++ src/agentpeek/tui/screens/confirm.py | 38 +++++++ src/agentpeek/tui/screens/main.py | 123 ++++++++++++++++++++- src/agentpeek/tui/styles/app.tcss | 73 ++++++++++++ 8 files changed, 424 insertions(+), 5 deletions(-) create mode 100644 src/agentpeek/actions/__init__.py create mode 100644 src/agentpeek/actions/runner.py create mode 100644 src/agentpeek/tui/screens/action_result.py create mode 100644 src/agentpeek/tui/screens/confirm.py diff --git a/src/agentpeek/actions/__init__.py b/src/agentpeek/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agentpeek/actions/runner.py b/src/agentpeek/actions/runner.py new file mode 100644 index 0000000..ccb6963 --- /dev/null +++ b/src/agentpeek/actions/runner.py @@ -0,0 +1,104 @@ +"""Shell-out runner for `claude plugin …` write operations. + +agentpeek does not edit Claude state files directly. Every write goes +through the `claude` CLI, which handles dependency validation, version +tracking, and cache invariants. See docs.claude.com plugin reference. +""" + +import shutil +import subprocess +import time +from dataclasses import dataclass +from typing import Literal + +PluginVerb = Literal["enable", "disable", "update", "uninstall", "install"] +Scope = Literal["user", "project", "local"] + +_TIMEOUT_SECONDS = 60 + + +@dataclass(frozen=True, slots=True) +class ActionResult: + ok: bool + verb: str + target: str + scope: str | None + returncode: int + stdout: str + stderr: str + elapsed_ms: int + + @property + def message(self) -> str: + """One-line summary suitable for a toast notification.""" + merged = (self.stdout + self.stderr).strip().splitlines() + return merged[-1] if merged else ("ok" if self.ok else "failed") + + +def claude_cli_available() -> bool: + return shutil.which("claude") is not None + + +def run_plugin(verb: PluginVerb, qualified_id: str, *, scope: Scope) -> ActionResult: + cmd = ["claude", "plugin", verb, qualified_id, "--scope", scope] + return _run(cmd, verb=verb, target=qualified_id, scope=scope) + + +def run_marketplace_update(name: str | None) -> ActionResult: + """Refresh one marketplace (or all if `name` is None).""" + cmd = ["claude", "plugin", "marketplace", "update"] + if name is not None: + cmd.append(name) + return _run(cmd, verb="marketplace update", target=name or "(all)", scope=None) + + +def _run( + cmd: list[str], *, verb: str, target: str, scope: str | None +) -> ActionResult: + start = time.monotonic() + try: + proc = subprocess.run( # noqa: S603 - cmd is built from a fixed allowlist + cmd, + capture_output=True, + text=True, + timeout=_TIMEOUT_SECONDS, + check=False, + ) + elapsed_ms = int((time.monotonic() - start) * 1000) + return ActionResult( + ok=proc.returncode == 0, + verb=verb, + target=target, + scope=scope, + returncode=proc.returncode, + stdout=proc.stdout, + stderr=proc.stderr, + elapsed_ms=elapsed_ms, + ) + except subprocess.TimeoutExpired as exc: + elapsed_ms = int((time.monotonic() - start) * 1000) + partial = exc.stdout + if isinstance(partial, bytes): + partial = partial.decode("utf-8", errors="replace") + return ActionResult( + ok=False, + verb=verb, + target=target, + scope=scope, + returncode=-1, + stdout=partial or "", + stderr=f"timed out after {_TIMEOUT_SECONDS}s", + elapsed_ms=elapsed_ms, + ) + except FileNotFoundError: + elapsed_ms = int((time.monotonic() - start) * 1000) + return ActionResult( + ok=False, + verb=verb, + target=target, + scope=scope, + returncode=-1, + stdout="", + stderr="`claude` CLI not found on PATH", + elapsed_ms=elapsed_ms, + ) diff --git a/src/agentpeek/cli.py b/src/agentpeek/cli.py index c5fea10..6f04e94 100644 --- a/src/agentpeek/cli.py +++ b/src/agentpeek/cli.py @@ -40,6 +40,12 @@ def main(argv: list[str] | None = None) -> int: default="WARNING", choices=["DEBUG", "INFO", "WARNING", "ERROR"], ) + parser.add_argument( + "--actions", + action="store_true", + help="Enable write actions (update/enable/disable/uninstall plugins, " + "refresh marketplaces) via the `claude plugin` CLI.", + ) args = parser.parse_args(argv) configure_logging(args.log_level) @@ -55,5 +61,9 @@ def main(argv: list[str] | None = None) -> int: # TUI into module-load time when only the CLI surface is exercised. from agentpeek.tui.app import AgentViewApp # noqa: PLC0415 - AgentViewApp(scan_root=args.root, source_name=args.source).run() + AgentViewApp( + scan_root=args.root, + source_name=args.source, + actions=args.actions, + ).run() return 0 diff --git a/src/agentpeek/tui/app.py b/src/agentpeek/tui/app.py index 06d6421..8372421 100644 --- a/src/agentpeek/tui/app.py +++ b/src/agentpeek/tui/app.py @@ -18,10 +18,17 @@ class AgentViewApp(App[None]): ] TITLE = "agentpeek" - def __init__(self, *, scan_root: Path | None, source_name: str | None) -> None: + def __init__( + self, + *, + scan_root: Path | None, + source_name: str | None, + actions: bool = False, + ) -> None: super().__init__() self._scan_root = scan_root self._source_name = source_name + self._actions = actions def rescan(self) -> ScanReport: """Re-run the scanner with the cached args and update the subtitle. @@ -38,4 +45,10 @@ def rescan(self) -> ScanReport: def on_mount(self) -> None: report = self.rescan() - self.push_screen(MainScreen(report, explicit_root=self._scan_root is not None)) + self.push_screen( + MainScreen( + report, + explicit_root=self._scan_root is not None, + actions=self._actions, + ) + ) diff --git a/src/agentpeek/tui/screens/action_result.py b/src/agentpeek/tui/screens/action_result.py new file mode 100644 index 0000000..919c1bc --- /dev/null +++ b/src/agentpeek/tui/screens/action_result.py @@ -0,0 +1,62 @@ +from typing import ClassVar + +from textual.app import ComposeResult +from textual.binding import Binding, BindingType +from textual.containers import Container, VerticalScroll +from textual.content import Content +from textual.screen import ModalScreen +from textual.widgets import Static + +from agentpeek.actions.runner import ActionResult + + +class ActionResultModal(ModalScreen[None]): + """Surface the raw stdout/stderr of a `claude plugin ...` call. + + Used for failures (and for successes when output is non-trivial). + Successful one-line outcomes use `self.notify(...)` instead. + """ + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("q,escape,enter", "dismiss_modal", "Close"), + ] + + def __init__(self, result: ActionResult) -> None: + super().__init__() + self._result = result + + def compose(self) -> ComposeResult: + r = self._result + header_color = "$success" if r.ok else "$error" + verdict = "OK" if r.ok else f"FAILED (exit {r.returncode})" + scope_part = f" --scope {r.scope}" if r.scope else "" + with Container(id="action-result-card"): + yield Static( + Content.assemble( + (f"{verdict} ", f"bold {header_color}"), + (f"{r.verb} {r.target}{scope_part}", "$text-muted"), + ), + id="action-result-title", + ) + yield Static( + Content.assemble( + ("elapsed ", "$text-muted"), + (f"{r.elapsed_ms} ms", "bold"), + ), + id="action-result-meta", + ) + with VerticalScroll(id="action-result-body"): + if r.stdout.strip(): + yield Static("stdout", classes="action-result-section-title") + yield Static(r.stdout.rstrip(), classes="action-result-stream") + if r.stderr.strip(): + yield Static("stderr", classes="action-result-section-title") + yield Static(r.stderr.rstrip(), classes="action-result-stream") + if not r.stdout.strip() and not r.stderr.strip(): + yield Static( + "(no output)", + classes="action-result-stream", + ) + + def action_dismiss_modal(self) -> None: + self.dismiss() diff --git a/src/agentpeek/tui/screens/confirm.py b/src/agentpeek/tui/screens/confirm.py new file mode 100644 index 0000000..5d9f1fe --- /dev/null +++ b/src/agentpeek/tui/screens/confirm.py @@ -0,0 +1,38 @@ +from typing import ClassVar + +from textual.app import ComposeResult +from textual.binding import Binding, BindingType +from textual.containers import Container, Horizontal +from textual.screen import ModalScreen +from textual.widgets import Button, Static + + +class ConfirmModal(ModalScreen[bool]): + """Yes/no modal. `push_screen_wait()` returns True on confirm, False on cancel.""" + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("y", "confirm", "Yes", show=False), + Binding("n,escape,q", "cancel", "No", show=False), + ] + + def __init__(self, message: str, *, title: str = "Confirm") -> None: + super().__init__() + self._message = message + self._title = title + + def compose(self) -> ComposeResult: + with Container(id="confirm-card"): + yield Static(self._title, id="confirm-title") + yield Static(self._message, id="confirm-body") + with Horizontal(id="confirm-buttons"): + yield Button("Yes (y)", id="confirm-yes", variant="warning") + yield Button("No (n)", id="confirm-no") + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.dismiss(event.button.id == "confirm-yes") + + def action_confirm(self) -> None: + self.dismiss(True) + + def action_cancel(self) -> None: + self.dismiss(False) diff --git a/src/agentpeek/tui/screens/main.py b/src/agentpeek/tui/screens/main.py index a6af565..1b06460 100644 --- a/src/agentpeek/tui/screens/main.py +++ b/src/agentpeek/tui/screens/main.py @@ -3,6 +3,7 @@ from pathlib import Path from typing import TYPE_CHECKING, ClassVar, cast +from textual import work from textual.app import ComposeResult from textual.binding import Binding, BindingType from textual.containers import Container, Horizontal, Vertical, VerticalScroll @@ -19,7 +20,8 @@ Static, ) -from agentpeek.models import PluginAgent, PluginSkill, ScanReport +from agentpeek.actions.runner import ActionResult, PluginVerb, Scope, run_plugin +from agentpeek.models import Plugin, PluginAgent, PluginSkill, ScanReport from agentpeek.tui.render import ( CATEGORIES, item_body, @@ -29,7 +31,9 @@ scope_summary, sidebar_count, ) +from agentpeek.tui.screens.action_result import ActionResultModal from agentpeek.tui.screens.agent_detail import AgentDetailModal +from agentpeek.tui.screens.confirm import ConfirmModal from agentpeek.tui.screens.help import HelpScreen from agentpeek.tui.screens.skill_detail import SkillDetailModal @@ -45,6 +49,9 @@ class MainScreen(Screen[None]): Binding("o", "open", "Open"), Binding("y", "yank", "Yank path"), Binding("b", "yank_body", "Yank body"), + Binding("u", "update_plugin", "Update plugin"), + Binding("t", "toggle_plugin", "Toggle enabled"), + Binding("x", "uninstall_plugin", "Uninstall plugin"), Binding("slash", "focus_filter", "Filter"), Binding("question_mark", "help", "Help"), Binding("escape", "clear_filter", show=False), @@ -54,10 +61,17 @@ class MainScreen(Screen[None]): selected_index: reactive[int] = reactive(-1, init=False) filter_text: reactive[str] = reactive("", init=False) - def __init__(self, report: ScanReport, *, explicit_root: bool = False) -> None: + def __init__( + self, + report: ScanReport, + *, + explicit_root: bool = False, + actions: bool = False, + ) -> None: super().__init__() self._report = report self._explicit_root = explicit_root + self._actions_enabled = actions # Per-category cursor memory so switching tabs preserves position. self._category_state: dict[str, int] = {} @@ -350,3 +364,108 @@ def _current_item_label(self) -> str | None: if payload is not None: return label.plain return None + + # --- write actions (claude plugin CLI) ---------------------------------- + + def _current_plugin_and_scope(self) -> tuple[Plugin, Scope] | None: + """Resolve current row to (Plugin, scope) if it's a plugin row. + + Returns None and notifies the user otherwise. Used by all per-plugin + action handlers. + """ + if self.selected_category != "plugins": + self.notify( + "Plugin actions only apply in the Plugins category", + severity="warning", + timeout=2, + ) + return None + items = items_for_report(self._report, self.selected_category) + idx = self.selected_index + if not 0 <= idx < len(items): + self.notify("No plugin selected", severity="warning", timeout=2) + return None + _label, payload, scope = items[idx] + if not isinstance(payload, Plugin): + self.notify("No plugin selected", severity="warning", timeout=2) + return None + if scope not in ("user", "project", "local"): + self.notify(f"Unsupported scope: {scope}", severity="error", timeout=2) + return None + return payload, cast("Scope", scope) + + def _require_actions(self) -> bool: + if not self._actions_enabled: + self.notify( + "Write actions disabled — relaunch with --actions", + severity="warning", + timeout=3, + ) + return False + return True + + def action_update_plugin(self) -> None: + if not self._require_actions(): + return + pair = self._current_plugin_and_scope() + if pair is None: + return + plugin, scope = pair + self.notify(f"Updating {plugin.qualified_id}…", timeout=2) + self._run_plugin_action("update", plugin.qualified_id, scope) + + def action_toggle_plugin(self) -> None: + if not self._require_actions(): + return + pair = self._current_plugin_and_scope() + if pair is None: + return + plugin, scope = pair + verb: PluginVerb = "disable" if plugin.enabled else "enable" + self.notify(f"{verb.title()} {plugin.qualified_id}…", timeout=2) + self._run_plugin_action(verb, plugin.qualified_id, scope) + + def action_uninstall_plugin(self) -> None: + if not self._require_actions(): + return + pair = self._current_plugin_and_scope() + if pair is None: + return + plugin, scope = pair + qid = plugin.qualified_id + + def _after_confirm(ok: bool | None) -> None: + if not ok: + return + self.notify(f"Uninstalling {qid}…", timeout=2) + self._run_plugin_action("uninstall", qid, scope) + + self.app.push_screen( # pyright: ignore[reportUnknownMemberType] + ConfirmModal( + f"Uninstall {qid} from {scope} scope?\n\nThis removes the plugin " + "cache and registry entry. Run again to reinstall.", + title="Uninstall plugin?", + ), + _after_confirm, + ) + + @work(exclusive=True, group="plugin-action") + async def _run_plugin_action( + self, verb: PluginVerb, qid: str, scope: Scope + ) -> None: + import asyncio # noqa: PLC0415 + + result = await asyncio.to_thread(run_plugin, verb, qid, scope=scope) + await self._after_plugin_action(result) + + async def _after_plugin_action(self, result: ActionResult) -> None: + await self.action_refresh() + if result.ok: + self.notify( + f"{result.verb} {result.target}: {result.message}", + timeout=4, + ) + else: + self.app.push_screen( # pyright: ignore[reportUnknownMemberType] + ActionResultModal(result) + ) diff --git a/src/agentpeek/tui/styles/app.tcss b/src/agentpeek/tui/styles/app.tcss index 549fd08..a711aad 100644 --- a/src/agentpeek/tui/styles/app.tcss +++ b/src/agentpeek/tui/styles/app.tcss @@ -281,3 +281,76 @@ AgentDetailModal #agent-modal-body { height: 1fr; background: $surface; } + +/* Confirm modal — yes/no prompt used before destructive actions. */ +ConfirmModal { + align: center middle; + background: $background 60%; +} + +ConfirmModal > #confirm-card { + width: 60; + height: auto; + max-height: 16; + background: $surface; + border: round $warning; + padding: 1 2; +} + +ConfirmModal #confirm-title { + text-style: bold; + color: $warning; + padding-bottom: 1; +} + +ConfirmModal #confirm-body { + height: auto; + padding-bottom: 1; +} + +ConfirmModal #confirm-buttons { + height: auto; + align-horizontal: right; +} + +ConfirmModal Button { + margin-left: 1; +} + +/* Action result modal — surfaces stdout/stderr from a claude-plugin call. */ +ActionResultModal { + align: center middle; + background: $background 60%; +} + +ActionResultModal > #action-result-card { + width: 80%; + height: 70%; + background: $surface; + border: round $accent; + padding: 1 2; +} + +ActionResultModal #action-result-title { + padding-bottom: 0; +} + +ActionResultModal #action-result-meta { + color: $text-muted; + padding-bottom: 1; +} + +ActionResultModal #action-result-body { + height: 1fr; + background: $surface; +} + +ActionResultModal .action-result-section-title { + text-style: bold; + color: $accent; + padding-top: 1; +} + +ActionResultModal .action-result-stream { + background: $surface; +} From cbcd802367c876744331b418965f8247656ef5b9 Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Fri, 22 May 2026 16:11:33 +0530 Subject: [PATCH 2/6] feat(actions): bulk update-all with progress in app subtitle - binding U; serial worker reports per-plugin progress via app.sub_title - confirm modal when N > 5 - subtitle restored in try/finally; final summary toast at end Textual 8.x has a documented ModalScreen dismissal-deadlock for modals containing scrollable progress content (issues #5596, #5008, #4552), so the subtitle approach gives live progress without the deadlock surface. --- src/agentpeek/tui/screens/main.py | 98 +++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/src/agentpeek/tui/screens/main.py b/src/agentpeek/tui/screens/main.py index 1b06460..96cfe73 100644 --- a/src/agentpeek/tui/screens/main.py +++ b/src/agentpeek/tui/screens/main.py @@ -50,6 +50,7 @@ class MainScreen(Screen[None]): Binding("y", "yank", "Yank path"), Binding("b", "yank_body", "Yank body"), Binding("u", "update_plugin", "Update plugin"), + Binding("U", "update_all_plugins", "Update all"), Binding("t", "toggle_plugin", "Toggle enabled"), Binding("x", "uninstall_plugin", "Uninstall plugin"), Binding("slash", "focus_filter", "Filter"), @@ -469,3 +470,100 @@ async def _after_plugin_action(self, result: ActionResult) -> None: self.app.push_screen( # pyright: ignore[reportUnknownMemberType] ActionResultModal(result) ) + + def action_update_all_plugins(self) -> None: + if not self._require_actions(): + return + if self.selected_category != "plugins": + self.notify( + "Bulk update only applies in the Plugins category", + severity="warning", + timeout=2, + ) + return + pairs = self._all_plugin_targets() + if not pairs: + self.notify("No plugins to update", severity="warning", timeout=2) + return + + def _after_confirm(ok: bool | None) -> None: + if not ok: + return + self.notify( + f"Updating {len(pairs)} plugins serially " + "(~5-7s each, no progress display)…", + timeout=4, + ) + self._run_bulk_update(pairs) + + if len(pairs) > 5: + self.app.push_screen( # pyright: ignore[reportUnknownMemberType] + ConfirmModal( + f"Update {len(pairs)} plugins serially?\n" + "Each call can take a few seconds (git pull).", + title="Update all plugins?", + ), + _after_confirm, + ) + else: + _after_confirm(True) + + def _all_plugin_targets(self) -> list[tuple[str, Scope]]: + """Collect (qualified_id, scope) pairs for every installed plugin row. + + Mirrors what the user sees in the Plugins category: group headers + (payload=None) are skipped; per-scope rows are emitted in display + order. The same plugin appearing in both user and project scope + yields two pairs — that matches what `claude plugin update --scope` + needs (it's scope-specific). + """ + pairs: list[tuple[str, Scope]] = [] + for _label, payload, scope in items_for_report(self._report, "plugins"): + if not isinstance(payload, Plugin): + continue + if scope not in ("user", "project", "local"): + continue + pairs.append((payload.qualified_id, cast("Scope", scope))) + return pairs + + @work(exclusive=True, group="bulk-update") + async def _run_bulk_update(self, pairs: list[tuple[str, Scope]]) -> None: + """Serial bulk-update worker. + + Progress is surfaced via `app.sub_title` (the header subtitle). + Textual 8.x's `ModalScreen` has a dismissal-deadlock for the + modal-with-scrollable-content shape we'd otherwise want here + (issues #5596, #5008, #4552); the subtitle gives live progress + without any modal lifecycle. + """ + import asyncio # noqa: PLC0415 + + original_subtitle = self.app.sub_title + ok_count = 0 + fail_count = 0 + failures: list[str] = [] + total = len(pairs) + try: + for i, (qid, scope) in enumerate(pairs, start=1): + self.app.sub_title = ( + f"bulk-update ({i}/{total}) {qid} [{scope}]…" + ) + result = await asyncio.to_thread( + run_plugin, "update", qid, scope=scope + ) + if result.ok: + ok_count += 1 + else: + fail_count += 1 + failures.append(f"{qid}: {result.message}") + finally: + self.app.sub_title = original_subtitle + summary = ( + f"Bulk update done — {ok_count} ok, {fail_count} failed. " + "Press r to refresh, restart Claude Code to apply." + ) + if failures: + summary += "\n\nFailures:\n" + "\n".join(failures[:5]) + if len(failures) > 5: + summary += f"\n…(+{len(failures) - 5} more)" + self.notify(summary, timeout=15, severity="warning" if failures else "information") From 277ccf74a406dd82f34ffd041b3bed9c077ed279 Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Fri, 22 May 2026 16:12:29 +0530 Subject: [PATCH 3/6] feat(actions): marketplace refresh - binding M: current plugin's marketplace, or all if not on a Plugin row --- src/agentpeek/tui/screens/main.py | 36 ++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/agentpeek/tui/screens/main.py b/src/agentpeek/tui/screens/main.py index 96cfe73..ed74277 100644 --- a/src/agentpeek/tui/screens/main.py +++ b/src/agentpeek/tui/screens/main.py @@ -20,7 +20,13 @@ Static, ) -from agentpeek.actions.runner import ActionResult, PluginVerb, Scope, run_plugin +from agentpeek.actions.runner import ( + ActionResult, + PluginVerb, + Scope, + run_marketplace_update, + run_plugin, +) from agentpeek.models import Plugin, PluginAgent, PluginSkill, ScanReport from agentpeek.tui.render import ( CATEGORIES, @@ -53,6 +59,7 @@ class MainScreen(Screen[None]): Binding("U", "update_all_plugins", "Update all"), Binding("t", "toggle_plugin", "Toggle enabled"), Binding("x", "uninstall_plugin", "Uninstall plugin"), + Binding("M", "refresh_marketplace", "Refresh market(s)"), Binding("slash", "focus_filter", "Filter"), Binding("question_mark", "help", "Help"), Binding("escape", "clear_filter", show=False), @@ -567,3 +574,30 @@ async def _run_bulk_update(self, pairs: list[tuple[str, Scope]]) -> None: if len(failures) > 5: summary += f"\n…(+{len(failures) - 5} more)" self.notify(summary, timeout=15, severity="warning" if failures else "information") + + def action_refresh_marketplace(self) -> None: + """Refresh the current plugin's marketplace, or all marketplaces. + + When the cursor is on a plugin row, refresh only that plugin's + marketplace. Otherwise refresh every configured marketplace. + """ + if not self._require_actions(): + return + name: str | None = None + if self.selected_category == "plugins": + items = items_for_report(self._report, "plugins") + idx = self.selected_index + if 0 <= idx < len(items): + _label, payload, _scope = items[idx] + if isinstance(payload, Plugin) and payload.marketplace: + name = payload.marketplace + target = name or "all marketplaces" + self.notify(f"Refreshing {target}…", timeout=2) + self._run_marketplace_refresh(name) + + @work(exclusive=True, group="marketplace-refresh") + async def _run_marketplace_refresh(self, name: str | None) -> None: + import asyncio # noqa: PLC0415 + + result = await asyncio.to_thread(run_marketplace_update, name) + await self._after_plugin_action(result) From f3cb6fb414603ffe6faa36b6162e78688a88a14e Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Fri, 22 May 2026 16:13:26 +0530 Subject: [PATCH 4/6] fix(actions): unify worker group, single-notify - all action workers share group="claude-plugin-write" (serialises claude state writes across single / bulk / marketplace) - _rescan_and_rebuild(notify=False) avoids duplicate toast after each action --- src/agentpeek/tui/screens/main.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/agentpeek/tui/screens/main.py b/src/agentpeek/tui/screens/main.py index ed74277..9c2a17b 100644 --- a/src/agentpeek/tui/screens/main.py +++ b/src/agentpeek/tui/screens/main.py @@ -341,6 +341,15 @@ def action_open(self) -> None: async def action_refresh(self) -> None: """Re-scan disk and rebuild every list/count/detail in place.""" + await self._rescan_and_rebuild(notify=True) + + async def _rescan_and_rebuild(self, *, notify: bool) -> None: + """Shared core for `action_refresh` and post-action refreshes. + + `notify=False` is what the write-action handlers use, so the + success/failure toast for the action isn't shadowed by a generic + "Rescanned" message. + """ prev_label = self._current_item_label() app = cast("AgentViewApp", self.app) # pyright: ignore[reportUnknownMemberType] self._report = app.rescan() @@ -355,14 +364,15 @@ async def action_refresh(self) -> None: await self.watch_selected_category(self.selected_category) if prev_label is not None: current = self._current_item_label() - if current != prev_label: + if current != prev_label and notify: self.notify( f"Selection reset (was: {prev_label})", severity="warning", timeout=3, ) return - self.notify("Rescanned", timeout=2) + if notify: + self.notify("Rescanned", timeout=2) def _current_item_label(self) -> str | None: items = items_for_report(self._report, self.selected_category) @@ -457,7 +467,7 @@ def _after_confirm(ok: bool | None) -> None: _after_confirm, ) - @work(exclusive=True, group="plugin-action") + @work(exclusive=True, group="claude-plugin-write") async def _run_plugin_action( self, verb: PluginVerb, qid: str, scope: Scope ) -> None: @@ -467,7 +477,7 @@ async def _run_plugin_action( await self._after_plugin_action(result) async def _after_plugin_action(self, result: ActionResult) -> None: - await self.action_refresh() + await self._rescan_and_rebuild(notify=False) if result.ok: self.notify( f"{result.verb} {result.target}: {result.message}", @@ -533,7 +543,7 @@ def _all_plugin_targets(self) -> list[tuple[str, Scope]]: pairs.append((payload.qualified_id, cast("Scope", scope))) return pairs - @work(exclusive=True, group="bulk-update") + @work(exclusive=True, group="claude-plugin-write") async def _run_bulk_update(self, pairs: list[tuple[str, Scope]]) -> None: """Serial bulk-update worker. @@ -595,7 +605,7 @@ def action_refresh_marketplace(self) -> None: self.notify(f"Refreshing {target}…", timeout=2) self._run_marketplace_refresh(name) - @work(exclusive=True, group="marketplace-refresh") + @work(exclusive=True, group="claude-plugin-write") async def _run_marketplace_refresh(self, name: str | None) -> None: import asyncio # noqa: PLC0415 From 7153bf46754274bd4c9131cbc26851baff766f7a Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Fri, 22 May 2026 16:14:47 +0530 Subject: [PATCH 5/6] fix(actions): scope resolution + clearer no-install error - --scope picked from installation.scope, not bucket label (managed/local installs need their actual scope) - Scope literal extended to include "managed" (claude plugin update accepts it) - clearer error when no install matches current cwd ([O]-marker case lists the dirs to cd into) --- src/agentpeek/actions/runner.py | 6 +- src/agentpeek/tui/screens/main.py | 106 +++++++++++++++++++++++++----- 2 files changed, 96 insertions(+), 16 deletions(-) diff --git a/src/agentpeek/actions/runner.py b/src/agentpeek/actions/runner.py index ccb6963..490c338 100644 --- a/src/agentpeek/actions/runner.py +++ b/src/agentpeek/actions/runner.py @@ -12,7 +12,11 @@ from typing import Literal PluginVerb = Literal["enable", "disable", "update", "uninstall", "install"] -Scope = Literal["user", "project", "local"] +# `managed` is accepted by `claude plugin update` for enterprise-pushed +# plugins; enable/disable/uninstall do not accept it. If a managed-scope +# plugin is targeted with a non-update verb, the CLI surfaces a clear error +# in the result modal. +Scope = Literal["user", "project", "local", "managed"] _TIMEOUT_SECONDS = 60 diff --git a/src/agentpeek/tui/screens/main.py b/src/agentpeek/tui/screens/main.py index 9c2a17b..b72f129 100644 --- a/src/agentpeek/tui/screens/main.py +++ b/src/agentpeek/tui/screens/main.py @@ -27,7 +27,13 @@ run_marketplace_update, run_plugin, ) -from agentpeek.models import Plugin, PluginAgent, PluginSkill, ScanReport +from agentpeek.models import ( + Plugin, + PluginAgent, + PluginInstallation, + PluginSkill, + ScanReport, +) from agentpeek.tui.render import ( CATEGORIES, item_body, @@ -385,11 +391,70 @@ def _current_item_label(self) -> str | None: # --- write actions (claude plugin CLI) ---------------------------------- + def _no_install_message(self, plugin: Plugin, bucket: str) -> str: + """Error text for the 'no install for this cwd' case. + + The user bucket aggregates truly-user-global installs AND plugins + whose installations all live in other projects (the `[O]` marker + case). Both render as user-bucket rows, but only the first is + actionable from the current cwd. When the lookup fails, surface + the actual install dirs so the user knows where to `cd` to. + """ + qid = plugin.qualified_id + other_dirs = [ + str(i.project_path) + for i in plugin.installations + if i.project_path is not None + ] + if bucket == "user" and other_dirs: + shown = ", ".join(other_dirs[:2]) + ( + f", …(+{len(other_dirs) - 2} more)" if len(other_dirs) > 2 else "" + ) + return ( + f"{qid}: no install for this cwd. " + f"All installs are in other projects: {shown}" + ) + return f"{qid}: no installation matches this {bucket}-scope row" + + def _installation_for_bucket( + self, plugin: Plugin, bucket: str + ) -> PluginInstallation | None: + """Pick the installation that backs a plugin row in a given bucket. + + Buckets in the TUI are "user" / "project" — set by the scanner's + `redistribute_plugins`, which moves any installation whose + `project_path` matches the current project root into the project + bucket regardless of whether the installation's own `scope` is + `project` or `local`. So the bucket label and the `--scope` flag + are not interchangeable; we need the installation's true scope. + """ + if bucket == "user": + for inst in plugin.installations: + if inst.project_path is None: + return inst + return None + if bucket == "project": + root = self._report.project_root + if root is None: + return None + # `report.project_root` is the `.claude/` dir; `inst.project_path` + # is the project dir that contains it. Mirror scanner.py:157. + project_dir = root.parent.resolve() + for inst in plugin.installations: + if ( + inst.project_path is not None + and inst.project_path.resolve() == project_dir + ): + return inst + return None + return None + def _current_plugin_and_scope(self) -> tuple[Plugin, Scope] | None: """Resolve current row to (Plugin, scope) if it's a plugin row. - Returns None and notifies the user otherwise. Used by all per-plugin - action handlers. + `scope` is the *installation's* scope (user/project/local), looked up + from `Plugin.installations` — not the bucket label from the row. See + `_installation_for_bucket` for why those can differ. """ if self.selected_category != "plugins": self.notify( @@ -403,14 +468,26 @@ def _current_plugin_and_scope(self) -> tuple[Plugin, Scope] | None: if not 0 <= idx < len(items): self.notify("No plugin selected", severity="warning", timeout=2) return None - _label, payload, scope = items[idx] + _label, payload, bucket = items[idx] if not isinstance(payload, Plugin): self.notify("No plugin selected", severity="warning", timeout=2) return None - if scope not in ("user", "project", "local"): - self.notify(f"Unsupported scope: {scope}", severity="error", timeout=2) + inst = self._installation_for_bucket(payload, bucket) + if inst is None: + self.notify( + self._no_install_message(payload, bucket), + severity="error", + timeout=6, + ) + return None + if inst.scope not in ("user", "project", "local", "managed"): + self.notify( + f"Unsupported install scope: {inst.scope}", + severity="error", + timeout=3, + ) return None - return payload, cast("Scope", scope) + return payload, cast("Scope", inst.scope) def _require_actions(self) -> bool: if not self._actions_enabled: @@ -528,19 +605,18 @@ def _after_confirm(ok: bool | None) -> None: def _all_plugin_targets(self) -> list[tuple[str, Scope]]: """Collect (qualified_id, scope) pairs for every installed plugin row. - Mirrors what the user sees in the Plugins category: group headers - (payload=None) are skipped; per-scope rows are emitted in display - order. The same plugin appearing in both user and project scope - yields two pairs — that matches what `claude plugin update --scope` - needs (it's scope-specific). + Mirrors what the user sees in the Plugins category. The `scope` + field is the installation's true scope (user/project/local), not + the bucket label — see `_installation_for_bucket` for why. """ pairs: list[tuple[str, Scope]] = [] - for _label, payload, scope in items_for_report(self._report, "plugins"): + for _label, payload, bucket in items_for_report(self._report, "plugins"): if not isinstance(payload, Plugin): continue - if scope not in ("user", "project", "local"): + inst = self._installation_for_bucket(payload, bucket) + if inst is None or inst.scope not in ("user", "project", "local", "managed"): continue - pairs.append((payload.qualified_id, cast("Scope", scope))) + pairs.append((payload.qualified_id, cast("Scope", inst.scope))) return pairs @work(exclusive=True, group="claude-plugin-write") From a80eff387c2141e200fd4eedcd36a362d2aafc0a Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Fri, 22 May 2026 16:15:07 +0530 Subject: [PATCH 6/6] feat(actions): on by default, --read-only opt-out - previously gated behind --actions; flipped after Phase 0 verified every verb works headlessly --- src/agentpeek/cli.py | 9 +++++---- src/agentpeek/tui/app.py | 2 +- src/agentpeek/tui/screens/main.py | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/agentpeek/cli.py b/src/agentpeek/cli.py index 6f04e94..f7f1ad2 100644 --- a/src/agentpeek/cli.py +++ b/src/agentpeek/cli.py @@ -41,10 +41,11 @@ def main(argv: list[str] | None = None) -> int: choices=["DEBUG", "INFO", "WARNING", "ERROR"], ) parser.add_argument( - "--actions", + "--read-only", action="store_true", - help="Enable write actions (update/enable/disable/uninstall plugins, " - "refresh marketplaces) via the `claude plugin` CLI.", + help="Disable write actions. By default the TUI exposes update / " + "enable / disable / uninstall / marketplace-refresh bindings that " + "shell out to the `claude plugin` CLI.", ) args = parser.parse_args(argv) configure_logging(args.log_level) @@ -64,6 +65,6 @@ def main(argv: list[str] | None = None) -> int: AgentViewApp( scan_root=args.root, source_name=args.source, - actions=args.actions, + actions=not args.read_only, ).run() return 0 diff --git a/src/agentpeek/tui/app.py b/src/agentpeek/tui/app.py index 8372421..6415919 100644 --- a/src/agentpeek/tui/app.py +++ b/src/agentpeek/tui/app.py @@ -23,7 +23,7 @@ def __init__( *, scan_root: Path | None, source_name: str | None, - actions: bool = False, + actions: bool = True, ) -> None: super().__init__() self._scan_root = scan_root diff --git a/src/agentpeek/tui/screens/main.py b/src/agentpeek/tui/screens/main.py index b72f129..59c3c10 100644 --- a/src/agentpeek/tui/screens/main.py +++ b/src/agentpeek/tui/screens/main.py @@ -80,7 +80,7 @@ def __init__( report: ScanReport, *, explicit_root: bool = False, - actions: bool = False, + actions: bool = True, ) -> None: super().__init__() self._report = report @@ -492,7 +492,7 @@ def _current_plugin_and_scope(self) -> tuple[Plugin, Scope] | None: def _require_actions(self) -> bool: if not self._actions_enabled: self.notify( - "Write actions disabled — relaunch with --actions", + "Read-only mode — relaunch without --read-only to enable actions", severity="warning", timeout=3, )