Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
dc6d025
feat: read plugins/blocklist.json and surface blocked state
rattle99 May 20, 2026
3b78fb5
feat: parse and surface statusLine + skipAutoPermissionPrompt
rattle99 May 20, 2026
5901335
feat: resolve plugin marketplace source from known_marketplaces.json
rattle99 May 20, 2026
c4ebe65
fix: merge permissions across user + local settings (don't override)
rattle99 May 20, 2026
0c5e579
fix: MCP env column header doesn't lie about partial redaction
rattle99 May 20, 2026
17c81b1
feat: surface OAuth MCPs awaiting auth (mcp-needs-auth-cache.json)
rattle99 May 20, 2026
544f2cb
feat: surface policy-limits.json restrictions in settings detail
rattle99 May 20, 2026
6f19352
feat: surface companyAnnouncements + spinnerTipsOverride from remote-…
rattle99 May 20, 2026
552becd
fix: use [...] for preview truncation so it can't be confused with a …
rattle99 May 20, 2026
7c77811
fix: warn when plugin defines the same MCP server in plugin.json + .m…
rattle99 May 20, 2026
b6b594b
fix: warn on malformed keybindings.json shape instead of silently dro…
rattle99 May 20, 2026
5e51710
fix: enabledPlugins disagreement warning explains union semantics
rattle99 May 20, 2026
ebd42d8
fix: warn when frontmatter string fields are wrong-typed
rattle99 May 20, 2026
3b85d94
feat: MCP detail shows source plugin explicitly
rattle99 May 20, 2026
f7c4243
feat: surface files under ~/.claude/local/ as settings detail row
rattle99 May 20, 2026
3dd0544
fix: skill group-header rows render as obvious dividers, not selectable
rattle99 May 20, 2026
bf59960
fix: unify placeholders — em dash for unset scalars, (empty) for empt…
rattle99 May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/agentpeek/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
32 changes: 32 additions & 0 deletions src/agentpeek/models.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -20,6 +21,22 @@ 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
# `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, ...]
# Relative paths inside `<root>/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, ...]
Expand Down Expand Up @@ -116,6 +133,16 @@ 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
# 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"]
Expand Down Expand Up @@ -151,6 +178,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)
Expand Down
34 changes: 34 additions & 0 deletions src/agentpeek/parsers/frontmatter_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
57 changes: 48 additions & 9 deletions src/agentpeek/parsers/plugin_contents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
)
Expand All @@ -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,
)
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -342,8 +366,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,
Expand Down
Loading
Loading