From dc6d0253a7e9373b599ef24c1a00309c1229a536 Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Wed, 20 May 2026 14:45:46 +0530 Subject: [PATCH 01/17] feat: read plugins/blocklist.json and surface blocked state - Plugin.blocked + blocked_reason fields populated from ~/.claude/plugins/blocklist.json. - Health warning when a plugin is enabled AND blocklisted (Claude Code refuses to load it regardless of enabledPlugins). - Plugin items list shows [BLOCKED] badge; detail pane shows reason. --- src/agentpeek/health.py | 17 ++++++++++++++++ src/agentpeek/models.py | 5 +++++ src/agentpeek/sources/local.py | 34 +++++++++++++++++++++++++++++++ src/agentpeek/tui/render.py | 18 ++++++++++++++--- tests/test_health.py | 37 ++++++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 3 deletions(-) diff --git a/src/agentpeek/health.py b/src/agentpeek/health.py index 16aa498..b016a34 100644 --- a/src/agentpeek/health.py +++ b/src/agentpeek/health.py @@ -78,6 +78,23 @@ def is_for_this_scope(inst: object) -> bool: issues: list[ScanWarning] = [] for plugin in result.plugins: local_installs = [i for i in plugin.installations if is_for_this_scope(i)] + if plugin.blocked and plugin.enabled: + issues.append( + ScanWarning( + path=None, + category="plugin_state", + reason=( + f"plugin {plugin.qualified_id} is enabled but appears " + f"in plugins/blocklist.json" + + ( + f" (reason: {plugin.blocked_reason})" + if plugin.blocked_reason + else "" + ) + + " — Claude Code will refuse to load it" + ), + ) + ) if plugin.enabled and not plugin.installations: issues.append( ScanWarning( diff --git a/src/agentpeek/models.py b/src/agentpeek/models.py index 3681444..2759635 100644 --- a/src/agentpeek/models.py +++ b/src/agentpeek/models.py @@ -116,6 +116,11 @@ class Plugin: commands: tuple[SlashCommand, ...] = () hooks: tuple[HookSpec, ...] = () mcps: tuple["MCPServer", ...] = () + # `~/.claude/plugins/blocklist.json` entries override any + # `enabled=True` claim — Claude Code refuses to load a blocklisted + # plugin regardless of settings. + blocked: bool = False + blocked_reason: str | None = None MemoryKind = Literal["claude_md", "memory_index", "memory_entry"] diff --git a/src/agentpeek/sources/local.py b/src/agentpeek/sources/local.py index 84af0e8..aeea350 100644 --- a/src/agentpeek/sources/local.py +++ b/src/agentpeek/sources/local.py @@ -271,6 +271,7 @@ def _scan_plugins( return () enabled_union = _collect_enabled_plugins(root, warnings) + blocklist = _load_blocklist(root, warnings) results: list[Plugin] = [] for qid, installs_obj in cast("dict[str, object]", plugins_obj).items(): @@ -306,6 +307,8 @@ def _scan_plugins( commands=contents.commands if contents else (), hooks=contents.hooks if contents else (), mcps=contents.mcps if contents else (), + blocked=qid_str in blocklist, + blocked_reason=blocklist.get(qid_str), ) ) return tuple(results) @@ -490,6 +493,37 @@ def _safe_load_json_dict( return {} +def _load_blocklist(root: Path, warnings: list[ScanWarning]) -> dict[str, str]: + """Return {qualified_id: reason} for plugins in `plugins/blocklist.json`. + + Claude Code refuses to load any plugin in this file regardless of + enabledPlugins. The blocklist only lives at user scope; at project + scope the file is absent and we return {}. + """ + path = root / "plugins" / "blocklist.json" + if not path.exists(): + return {} + data, warning = load_json(path, category="plugins") + if warning is not None: + warnings.append(warning) + if not isinstance(data, dict): + return {} + entries = cast("dict[str, object]", data).get("plugins") + if not isinstance(entries, list): + return {} + out: dict[str, str] = {} + for entry in cast("list[object]", entries): + if not isinstance(entry, dict): + continue + entry_d = cast("dict[str, object]", entry) + qid = as_str(entry_d.get("plugin")) + if not qid: + continue + reason = as_str(entry_d.get("reason")) or as_str(entry_d.get("text")) or "" + out[qid] = reason + return out + + def _collect_enabled_plugins(root: Path, warnings: list[ScanWarning]) -> set[str]: # All three files contribute to enabledPlugins. `/plugin install` writes # to settings.local.json; enterprise/remote config lands in diff --git a/src/agentpeek/tui/render.py b/src/agentpeek/tui/render.py index 8c44a74..94819d7 100644 --- a/src/agentpeek/tui/render.py +++ b/src/agentpeek/tui/render.py @@ -774,13 +774,15 @@ def _plugins_items(plugins: tuple[Plugin, ...]) -> list[tuple[Content, object]]: state_text, state_color = ( ("enabled", COLOR_SUCCESS) if p.enabled else ("disabled", COLOR_MUTED) ) - label = Content.assemble( + parts: list[Content | str | tuple[str, str]] = [ (p.qualified_id, "bold"), " ", (state_text, state_color), (f" [{len(p.installations)} install(s)]", COLOR_MUTED), - ) - items.append((label, p)) + ] + if p.blocked: + parts.extend((" ", ("[BLOCKED]", f"bold {COLOR_ERROR}"))) + items.append((Content.assemble(*parts), p)) return items @@ -800,6 +802,16 @@ def _plugins_detail_widgets(payload: object) -> list[Widget]: ("ID", payload.id), ("Marketplace", payload.marketplace or _muted_cell("(none)")), ] + if payload.blocked: + rows.append( + ( + "Blocklisted", + Text( + payload.blocked_reason or "(no reason given)", + style="bold red", + ), + ) + ) widgets.append(_card("Properties", Static(_kv_table(rows)))) if payload.manifest is not None: widgets.append(_card("Manifest", Static(_manifest_table(payload.manifest)))) diff --git a/tests/test_health.py b/tests/test_health.py index 9b8dc4e..555d0cc 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -168,6 +168,43 @@ def test_mixed_user_and_other_project_installs_still_warns( assert len(not_enabled) == 1 +def test_blocklisted_plugin_enabled_fires_warning(tmp_path: Path) -> None: + installed = tmp_path / "alpha" + installed.mkdir() + p = Plugin( + id="alpha", + marketplace="m", + qualified_id="alpha@m", + enabled=True, + installations=(_install(installed),), + blocked=True, + blocked_reason="security", + ) + issues = _check_plugin_state(_result((p,), root=tmp_path / ".claude")) + blocklisted = [i for i in issues if "blocklist" in i.reason] + assert len(blocklisted) == 1 + assert "security" in blocklisted[0].reason + + +def test_blocklisted_but_disabled_no_warning(tmp_path: Path) -> None: + installed = tmp_path / "alpha" + installed.mkdir() + p = Plugin( + id="alpha", + marketplace="m", + qualified_id="alpha@m", + enabled=False, + installations=(_install(installed),), + blocked=True, + blocked_reason="security", + ) + issues = _check_plugin_state(_result((p,), root=tmp_path / ".claude")) + # Not enabled → blocklist doesn't matter to Claude Code runtime; + # don't warn. (The "not enabled" warning may still fire for the + # installation existing, but no "blocklist" warning.) + assert not any("blocklist" in i.reason for i in issues) + + def test_healthy_plugin_no_warnings(tmp_path: Path) -> None: installed = tmp_path / "ok-plugin" installed.mkdir() From 3b78fb5abbc09012aec04fa4a660d0d429af92d6 Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Wed, 20 May 2026 14:47:15 +0530 Subject: [PATCH 02/17] feat: parse and surface statusLine + skipAutoPermissionPrompt --- src/agentpeek/models.py | 4 ++++ src/agentpeek/sources/local.py | 32 ++++++++++++++++++++++++++++++++ src/agentpeek/tui/render.py | 19 +++++++++++++++++++ tests/test_render.py | 2 ++ 4 files changed, 57 insertions(+) diff --git a/src/agentpeek/models.py b/src/agentpeek/models.py index 2759635..420da91 100644 --- a/src/agentpeek/models.py +++ b/src/agentpeek/models.py @@ -20,6 +20,10 @@ class SettingsBundle: editor_mode: str | None effort_level: str | None output_style: str | None + # `statusLine` is either {"type": "command", "command": "..."} or a + # legacy string; normalized to dict shape (or None when absent). + status_line: Mapping[str, str] | None + skip_auto_permission_prompt: bool env: Mapping[str, str] permissions_allow: tuple[str, ...] permissions_deny: tuple[str, ...] diff --git a/src/agentpeek/sources/local.py b/src/agentpeek/sources/local.py index aeea350..da7de66 100644 --- a/src/agentpeek/sources/local.py +++ b/src/agentpeek/sources/local.py @@ -2,6 +2,7 @@ import json import re import shlex +from collections.abc import Mapping from pathlib import Path from types import MappingProxyType from typing import ClassVar, cast @@ -127,6 +128,17 @@ def _scan_settings( sum(1 for _ in hooks_dir.iterdir()) if hooks_dir.is_dir() else 0 ) + # statusLine can be a dict ({"type": "command", "command": "..."}) + # or, legacy, a bare string. Local overrides user. + status_line = _parse_status_line(local_data.get("statusLine")) or ( + _parse_status_line(user_data.get("statusLine")) + ) + + skip_prompt = bool( + local_data.get("skipAutoPermissionPrompt") + or user_data.get("skipAutoPermissionPrompt") + ) + return SettingsBundle( user_settings_path=user_path if user_path.exists() else None, local_settings_path=local_path if local_path.exists() else None, @@ -135,6 +147,8 @@ def _scan_settings( editor_mode=as_str(user_data.get("editorMode")), effort_level=as_str(user_data.get("effortLevel")), output_style=as_str(user_data.get("outputStyle")), + status_line=status_line, + skip_auto_permission_prompt=skip_prompt, env=MappingProxyType(env), permissions_allow=permissions_allow, permissions_deny=permissions_deny, @@ -493,6 +507,24 @@ def _safe_load_json_dict( return {} +def _parse_status_line(v: object) -> Mapping[str, str] | None: + """Normalize statusLine to a {type, command, ...} dict, or None. + + Accepts dict-shaped configs (modern) and bare strings (legacy form + where the entire value is the shell command). + """ + if isinstance(v, str): + return MappingProxyType({"type": "command", "command": v}) + if isinstance(v, dict): + d = { + str(k): str(val) + for k, val in cast("dict[str, object]", v).items() + if isinstance(val, str) + } + return MappingProxyType(d) if d else None + return None + + def _load_blocklist(root: Path, warnings: list[ScanWarning]) -> dict[str, str]: """Return {qualified_id: reason} for plugins in `plugins/blocklist.json`. diff --git a/src/agentpeek/tui/render.py b/src/agentpeek/tui/render.py index 94819d7..c09fc0a 100644 --- a/src/agentpeek/tui/render.py +++ b/src/agentpeek/tui/render.py @@ -571,6 +571,25 @@ def _settings_items(s: SettingsBundle | None) -> list[tuple[Content, object]]: _scalar_label("Output style", s.output_style), _SettingsItem("Output style", "scalar", s.output_style), ), + ( + _scalar_label( + "Skip auto permission prompt", + "yes" if s.skip_auto_permission_prompt else None, + ), + _SettingsItem( + "Skip auto permission prompt", + "scalar", + "yes" if s.skip_auto_permission_prompt else None, + ), + ), + ( + _count_item_label( + "Status line", 1 if s.status_line else 0 + ), + _SettingsItem( + "Status line", "dict", dict(s.status_line) if s.status_line else {} + ), + ), ( _count_item_label("Env vars", len(s.env)), _SettingsItem("Env vars", "dict", dict(s.env)), diff --git a/tests/test_render.py b/tests/test_render.py index 5391053..e8ef21f 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -273,6 +273,8 @@ def test_item_body_settings_returns_none() -> None: editor_mode=None, effort_level=None, output_style=None, + status_line=None, + skip_auto_permission_prompt=False, env={}, permissions_allow=(), permissions_deny=(), From 5901335395e3a587b0fb4f68e71a11eb63eaabf2 Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Wed, 20 May 2026 14:48:23 +0530 Subject: [PATCH 03/17] feat: resolve plugin marketplace source from known_marketplaces.json --- src/agentpeek/models.py | 6 ++++ src/agentpeek/sources/local.py | 59 ++++++++++++++++++++++++++++++++++ src/agentpeek/tui/render.py | 12 +++++++ 3 files changed, 77 insertions(+) diff --git a/src/agentpeek/models.py b/src/agentpeek/models.py index 420da91..ba1e19e 100644 --- a/src/agentpeek/models.py +++ b/src/agentpeek/models.py @@ -1,6 +1,7 @@ from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path +from types import MappingProxyType from typing import Literal @@ -125,6 +126,11 @@ class Plugin: # plugin regardless of settings. blocked: bool = False blocked_reason: str | None = None + # Where this plugin's marketplace lives — resolved from + # `~/.claude/plugins/known_marketplaces.json` and the + # `extraKnownMarketplaces` keys of user + remote settings. Empty + # mapping when the marketplace isn't found in any registry. + marketplace_source: Mapping[str, str] = MappingProxyType({}) MemoryKind = Literal["claude_md", "memory_index", "memory_entry"] diff --git a/src/agentpeek/sources/local.py b/src/agentpeek/sources/local.py index da7de66..4a513fd 100644 --- a/src/agentpeek/sources/local.py +++ b/src/agentpeek/sources/local.py @@ -286,6 +286,7 @@ def _scan_plugins( enabled_union = _collect_enabled_plugins(root, warnings) blocklist = _load_blocklist(root, warnings) + marketplaces = _load_marketplaces(root, warnings) results: list[Plugin] = [] for qid, installs_obj in cast("dict[str, object]", plugins_obj).items(): @@ -323,6 +324,9 @@ def _scan_plugins( mcps=contents.mcps if contents else (), blocked=qid_str in blocklist, blocked_reason=blocklist.get(qid_str), + marketplace_source=MappingProxyType( + marketplaces.get(marketplace, {}) + ), ) ) return tuple(results) @@ -507,6 +511,61 @@ def _safe_load_json_dict( return {} +def _load_marketplaces( + root: Path, warnings: list[ScanWarning] +) -> dict[str, dict[str, str]]: + """Return {marketplace_name: source_dict} from all known registries. + + Sources, in increasing precedence (later wins on conflict): + 1. `/plugins/known_marketplaces.json` — Claude Code's + authoritative cache; its `source` block is what's cloned. + 2. `extraKnownMarketplaces` in settings.json — user-declared. + 3. `extraKnownMarketplaces` in remote-settings.json — enterprise. + + Each source_dict carries flattened `source.*` keys plus optional + `installLocation` / `lastUpdated` from the registry file. + """ + out: dict[str, dict[str, str]] = {} + + registry_path = root / "plugins" / "known_marketplaces.json" + if registry_path.is_file(): + data, warning = load_json(registry_path, category="plugins") + if warning is not None: + warnings.append(warning) + if isinstance(data, dict): + for name, entry in cast("dict[str, object]", data).items(): + if isinstance(entry, dict): + out[str(name)] = _flatten_marketplace( + cast("dict[str, object]", entry) + ) + + for filename in ("settings.json", "remote-settings.json"): + data = _safe_load_json_dict(root / filename, "plugins", warnings) + extra = as_dict(data.get("extraKnownMarketplaces")) + if not extra: + continue + for name, entry in extra.items(): + if isinstance(entry, dict): + flat = _flatten_marketplace(cast("dict[str, object]", entry)) + out.setdefault(str(name), {}).update(flat) + + return out + + +def _flatten_marketplace(entry: dict[str, object]) -> dict[str, str]: + flat: dict[str, str] = {} + src = entry.get("source") + if isinstance(src, dict): + for k, v in cast("dict[str, object]", src).items(): + if isinstance(v, str): + flat[str(k)] = v + for k in ("installLocation", "lastUpdated"): + v = entry.get(k) + if isinstance(v, str): + flat[k] = v + return flat + + def _parse_status_line(v: object) -> Mapping[str, str] | None: """Normalize statusLine to a {type, command, ...} dict, or None. diff --git a/src/agentpeek/tui/render.py b/src/agentpeek/tui/render.py index c09fc0a..70a2656 100644 --- a/src/agentpeek/tui/render.py +++ b/src/agentpeek/tui/render.py @@ -821,6 +821,18 @@ def _plugins_detail_widgets(payload: object) -> list[Widget]: ("ID", payload.id), ("Marketplace", payload.marketplace or _muted_cell("(none)")), ] + src = payload.marketplace_source + if src: + source_kind = src.get("source", "") + repo = src.get("repo", "") + ref = src.get("ref", "") + if source_kind and repo: + source_line = f"{source_kind}:{repo}" + (f"@{ref}" if ref else "") + rows.append(("Source", source_line)) + if src.get("installLocation"): + rows.append(("Cached at", src["installLocation"])) + if src.get("lastUpdated"): + rows.append(("Marketplace updated", src["lastUpdated"])) if payload.blocked: rows.append( ( From c4ebe65146304d6786eb74058927842f5b736380 Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Wed, 20 May 2026 14:48:59 +0530 Subject: [PATCH 04/17] fix: merge permissions across user + local settings (don't override) --- src/agentpeek/sources/local.py | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/agentpeek/sources/local.py b/src/agentpeek/sources/local.py index 4a513fd..2acf94c 100644 --- a/src/agentpeek/sources/local.py +++ b/src/agentpeek/sources/local.py @@ -94,16 +94,23 @@ def _scan_settings( env_local = as_str_dict(local_data.get("env")) env = {**env_user, **env_local} - permissions = as_dict(local_data.get("permissions")) or as_dict( - user_data.get("permissions") + # Per Claude Code docs, permission rules MERGE across scopes + # rather than override. Union allow/deny/ask from both files, + # preserving the order user-then-local with dedup. + user_perms = as_dict(user_data.get("permissions")) or {} + local_perms = as_dict(local_data.get("permissions")) or {} + permissions_allow = _union_str_tuple( + as_str_tuple(user_perms.get("allow")), + as_str_tuple(local_perms.get("allow")), ) - permissions_allow = as_str_tuple( - permissions.get("allow") if permissions else None + permissions_deny = _union_str_tuple( + as_str_tuple(user_perms.get("deny")), + as_str_tuple(local_perms.get("deny")), ) - permissions_deny = as_str_tuple( - permissions.get("deny") if permissions else None + permissions_ask = _union_str_tuple( + as_str_tuple(user_perms.get("ask")), + as_str_tuple(local_perms.get("ask")), ) - permissions_ask = as_str_tuple(permissions.get("ask") if permissions else None) user_plugins = as_dict(user_data.get("enabledPlugins")) or {} local_plugins = as_dict(local_data.get("enabledPlugins")) or {} @@ -511,6 +518,16 @@ def _safe_load_json_dict( return {} +def _union_str_tuple(*lists: tuple[str, ...]) -> tuple[str, ...]: + """Order-preserving union of string sequences.""" + seen: list[str] = [] + for lst in lists: + for item in lst: + if item not in seen: + seen.append(item) + return tuple(seen) + + def _load_marketplaces( root: Path, warnings: list[ScanWarning] ) -> dict[str, dict[str, str]]: From 0c5e579b31073e510c21046d20a851071be7f829 Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Wed, 20 May 2026 14:49:57 +0530 Subject: [PATCH 05/17] fix: MCP env column header doesn't lie about partial redaction --- src/agentpeek/tui/render.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/agentpeek/tui/render.py b/src/agentpeek/tui/render.py index 70a2656..992c8ce 100644 --- a/src/agentpeek/tui/render.py +++ b/src/agentpeek/tui/render.py @@ -1124,14 +1124,22 @@ def _mcp_detail_widgets(payload: object) -> list[Widget]: if not payload.env: widgets.append(_card(title, Static("(no env vars)", classes="muted"))) else: + # `redact()` only masks values of length ≥ 8 — short values like + # "true" / "on" pass through. Surface that contract honestly + # instead of labelling the column "redacted" when some rows + # aren't. env_rows: tuple[tuple[str, ...], ...] = tuple( (k, redact(payload.env[k])) for k in sorted(payload.env.keys()) ) widgets.append( _card( title, - _PendingDataTable( - columns=("Key", "Value (redacted)"), rows=env_rows + Container( + _PendingDataTable(columns=("Key", "Value"), rows=env_rows), + Static( + "(values ≥ 8 chars are masked)", + classes="muted", + ), ), ) ) From 17c81b1a5269f72bc20846df46716bdad72dd7b2 Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Wed, 20 May 2026 14:51:09 +0530 Subject: [PATCH 06/17] feat: surface OAuth MCPs awaiting auth (mcp-needs-auth-cache.json) --- src/agentpeek/models.py | 5 +++++ src/agentpeek/sources/local.py | 28 ++++++++++++++++++++++++++++ src/agentpeek/tui/render.py | 22 +++++++++++++++++----- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/agentpeek/models.py b/src/agentpeek/models.py index ba1e19e..e289386 100644 --- a/src/agentpeek/models.py +++ b/src/agentpeek/models.py @@ -166,6 +166,11 @@ class MCPServer: args: tuple[str, ...] env: Mapping[str, str] source_plugin: str | None = None + # True when this MCP server appears in + # `~/.claude/mcp-needs-auth-cache.json` — Claude Code remembers it + # but the OAuth/auth flow hasn't completed, so it won't actually + # connect until the user finishes auth. + auth_pending: bool = False @dataclass(frozen=True, slots=True) diff --git a/src/agentpeek/sources/local.py b/src/agentpeek/sources/local.py index 2acf94c..421b498 100644 --- a/src/agentpeek/sources/local.py +++ b/src/agentpeek/sources/local.py @@ -410,6 +410,7 @@ def _scan_mcp( self, root: Path, warnings: list[ScanWarning] ) -> tuple[MCPServer, ...]: results: list[MCPServer] = [] + seen_names: set[str] = set() for filename in ("settings.json", "remote-settings.json"): path = root / filename if not path.exists(): @@ -443,6 +444,33 @@ def _scan_mcp( env=MappingProxyType(env_obj), ) ) + seen_names.add(str(srv_name)) + + # Surface OAuth-based MCP servers Claude Code registered into + # `mcp-needs-auth-cache.json`. These never appear in + # `mcpServers` because they're configured through the + # claude.ai UI, but they're real entries the user might want + # to know about — and they won't actually work until auth + # completes. + auth_cache_path = root / "mcp-needs-auth-cache.json" + if auth_cache_path.is_file(): + data, warning = load_json(auth_cache_path, category="mcp") + if warning is not None: + warnings.append(warning) + if isinstance(data, dict): + for name in cast("dict[str, object]", data): + if name in seen_names: + continue + results.append( + MCPServer( + name=str(name), + source_path=auth_cache_path, + command=None, + args=(), + env=MappingProxyType({}), + auth_pending=True, + ) + ) return tuple(results) diff --git a/src/agentpeek/tui/render.py b/src/agentpeek/tui/render.py index 992c8ce..825a59a 100644 --- a/src/agentpeek/tui/render.py +++ b/src/agentpeek/tui/render.py @@ -1096,11 +1096,13 @@ def _keybindings_detail_widgets(payload: object) -> list[Widget]: def _mcp_items(mcp: tuple[MCPServer, ...]) -> list[tuple[Content, object]]: items: list[tuple[Content, object]] = [] for m in mcp: - label = Content.assemble( + parts: list[Content | str | tuple[str, str]] = [ (m.name, "bold"), - (f" ({m.command or '?'})", COLOR_MUTED), - ) - items.append((_plug_prefix(label, m.source_plugin), m)) + (f" ({m.command or 'oauth'})", COLOR_MUTED), + ] + if m.auth_pending: + parts.extend((" ", ("[auth pending]", f"bold {COLOR_WARNING}"))) + items.append((_plug_prefix(Content.assemble(*parts), m.source_plugin), m)) return items @@ -1116,9 +1118,19 @@ def _mcp_detail_widgets(payload: object) -> list[Widget]: args = " ".join(payload.args) if payload.args else "(none)" rows: list[tuple[str, RenderableType]] = [ ("Source", str(payload.source_path)), - ("Command", payload.command or _muted_cell("(none)")), + ("Command", payload.command or _muted_cell("(none — OAuth-based)")), ("Args", args), ] + if payload.auth_pending: + rows.append( + ( + "Auth", + Text( + "pending — Claude Code will not connect until OAuth completes", + style="bold yellow", + ), + ) + ) widgets.append(_card("Properties", Static(_kv_table(rows)))) title = f"Environment ({len(payload.env)})" if not payload.env: From 544f2cb69032a85789c866f85de7e5a8f2c04a04 Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Wed, 20 May 2026 14:58:24 +0530 Subject: [PATCH 07/17] feat: surface policy-limits.json restrictions in settings detail --- src/agentpeek/models.py | 3 +++ src/agentpeek/sources/local.py | 28 ++++++++++++++++++++++++++++ src/agentpeek/tui/render.py | 8 ++++++++ tests/test_render.py | 1 + 4 files changed, 40 insertions(+) diff --git a/src/agentpeek/models.py b/src/agentpeek/models.py index e289386..02b6bdc 100644 --- a/src/agentpeek/models.py +++ b/src/agentpeek/models.py @@ -25,6 +25,9 @@ class SettingsBundle: # legacy string; normalized to dict shape (or None when absent). status_line: Mapping[str, str] | None skip_auto_permission_prompt: bool + # `policy-limits.json` from this scope, flattened to + # {restriction_name: allowed_bool}. Empty when the file is absent. + policy_restrictions: Mapping[str, bool] env: Mapping[str, str] permissions_allow: tuple[str, ...] permissions_deny: tuple[str, ...] diff --git a/src/agentpeek/sources/local.py b/src/agentpeek/sources/local.py index 421b498..e959e0d 100644 --- a/src/agentpeek/sources/local.py +++ b/src/agentpeek/sources/local.py @@ -146,6 +146,8 @@ def _scan_settings( or user_data.get("skipAutoPermissionPrompt") ) + policy_restrictions = _load_policy_limits(root, warnings) + return SettingsBundle( user_settings_path=user_path if user_path.exists() else None, local_settings_path=local_path if local_path.exists() else None, @@ -161,6 +163,7 @@ def _scan_settings( permissions_deny=permissions_deny, permissions_ask=permissions_ask, enabled_plugins=enabled_plugins, + policy_restrictions=MappingProxyType(policy_restrictions), hooks_raw=MappingProxyType(hooks_raw), hooks_dir_files=hooks_dir_files, ) @@ -611,6 +614,31 @@ def _flatten_marketplace(entry: dict[str, object]) -> dict[str, str]: return flat +def _load_policy_limits(root: Path, warnings: list[ScanWarning]) -> dict[str, bool]: + """Return {restriction_name: allowed} from `/policy-limits.json`. + + Empty dict when the file is absent (typical for project scope). + """ + path = root / "policy-limits.json" + if not path.is_file(): + return {} + data, warning = load_json(path, category="settings") + if warning is not None: + warnings.append(warning) + if not isinstance(data, dict): + return {} + restrictions = cast("dict[str, object]", data).get("restrictions") + if not isinstance(restrictions, dict): + return {} + out: dict[str, bool] = {} + for name, entry in cast("dict[str, object]", restrictions).items(): + if isinstance(entry, dict): + allowed = cast("dict[str, object]", entry).get("allowed") + if isinstance(allowed, bool): + out[str(name)] = allowed + return out + + def _parse_status_line(v: object) -> Mapping[str, str] | None: """Normalize statusLine to a {type, command, ...} dict, or None. diff --git a/src/agentpeek/tui/render.py b/src/agentpeek/tui/render.py index 825a59a..82065ec 100644 --- a/src/agentpeek/tui/render.py +++ b/src/agentpeek/tui/render.py @@ -610,6 +610,14 @@ def _settings_items(s: SettingsBundle | None) -> list[tuple[Content, object]]: _count_item_label("Enabled plugins", len(s.enabled_plugins)), _SettingsItem("Enabled plugins", "list", list(s.enabled_plugins)), ), + ( + _count_item_label("Policy restrictions", len(s.policy_restrictions)), + _SettingsItem( + "Policy restrictions", + "dict", + {k: str(v) for k, v in s.policy_restrictions.items()}, + ), + ), ( _count_item_label("Hooks dir files", s.hooks_dir_files), _SettingsItem("Hooks dir files", "scalar", str(s.hooks_dir_files)), diff --git a/tests/test_render.py b/tests/test_render.py index e8ef21f..4ede4d6 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -280,6 +280,7 @@ def test_item_body_settings_returns_none() -> None: permissions_deny=(), permissions_ask=(), enabled_plugins=(), + policy_restrictions={}, hooks_raw={}, hooks_dir_files=0, ) From 6f19352af12cd28131c9fc4b1d34a6488f6b30cf Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Wed, 20 May 2026 14:59:16 +0530 Subject: [PATCH 08/17] feat: surface companyAnnouncements + spinnerTipsOverride from remote-settings --- src/agentpeek/models.py | 4 ++++ src/agentpeek/sources/local.py | 8 ++++++++ src/agentpeek/tui/render.py | 10 ++++++++++ tests/test_render.py | 2 ++ 4 files changed, 24 insertions(+) diff --git a/src/agentpeek/models.py b/src/agentpeek/models.py index 02b6bdc..96e4e72 100644 --- a/src/agentpeek/models.py +++ b/src/agentpeek/models.py @@ -28,6 +28,10 @@ class SettingsBundle: # `policy-limits.json` from this scope, flattened to # {restriction_name: allowed_bool}. Empty when the file is absent. policy_restrictions: Mapping[str, bool] + # Enterprise-pushed remote-settings.json extras. Surfaced so users + # can see what their managed config injected into the session. + company_announcements: tuple[str, ...] + spinner_tips: tuple[str, ...] env: Mapping[str, str] permissions_allow: tuple[str, ...] permissions_deny: tuple[str, ...] diff --git a/src/agentpeek/sources/local.py b/src/agentpeek/sources/local.py index e959e0d..60dbbbc 100644 --- a/src/agentpeek/sources/local.py +++ b/src/agentpeek/sources/local.py @@ -83,9 +83,11 @@ def _scan_settings( ) -> SettingsBundle | None: user_path = root / "settings.json" local_path = root / "settings.local.json" + remote_path = root / "remote-settings.json" user_data = _safe_load_json_dict(user_path, "settings", warnings) local_data = _safe_load_json_dict(local_path, "settings", warnings) + remote_data = _safe_load_json_dict(remote_path, "settings", warnings) if not user_path.exists() and not local_path.exists(): return None @@ -148,6 +150,10 @@ def _scan_settings( policy_restrictions = _load_policy_limits(root, warnings) + company_announcements = as_str_tuple(remote_data.get("companyAnnouncements")) + spinner_override = as_dict(remote_data.get("spinnerTipsOverride")) or {} + spinner_tips = as_str_tuple(spinner_override.get("tips")) + return SettingsBundle( user_settings_path=user_path if user_path.exists() else None, local_settings_path=local_path if local_path.exists() else None, @@ -164,6 +170,8 @@ def _scan_settings( permissions_ask=permissions_ask, enabled_plugins=enabled_plugins, policy_restrictions=MappingProxyType(policy_restrictions), + company_announcements=company_announcements, + spinner_tips=spinner_tips, hooks_raw=MappingProxyType(hooks_raw), hooks_dir_files=hooks_dir_files, ) diff --git a/src/agentpeek/tui/render.py b/src/agentpeek/tui/render.py index 82065ec..5134548 100644 --- a/src/agentpeek/tui/render.py +++ b/src/agentpeek/tui/render.py @@ -618,6 +618,16 @@ def _settings_items(s: SettingsBundle | None) -> list[tuple[Content, object]]: {k: str(v) for k, v in s.policy_restrictions.items()}, ), ), + ( + _count_item_label("Company announcements", len(s.company_announcements)), + _SettingsItem( + "Company announcements", "list", list(s.company_announcements) + ), + ), + ( + _count_item_label("Spinner tips override", len(s.spinner_tips)), + _SettingsItem("Spinner tips override", "list", list(s.spinner_tips)), + ), ( _count_item_label("Hooks dir files", s.hooks_dir_files), _SettingsItem("Hooks dir files", "scalar", str(s.hooks_dir_files)), diff --git a/tests/test_render.py b/tests/test_render.py index 4ede4d6..c14b263 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -281,6 +281,8 @@ def test_item_body_settings_returns_none() -> None: permissions_ask=(), enabled_plugins=(), policy_restrictions={}, + company_announcements=(), + spinner_tips=(), hooks_raw={}, hooks_dir_files=0, ) From 552becdab012bcb21d376e359f703c25b58e87a9 Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Wed, 20 May 2026 15:00:05 +0530 Subject: [PATCH 09/17] fix: use [...] for preview truncation so it can't be confused with a literal char --- src/agentpeek/tui/render.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/agentpeek/tui/render.py b/src/agentpeek/tui/render.py index 5134548..65ef28b 100644 --- a/src/agentpeek/tui/render.py +++ b/src/agentpeek/tui/render.py @@ -695,14 +695,22 @@ def _list_body(lst: list[object]) -> Widget: # --- Hooks -------------------------------------------------------------- +_PREVIEW_TRUNC_MARK = " […]" + + +def _truncate_preview(text: str, limit: int) -> str: + if len(text) <= limit: + return text + return text[:limit] + _PREVIEW_TRUNC_MARK + + def _hooks_items(hooks: tuple[HookSpec, ...]) -> list[tuple[Content, object]]: items: list[tuple[Content, object]] = [] for h in hooks: - preview = h.command[:40] + ("…" if len(h.command) > 40 else "") label = Content.assemble( (h.event, "bold"), (f" [{h.matcher or '*'}] ", COLOR_MUTED), - preview, + _truncate_preview(h.command, 40), ) items.append((_plug_prefix(label, h.source_plugin), h)) return items @@ -940,7 +948,7 @@ def _plugins_detail_widgets(payload: object) -> list[Widget]: ( h.event, h.matcher or "*", - h.command[:60] + ("…" if len(h.command) > 60 else ""), + _truncate_preview(h.command, 60), ) for h in payload.hooks ), @@ -1186,7 +1194,7 @@ def _warnings_items( for w in warnings: sev = warning_severity(w.category) color = _SEVERITY_COLOR[sev] - preview = w.reason[:60] + ("…" if len(w.reason) > 60 else "") + preview = _truncate_preview(w.reason, 60) label = Content.assemble( (f"[{w.category}] ", f"bold {color}"), preview, From 7c778116abf589cbce48f3a8ed3f975ac4f86271 Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Wed, 20 May 2026 15:00:36 +0530 Subject: [PATCH 10/17] fix: warn when plugin defines the same MCP server in plugin.json + .mcp.json --- src/agentpeek/parsers/plugin_contents.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/agentpeek/parsers/plugin_contents.py b/src/agentpeek/parsers/plugin_contents.py index 98d1320..c781297 100644 --- a/src/agentpeek/parsers/plugin_contents.py +++ b/src/agentpeek/parsers/plugin_contents.py @@ -342,8 +342,23 @@ def _parse_mcps( else () ) env_obj = as_str_dict(srv_d.get("env")) - seen[str(srv_name)] = MCPServer( - name=str(srv_name), + name_str = str(srv_name) + prior = seen.get(name_str) + if prior is not None and prior.source_path != source_path: + warnings.append( + ScanWarning( + path=source_path, + category="plugin_mcp", + reason=( + f"MCP server {name_str!r} for plugin " + f"{qualified_id} is defined in both " + f"{prior.source_path} and {source_path}; " + f"{source_path.name} takes precedence" + ), + ) + ) + seen[name_str] = MCPServer( + name=name_str, source_path=source_path, command=as_str(srv_d.get("command")), args=args_tuple, From b6b594b559d7da22f5a8862ca01e40b8a29f831e Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Wed, 20 May 2026 15:01:05 +0530 Subject: [PATCH 11/17] fix: warn on malformed keybindings.json shape instead of silently dropping --- src/agentpeek/sources/local.py | 82 ++++++++++++++++++++++++++-------- 1 file changed, 63 insertions(+), 19 deletions(-) diff --git a/src/agentpeek/sources/local.py b/src/agentpeek/sources/local.py index 60dbbbc..cccb2c4 100644 --- a/src/agentpeek/sources/local.py +++ b/src/agentpeek/sources/local.py @@ -396,25 +396,69 @@ def _scan_keybindings( if warning is not None: warnings.append(warning) entries: list[KeybindingEntry] = [] - if isinstance(data, dict): - data_d = cast("dict[str, object]", data) - bindings_outer = data_d.get("bindings") - if isinstance(bindings_outer, list): - for ctx_obj in cast("list[object]", bindings_outer): - if not isinstance(ctx_obj, dict): - continue - ctx_d = cast("dict[str, object]", ctx_obj) - context = as_str(ctx_d.get("context")) or "" - inner = ctx_d.get("bindings") - if isinstance(inner, dict): - for key, action in cast("dict[str, object]", inner).items(): - entries.append( - KeybindingEntry( - context=context, - key=str(key), - action=str(action), - ) - ) + if not isinstance(data, dict): + warnings.append( + ScanWarning( + path=path, + category="keybindings", + reason="top-level value is not a JSON object", + ) + ) + return KeybindingsBundle(path=path, entries=()) + data_d = cast("dict[str, object]", data) + bindings_outer = data_d.get("bindings") + if bindings_outer is None: + return KeybindingsBundle(path=path, entries=()) + if not isinstance(bindings_outer, list): + warnings.append( + ScanWarning( + path=path, + category="keybindings", + reason=( + f"`bindings` is {type(bindings_outer).__name__}, " + "expected an array of contexts" + ), + ) + ) + return KeybindingsBundle(path=path, entries=()) + for i, ctx_obj in enumerate(cast("list[object]", bindings_outer)): + if not isinstance(ctx_obj, dict): + warnings.append( + ScanWarning( + path=path, + category="keybindings", + reason=( + f"`bindings[{i}]` is " + f"{type(ctx_obj).__name__}, expected an object" + ), + ) + ) + continue + ctx_d = cast("dict[str, object]", ctx_obj) + context = as_str(ctx_d.get("context")) or "" + inner = ctx_d.get("bindings") + if inner is None: + continue + if not isinstance(inner, dict): + warnings.append( + ScanWarning( + path=path, + category="keybindings", + reason=( + f"`bindings[{i}].bindings` is " + f"{type(inner).__name__}, expected an object" + ), + ) + ) + continue + for key, action in cast("dict[str, object]", inner).items(): + entries.append( + KeybindingEntry( + context=context, + key=str(key), + action=str(action), + ) + ) return KeybindingsBundle(path=path, entries=tuple(entries)) def _scan_mcp( From 5e517109770992347af40d7393b51ab604d6ad34 Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Wed, 20 May 2026 15:01:59 +0530 Subject: [PATCH 12/17] fix: enabledPlugins disagreement warning explains union semantics --- src/agentpeek/sources/local.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/agentpeek/sources/local.py b/src/agentpeek/sources/local.py index cccb2c4..8c57f05 100644 --- a/src/agentpeek/sources/local.py +++ b/src/agentpeek/sources/local.py @@ -751,20 +751,26 @@ def _collect_enabled_plugins(root: Path, warnings: list[ScanWarning]) -> set[str if ep is not None: maps[filename] = ep - # Warn when two source files disagree on the same QID. + # Note when two source files disagree on the same QID. Claude Code's + # observed runtime behavior unions enabledPlugins across these files + # (a plugin enabled in ANY file is loaded), so a disagreement isn't + # a misconfiguration — the warning text reflects that. filenames = list(maps) for i, a in enumerate(filenames): for b in filenames[i + 1 :]: for qid in set(maps[a]) & set(maps[b]): if bool(maps[a][qid]) != bool(maps[b][qid]): + effective = bool(maps[a][qid]) or bool(maps[b][qid]) warnings.append( ScanWarning( path=None, category="plugins", reason=( - f"enabledPlugins conflict for {qid}: " + f"plugin {qid} enabledPlugins value differs: " f"{a}={bool(maps[a][qid])}, " - f"{b}={bool(maps[b][qid])}" + f"{b}={bool(maps[b][qid])}; " + f"Claude Code unions across files, " + f"effective={effective}" ), ) ) From ebd42d89f2ad7dccbd8779079f7da20018e7c9cc Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Wed, 20 May 2026 15:09:58 +0530 Subject: [PATCH 13/17] fix: warn when frontmatter string fields are wrong-typed --- src/agentpeek/parsers/frontmatter_parser.py | 34 ++++++++++++++++++ src/agentpeek/parsers/plugin_contents.py | 38 +++++++++++++++++---- src/agentpeek/sources/local.py | 16 +++++++-- 3 files changed, 78 insertions(+), 10 deletions(-) diff --git a/src/agentpeek/parsers/frontmatter_parser.py b/src/agentpeek/parsers/frontmatter_parser.py index a916b1d..8f80159 100644 --- a/src/agentpeek/parsers/frontmatter_parser.py +++ b/src/agentpeek/parsers/frontmatter_parser.py @@ -15,6 +15,40 @@ class FrontmatterFile: body: str +def read_str_field( + metadata: Mapping[str, object], + key: str, + *, + path: Path, + category: str, + warnings: list[ScanWarning], +) -> str | None: + """Read a string-typed frontmatter field; warn if present but + not a string. + + Falling back to the directory/file name without warning hides + misconfiguration — the user typed `name: ["foo"]` and gets a + different name than they expect with no explanation. Returns + None when the key is absent or the value isn't a string. + """ + v = metadata.get(key) + if v is None: + return None + if isinstance(v, str): + return v + warnings.append( + ScanWarning( + path=path, + category=category, + reason=( + f"frontmatter `{key}` is {type(v).__name__}, " + "expected string; ignored" + ), + ) + ) + return None + + def load_frontmatter( path: Path, *, category: str ) -> tuple[FrontmatterFile | None, ScanWarning | None]: diff --git a/src/agentpeek/parsers/plugin_contents.py b/src/agentpeek/parsers/plugin_contents.py index c781297..264e5fd 100644 --- a/src/agentpeek/parsers/plugin_contents.py +++ b/src/agentpeek/parsers/plugin_contents.py @@ -32,6 +32,7 @@ ) from agentpeek.parsers import load_frontmatter, load_json from agentpeek.parsers.coerce import as_int, as_str, as_str_dict +from agentpeek.parsers.frontmatter_parser import read_str_field @dataclass(frozen=True, slots=True) @@ -145,11 +146,18 @@ def _parse_skills( warnings.append(warning) if file is None: continue + name = read_str_field( + file.metadata, "name", + path=skill_md, category="plugin_skill", warnings=warnings, + ) results.append( PluginSkill( path=skill_md, - name=as_str(file.metadata.get("name")) or sub.name, - description=as_str(file.metadata.get("description")), + name=name or sub.name, + description=read_str_field( + file.metadata, "description", + path=skill_md, category="plugin_skill", warnings=warnings, + ), body=file.body, source_plugin=qualified_id, ) @@ -169,11 +177,18 @@ def _parse_agents( warnings.append(warning) if file is None: continue + name = read_str_field( + file.metadata, "name", + path=md, category="plugin_agent", warnings=warnings, + ) results.append( PluginAgent( path=md, - name=as_str(file.metadata.get("name")) or md.stem, - description=as_str(file.metadata.get("description")), + name=name or md.stem, + description=read_str_field( + file.metadata, "description", + path=md, category="plugin_agent", warnings=warnings, + ), body=file.body, source_plugin=qualified_id, ) @@ -215,7 +230,10 @@ def _parse_commands( ) ) continue - allowed = as_str(file.metadata.get("allowed-tools")) + allowed = read_str_field( + file.metadata, "allowed-tools", + path=md, category="plugin_command", warnings=warnings, + ) allowed_tuple: tuple[str, ...] = ( tuple(s.strip() for s in allowed.split(",") if s.strip()) if allowed @@ -225,8 +243,14 @@ def _parse_commands( SlashCommand( path=md, name=name, - description=as_str(file.metadata.get("description")), - argument_hint=as_str(file.metadata.get("argument-hint")), + description=read_str_field( + file.metadata, "description", + path=md, category="plugin_command", warnings=warnings, + ), + argument_hint=read_str_field( + file.metadata, "argument-hint", + path=md, category="plugin_command", warnings=warnings, + ), allowed_tools=allowed_tuple, body=file.body, source_plugin=qualified_id, diff --git a/src/agentpeek/sources/local.py b/src/agentpeek/sources/local.py index 8c57f05..9cd0956 100644 --- a/src/agentpeek/sources/local.py +++ b/src/agentpeek/sources/local.py @@ -29,6 +29,7 @@ as_str_dict, as_str_tuple, ) +from agentpeek.parsers.frontmatter_parser import read_str_field from agentpeek.parsers.plugin_contents import parse_plugin_contents @@ -269,7 +270,10 @@ def _scan_commands( ) ) continue - allowed = as_str(file.metadata.get("allowed-tools")) + allowed = read_str_field( + file.metadata, "allowed-tools", + path=md_path, category="commands", warnings=warnings, + ) allowed_tuple: tuple[str, ...] = ( tuple(s.strip() for s in allowed.split(",") if s.strip()) if allowed @@ -279,8 +283,14 @@ def _scan_commands( SlashCommand( path=md_path, name=name, - description=as_str(file.metadata.get("description")), - argument_hint=as_str(file.metadata.get("argument-hint")), + description=read_str_field( + file.metadata, "description", + path=md_path, category="commands", warnings=warnings, + ), + argument_hint=read_str_field( + file.metadata, "argument-hint", + path=md_path, category="commands", warnings=warnings, + ), allowed_tools=allowed_tuple, body=file.body, ) From 3b85d945056f1140e217684b8d3fced9be431f4c Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Wed, 20 May 2026 15:10:13 +0530 Subject: [PATCH 14/17] feat: MCP detail shows source plugin explicitly --- src/agentpeek/tui/render.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/agentpeek/tui/render.py b/src/agentpeek/tui/render.py index 65ef28b..bad61ed 100644 --- a/src/agentpeek/tui/render.py +++ b/src/agentpeek/tui/render.py @@ -1144,9 +1144,15 @@ def _mcp_detail_widgets(payload: object) -> list[Widget]: args = " ".join(payload.args) if payload.args else "(none)" rows: list[tuple[str, RenderableType]] = [ ("Source", str(payload.source_path)), - ("Command", payload.command or _muted_cell("(none — OAuth-based)")), - ("Args", args), ] + if payload.source_plugin: + rows.append(("Source plugin", payload.source_plugin)) + rows.extend( + [ + ("Command", payload.command or _muted_cell("(none — OAuth-based)")), + ("Args", args), + ] + ) if payload.auth_pending: rows.append( ( From f7c424376ac06fadb2d8c7100ae4dfee6c8a70b4 Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Wed, 20 May 2026 15:11:18 +0530 Subject: [PATCH 15/17] feat: surface files under ~/.claude/local/ as settings detail row --- src/agentpeek/models.py | 5 +++++ src/agentpeek/sources/local.py | 24 ++++++++++++++++++++++++ src/agentpeek/tui/render.py | 4 ++++ tests/test_render.py | 1 + 4 files changed, 34 insertions(+) diff --git a/src/agentpeek/models.py b/src/agentpeek/models.py index 96e4e72..d2c4c02 100644 --- a/src/agentpeek/models.py +++ b/src/agentpeek/models.py @@ -32,6 +32,11 @@ class SettingsBundle: # can see what their managed config injected into the session. company_announcements: tuple[str, ...] spinner_tips: tuple[str, ...] + # Relative paths inside `/local/` — typically user-applied + # patches or scratch scripts that aren't Claude Code config but + # may shadow or modify the canonical hooks. Surfaced so the user + # can see what's there. + local_overrides: tuple[str, ...] env: Mapping[str, str] permissions_allow: tuple[str, ...] permissions_deny: tuple[str, ...] diff --git a/src/agentpeek/sources/local.py b/src/agentpeek/sources/local.py index 9cd0956..deb1ba8 100644 --- a/src/agentpeek/sources/local.py +++ b/src/agentpeek/sources/local.py @@ -155,6 +155,8 @@ def _scan_settings( spinner_override = as_dict(remote_data.get("spinnerTipsOverride")) or {} spinner_tips = as_str_tuple(spinner_override.get("tips")) + local_overrides = _scan_local_overrides(root) + return SettingsBundle( user_settings_path=user_path if user_path.exists() else None, local_settings_path=local_path if local_path.exists() else None, @@ -173,6 +175,7 @@ def _scan_settings( policy_restrictions=MappingProxyType(policy_restrictions), company_announcements=company_announcements, spinner_tips=spinner_tips, + local_overrides=local_overrides, hooks_raw=MappingProxyType(hooks_raw), hooks_dir_files=hooks_dir_files, ) @@ -676,6 +679,27 @@ def _flatten_marketplace(entry: dict[str, object]) -> dict[str, str]: return flat +def _scan_local_overrides(root: Path) -> tuple[str, ...]: + """Return relative paths of every file under `/local/`. + + Claude Code itself doesn't define a `local/` directory — it's a + user convention for staging patches or scratch scripts. We list + what's there so users see what's adjacent to their config; we + make no claim about what these files do. + """ + local_dir = root / "local" + if not local_dir.is_dir(): + return () + paths: list[str] = [] + for f in sorted(local_dir.rglob("*")): + if f.is_file(): + try: + paths.append(str(f.relative_to(local_dir))) + except ValueError: + continue + return tuple(paths) + + def _load_policy_limits(root: Path, warnings: list[ScanWarning]) -> dict[str, bool]: """Return {restriction_name: allowed} from `/policy-limits.json`. diff --git a/src/agentpeek/tui/render.py b/src/agentpeek/tui/render.py index bad61ed..226b4e8 100644 --- a/src/agentpeek/tui/render.py +++ b/src/agentpeek/tui/render.py @@ -628,6 +628,10 @@ def _settings_items(s: SettingsBundle | None) -> list[tuple[Content, object]]: _count_item_label("Spinner tips override", len(s.spinner_tips)), _SettingsItem("Spinner tips override", "list", list(s.spinner_tips)), ), + ( + _count_item_label("Local overrides", len(s.local_overrides)), + _SettingsItem("Local overrides", "list", list(s.local_overrides)), + ), ( _count_item_label("Hooks dir files", s.hooks_dir_files), _SettingsItem("Hooks dir files", "scalar", str(s.hooks_dir_files)), diff --git a/tests/test_render.py b/tests/test_render.py index c14b263..f5777db 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -283,6 +283,7 @@ def test_item_body_settings_returns_none() -> None: policy_restrictions={}, company_announcements=(), spinner_tips=(), + local_overrides=(), hooks_raw={}, hooks_dir_files=0, ) From 3dd05446d89b27e5a847f9e20985cc81f58f445c Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Wed, 20 May 2026 15:11:59 +0530 Subject: [PATCH 16/17] fix: skill group-header rows render as obvious dividers, not selectable --- src/agentpeek/tui/styles/app.tcss | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/agentpeek/tui/styles/app.tcss b/src/agentpeek/tui/styles/app.tcss index 04ebdd5..70a779b 100644 --- a/src/agentpeek/tui/styles/app.tcss +++ b/src/agentpeek/tui/styles/app.tcss @@ -79,14 +79,24 @@ Screen { border-bottom: none; } -/* Group headers in the items list — bolder, accent-tinted, no - hover/highlight (they're disabled and non-selectable). Used by the - Skills category to mark each source plugin. */ +/* Group headers in the items list — non-selectable plugin dividers + used by the Skills category. Distinct background + accent left + border + dimmed text-style on hover-attempt make it clear these + aren't clickable rows. */ #item-list > ListItem.group-header { - background: $surface; + background: $primary 15%; + border-left: thick $primary; padding-top: 1; } +/* Even when highlighted (cursor passes over a header) keep the + visual identical — disabled+highlighted shouldn't look selectable. */ +#item-list > ListItem.group-header.--highlight, +#item-list:focus > ListItem.group-header.--highlight { + background: $primary 15%; + text-style: none; +} + /* Selection emphasis — highlighted row uses $accent. The focused list gets a stronger tint and bold text so the user always knows which of the three zones the keyboard is driving. */ From bf599603647829ad3578178879fbc97dffec7fef Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Wed, 20 May 2026 15:12:55 +0530 Subject: [PATCH 17/17] =?UTF-8?q?fix:=20unify=20placeholders=20=E2=80=94?= =?UTF-8?q?=20em=20dash=20for=20unset=20scalars,=20(empty)=20for=20empty?= =?UTF-8?q?=20collections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agentpeek/tui/render.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/agentpeek/tui/render.py b/src/agentpeek/tui/render.py index 226b4e8..c8a3bba 100644 --- a/src/agentpeek/tui/render.py +++ b/src/agentpeek/tui/render.py @@ -666,7 +666,7 @@ def _settings_body(payload: _SettingsItem) -> Widget: if payload.kind == "scalar": if payload.value: return Static(str(payload.value)) - return Static("(unset)", classes="muted") + return Static("—", classes="muted") if payload.kind == "dict" and isinstance(payload.value, dict): return _dict_body(cast("dict[str, object]", payload.value)) # pyright: ignore[reportUnknownMemberType] if payload.kind == "list" and isinstance(payload.value, list): @@ -800,12 +800,12 @@ def _commands_detail_widgets(payload: object) -> list[Widget]: else: header_content = _styled(f"/{payload.name}", f"bold {COLOR_PRIMARY}") widgets: list[Widget] = [Static(header_content, classes="detail-header")] - tools = ", ".join(payload.allowed_tools) if payload.allowed_tools else "(none)" + tools = ", ".join(payload.allowed_tools) if payload.allowed_tools else "—" rows: list[tuple[str, RenderableType]] = [ ("Path", str(payload.path)), ( "Argument hint", - payload.argument_hint or _muted_cell("(none)"), + payload.argument_hint or _muted_cell("—"), ), ("Allowed tools", tools), ] @@ -849,7 +849,7 @@ def _plugins_detail_widgets(payload: object) -> list[Widget]: widgets: list[Widget] = [Static(header_content, classes="detail-header")] rows: list[tuple[str, RenderableType]] = [ ("ID", payload.id), - ("Marketplace", payload.marketplace or _muted_cell("(none)")), + ("Marketplace", payload.marketplace or _muted_cell("—")), ] src = payload.marketplace_source if src: @@ -1025,7 +1025,7 @@ def _skills_detail_widgets(payload: object) -> list[Widget]: ), ( "Description", - payload.description or _muted_cell("(none)"), + payload.description or _muted_cell("—"), ), ("Path", str(payload.path)), ] @@ -1145,7 +1145,7 @@ def _mcp_detail_widgets(payload: object) -> list[Widget]: classes="detail-header", ) ] - args = " ".join(payload.args) if payload.args else "(none)" + args = " ".join(payload.args) if payload.args else "—" rows: list[tuple[str, RenderableType]] = [ ("Source", str(payload.source_path)), ] @@ -1170,7 +1170,7 @@ def _mcp_detail_widgets(payload: object) -> list[Widget]: widgets.append(_card("Properties", Static(_kv_table(rows)))) title = f"Environment ({len(payload.env)})" if not payload.env: - widgets.append(_card(title, Static("(no env vars)", classes="muted"))) + widgets.append(_card(title, Static("(empty)", classes="muted"))) else: # `redact()` only masks values of length ≥ 8 — short values like # "true" / "on" pass through. Surface that contract honestly