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..490c338 --- /dev/null +++ b/src/agentpeek/actions/runner.py @@ -0,0 +1,108 @@ +"""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"] +# `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 + + +@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..f7f1ad2 100644 --- a/src/agentpeek/cli.py +++ b/src/agentpeek/cli.py @@ -40,6 +40,13 @@ def main(argv: list[str] | None = None) -> int: default="WARNING", choices=["DEBUG", "INFO", "WARNING", "ERROR"], ) + parser.add_argument( + "--read-only", + action="store_true", + 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) @@ -55,5 +62,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=not args.read_only, + ).run() return 0 diff --git a/src/agentpeek/tui/app.py b/src/agentpeek/tui/app.py index 06d6421..6415919 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 = True, + ) -> 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..59c3c10 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,20 @@ Static, ) -from agentpeek.models import PluginAgent, PluginSkill, ScanReport +from agentpeek.actions.runner import ( + ActionResult, + PluginVerb, + Scope, + run_marketplace_update, + run_plugin, +) +from agentpeek.models import ( + Plugin, + PluginAgent, + PluginInstallation, + PluginSkill, + ScanReport, +) from agentpeek.tui.render import ( CATEGORIES, item_body, @@ -29,7 +43,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 +61,11 @@ 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("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), @@ -54,10 +75,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 = True, + ) -> 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] = {} @@ -319,6 +347,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() @@ -333,14 +370,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) @@ -350,3 +388,302 @@ def _current_item_label(self) -> str | None: if payload is not None: return label.plain return 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. + + `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( + "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, bucket = items[idx] + if not isinstance(payload, Plugin): + self.notify("No plugin selected", severity="warning", timeout=2) + return None + 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", inst.scope) + + def _require_actions(self) -> bool: + if not self._actions_enabled: + self.notify( + "Read-only mode — relaunch without --read-only to enable 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="claude-plugin-write") + 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._rescan_and_rebuild(notify=False) + if result.ok: + self.notify( + f"{result.verb} {result.target}: {result.message}", + timeout=4, + ) + else: + 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. 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, bucket in items_for_report(self._report, "plugins"): + if not isinstance(payload, Plugin): + continue + 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", inst.scope))) + return pairs + + @work(exclusive=True, group="claude-plugin-write") + 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") + + 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="claude-plugin-write") + 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) 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; +}