diff --git a/src/agentpeek/models.py b/src/agentpeek/models.py index 012470b..8e22394 100644 --- a/src/agentpeek/models.py +++ b/src/agentpeek/models.py @@ -26,24 +26,23 @@ class SettingsBundle: 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] + # {restriction_name: "allowed" / "denied" / "denied — "}. + # Empty when the file is absent. + policy_restrictions: Mapping[str, str] # 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 `/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, ...] permissions_ask: tuple[str, ...] enabled_plugins: tuple[str, ...] hooks_raw: Mapping[str, tuple[Mapping[str, object], ...]] - hooks_dir_files: int + # Filenames (relative to `/hooks/`) present on disk — + # surfaced as a list so users can see actual script names without + # opening the filesystem. + hooks_dir_files: tuple[str, ...] @dataclass(frozen=True, slots=True) @@ -96,6 +95,17 @@ class PluginManifest: homepage: str | None license: str | None keywords: tuple[str, ...] = () + # Declared name (`name` in plugin.json) — may differ from the + # install directory name. Surfaced so a renamed/forked plugin + # is recognizable. + name: str | None = None + # Upstream source repo URL. Accepts both bare-string form + # ("https://github.com/...") and the dict form ({"type": "git", + # "url": "..."}); normalized to a URL string here. + repository: str | None = None + # `requires` from plugin.json — qualified ids of plugins this + # plugin depends on. Empty when not declared. + requires: tuple[str, ...] = () @dataclass(frozen=True, slots=True) @@ -105,6 +115,17 @@ class PluginSkill: description: str | None body: str source_plugin: str | None = None + # `when_to_use` — trigger guidance shown to Claude Code so it + # knows when to auto-load this skill. Often more descriptive + # than `description`; surfaced here so users can see the + # activation contract. + when_to_use: str | None = None + # True/False if `user-invocable` is set in frontmatter; None + # when the field is absent (defaults vary). + user_invocable: bool | None = None + # Comma-separated tool allow/deny lists from frontmatter. + allowed_tools: tuple[str, ...] = () + disallowed_tools: tuple[str, ...] = () @dataclass(frozen=True, slots=True) @@ -114,6 +135,10 @@ class PluginAgent: description: str | None body: str source_plugin: str | None = None + when_to_use: str | None = None + user_invocable: bool | None = None + allowed_tools: tuple[str, ...] = () + disallowed_tools: tuple[str, ...] = () @dataclass(frozen=True, slots=True) diff --git a/src/agentpeek/parsers/plugin_contents.py b/src/agentpeek/parsers/plugin_contents.py index 264e5fd..a7fec22 100644 --- a/src/agentpeek/parsers/plugin_contents.py +++ b/src/agentpeek/parsers/plugin_contents.py @@ -16,6 +16,7 @@ `source_plugin = qualified_id` for provenance. """ +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path from types import MappingProxyType @@ -114,6 +115,22 @@ def _parse_manifest( if isinstance(keywords_raw, list) else () ) + requires_raw = d.get("requires") + requires: tuple[str, ...] = ( + tuple(s for s in cast("list[object]", requires_raw) if isinstance(s, str)) + if isinstance(requires_raw, list) + else () + ) + # `repository` accepts either a bare URL string or a dict like + # {"type": "git", "url": "..."}. Normalize to the URL. + repository_raw = d.get("repository") + repository: str | None = None + if isinstance(repository_raw, str): + repository = repository_raw + elif isinstance(repository_raw, dict): + repository = as_str( + cast("dict[str, object]", repository_raw).get("url") + ) manifest = PluginManifest( description=as_str(d.get("description")), version=as_str(d.get("version")), @@ -122,6 +139,9 @@ def _parse_manifest( homepage=as_str(d.get("homepage")), license=as_str(d.get("license")), keywords=keywords, + name=as_str(d.get("name")), + repository=repository, + requires=requires, ) return d, manifest @@ -160,6 +180,19 @@ def _parse_skills( ), body=file.body, source_plugin=qualified_id, + when_to_use=read_str_field( + file.metadata, "when_to_use", + path=skill_md, category="plugin_skill", warnings=warnings, + ), + user_invocable=_as_optional_bool(file.metadata.get("user-invocable")), + allowed_tools=_split_csv_field( + file.metadata, "allowed-tools", + path=skill_md, category="plugin_skill", warnings=warnings, + ), + disallowed_tools=_split_csv_field( + file.metadata, "disallowed-tools", + path=skill_md, category="plugin_skill", warnings=warnings, + ), ) ) return tuple(results) @@ -191,11 +224,46 @@ def _parse_agents( ), body=file.body, source_plugin=qualified_id, + when_to_use=read_str_field( + file.metadata, "when_to_use", + path=md, category="plugin_agent", warnings=warnings, + ), + user_invocable=_as_optional_bool(file.metadata.get("user-invocable")), + allowed_tools=_split_csv_field( + file.metadata, "allowed-tools", + path=md, category="plugin_agent", warnings=warnings, + ), + disallowed_tools=_split_csv_field( + file.metadata, "disallowed-tools", + path=md, category="plugin_agent", warnings=warnings, + ), ) ) return tuple(results) +def _as_optional_bool(v: object) -> bool | None: + if isinstance(v, bool): + return v + return None + + +def _split_csv_field( + metadata: Mapping[str, object], + key: str, + *, + path: Path, + category: str, + warnings: list[ScanWarning], +) -> tuple[str, ...]: + raw = read_str_field( + metadata, key, path=path, category=category, warnings=warnings + ) + if not raw: + return () + return tuple(s.strip() for s in raw.split(",") if s.strip()) + + # --- Slash commands ------------------------------------------------------ diff --git a/src/agentpeek/sources/local.py b/src/agentpeek/sources/local.py index deb1ba8..d775da8 100644 --- a/src/agentpeek/sources/local.py +++ b/src/agentpeek/sources/local.py @@ -114,6 +114,21 @@ def _scan_settings( as_str_tuple(user_perms.get("ask")), as_str_tuple(local_perms.get("ask")), ) + # Same pattern in both allow and deny is a contradiction the + # user probably didn't mean — flag it. Claude Code's runtime + # resolution for this case isn't documented; we report what's + # in the files and let the user decide which one to remove. + for collision in sorted(set(permissions_allow) & set(permissions_deny)): + warnings.append( + ScanWarning( + path=user_path if user_path.exists() else local_path, + category="settings", + reason=( + f"permission rule {collision!r} appears in both " + "allow and deny" + ), + ) + ) user_plugins = as_dict(user_data.get("enabledPlugins")) or {} local_plugins = as_dict(local_data.get("enabledPlugins")) or {} @@ -123,19 +138,31 @@ def _scan_settings( enabled_set.add(str(k)) enabled_plugins = tuple(sorted(enabled_set)) - hooks_raw_dict = as_dict(user_data.get("hooks")) or {} + # Union hooks across user + local settings (matches the + # permissions / enabledPlugins precedent — Claude Code stacks + # both). Entries from local follow entries from user within + # each event so the original ordering is preserved. hooks_raw: dict[str, tuple[dict[str, object], ...]] = {} - for event, entries in hooks_raw_dict.items(): - if isinstance(entries, list): - event_entries: list[dict[str, object]] = [] + merged_events: dict[str, list[dict[str, object]]] = {} + for source in ( + as_dict(user_data.get("hooks")) or {}, + as_dict(local_data.get("hooks")) or {}, + ): + for event, entries in source.items(): + if not isinstance(entries, list): + continue + bucket = merged_events.setdefault(str(event), []) for e in cast("list[object]", entries): if isinstance(e, dict): - event_entries.append(cast("dict[str, object]", e)) - hooks_raw[str(event)] = tuple(event_entries) + bucket.append(cast("dict[str, object]", e)) + for event, bucket in merged_events.items(): + hooks_raw[event] = tuple(bucket) hooks_dir = root / "hooks" - hooks_dir_files = ( - sum(1 for _ in hooks_dir.iterdir()) if hooks_dir.is_dir() else 0 + hooks_dir_files: tuple[str, ...] = ( + tuple(sorted(f.name for f in hooks_dir.iterdir() if f.is_file())) + if hooks_dir.is_dir() + else () ) # statusLine can be a dict ({"type": "command", "command": "..."}) @@ -144,10 +171,16 @@ def _scan_settings( _parse_status_line(user_data.get("statusLine")) ) - skip_prompt = bool( - local_data.get("skipAutoPermissionPrompt") - or user_data.get("skipAutoPermissionPrompt") - ) + # `in` rather than `or` — `or` short-circuits on False, so a + # project explicitly setting `skipAutoPermissionPrompt: false` + # would never override a user-level True. Use presence to + # distinguish "key absent" from "explicit False". + if "skipAutoPermissionPrompt" in local_data: + skip_prompt = bool(local_data["skipAutoPermissionPrompt"]) + elif "skipAutoPermissionPrompt" in user_data: + skip_prompt = bool(user_data["skipAutoPermissionPrompt"]) + else: + skip_prompt = False policy_restrictions = _load_policy_limits(root, warnings) @@ -155,8 +188,6 @@ 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, @@ -175,7 +206,6 @@ 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, ) @@ -679,31 +709,14 @@ 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`. +def _load_policy_limits(root: Path, warnings: list[ScanWarning]) -> dict[str, str]: + """Return {restriction_name: formatted-string} from + `/policy-limits.json`. - Empty dict when the file is absent (typical for project scope). + The string includes both the allowed bool and any `message` / + `text` field the enterprise admin attached. Format: + `"allowed"` / `"denied"` / `"denied — "` etc. + Empty dict when the file is absent. """ path = root / "policy-limits.json" if not path.is_file(): @@ -716,12 +729,17 @@ def _load_policy_limits(root: Path, warnings: list[ScanWarning]) -> dict[str, bo restrictions = cast("dict[str, object]", data).get("restrictions") if not isinstance(restrictions, dict): return {} - out: dict[str, bool] = {} + out: dict[str, str] = {} 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 + if not isinstance(entry, dict): + continue + entry_d = cast("dict[str, object]", entry) + allowed = entry_d.get("allowed") + verdict = ( + "allowed" if allowed is True else "denied" if allowed is False else "?" + ) + message = as_str(entry_d.get("message")) or as_str(entry_d.get("text")) + out[str(name)] = f"{verdict} — {message}" if message else verdict return out @@ -729,7 +747,10 @@ 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). + where the entire value is the shell command). Non-string dict + values (e.g. `padding: 1`, `refreshInterval: 30`) are coerced to + str rather than dropped — they're real config the user wrote and + inspecting them in agentpeek shouldn't silently lose them. """ if isinstance(v, str): return MappingProxyType({"type": "command", "command": v}) @@ -737,7 +758,6 @@ def _parse_status_line(v: object) -> Mapping[str, str] | None: 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 @@ -769,8 +789,22 @@ def _load_blocklist(root: Path, warnings: list[ScanWarning]) -> dict[str, str]: 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 + reason = as_str(entry_d.get("reason")) + text = as_str(entry_d.get("text")) + if reason and text and reason != text: + warnings.append( + ScanWarning( + path=path, + category="plugins", + reason=( + f"blocklist entry for {qid} has both `reason` and " + f"`text` with different values; surfacing both" + ), + ) + ) + out[qid] = f"{reason} (text: {text})" + else: + out[qid] = reason or text or "" return out diff --git a/src/agentpeek/tui/render.py b/src/agentpeek/tui/render.py index c8a3bba..b6d972a 100644 --- a/src/agentpeek/tui/render.py +++ b/src/agentpeek/tui/render.py @@ -615,7 +615,7 @@ def _settings_items(s: SettingsBundle | None) -> list[tuple[Content, object]]: _SettingsItem( "Policy restrictions", "dict", - {k: str(v) for k, v in s.policy_restrictions.items()}, + dict(s.policy_restrictions), ), ), ( @@ -629,12 +629,10 @@ def _settings_items(s: SettingsBundle | None) -> list[tuple[Content, object]]: _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)), + _count_item_label("Hooks dir files", len(s.hooks_dir_files)), + _SettingsItem( + "Hooks dir files", "list", list(s.hooks_dir_files) + ), ), ] @@ -974,6 +972,8 @@ def _plugins_detail_widgets(payload: object) -> list[Widget]: def _manifest_table(m: PluginManifest) -> Table: rows: list[tuple[str, RenderableType]] = [] + if m.name: + rows.append(("Name", m.name)) if m.description: rows.append(("Description", m.description)) if m.version: @@ -985,10 +985,14 @@ def _manifest_table(m: PluginManifest) -> Table: rows.append(("Author", author)) if m.homepage: rows.append(("Homepage", m.homepage)) + if m.repository: + rows.append(("Repository", m.repository)) if m.license: rows.append(("License", m.license)) if m.keywords: rows.append(("Keywords", ", ".join(m.keywords))) + if m.requires: + rows.append(("Requires", ", ".join(m.requires))) if not rows: rows.append(("(manifest)", _muted_cell("(no fields)"))) return _kv_table(rows) @@ -1029,6 +1033,14 @@ def _skills_detail_widgets(payload: object) -> list[Widget]: ), ("Path", str(payload.path)), ] + if payload.user_invocable is not None: + rows.append(("User-invocable", "yes" if payload.user_invocable else "no")) + if payload.when_to_use: + rows.append(("When to use", payload.when_to_use)) + if payload.allowed_tools: + rows.append(("Allowed tools", ", ".join(payload.allowed_tools))) + if payload.disallowed_tools: + rows.append(("Disallowed tools", ", ".join(payload.disallowed_tools))) widgets.append(_card("Properties", Static(_kv_table(rows)))) widgets.append(_card("Body", _bounded_markdown(payload.body))) return widgets diff --git a/src/agentpeek/tui/screens/agent_detail.py b/src/agentpeek/tui/screens/agent_detail.py index d9a2780..97c1252 100644 --- a/src/agentpeek/tui/screens/agent_detail.py +++ b/src/agentpeek/tui/screens/agent_detail.py @@ -41,6 +41,23 @@ def compose(self) -> ComposeResult: ) if a.description: yield Static(a.description, id="agent-modal-desc") + meta_lines: list[str] = [] + if a.when_to_use: + meta_lines.append(f"**When to use:** {a.when_to_use}") + if a.user_invocable is not None: + meta_lines.append( + f"**User-invocable:** {'yes' if a.user_invocable else 'no'}" + ) + if a.allowed_tools: + meta_lines.append( + f"**Allowed tools:** {', '.join(a.allowed_tools)}" + ) + if a.disallowed_tools: + meta_lines.append( + f"**Disallowed tools:** {', '.join(a.disallowed_tools)}" + ) + if meta_lines: + yield Static("\n\n".join(meta_lines), id="agent-modal-meta") with VerticalScroll(id="agent-modal-body"): yield Markdown(a.body or "_(empty)_") diff --git a/src/agentpeek/tui/screens/skill_detail.py b/src/agentpeek/tui/screens/skill_detail.py index c996468..ea1e1da 100644 --- a/src/agentpeek/tui/screens/skill_detail.py +++ b/src/agentpeek/tui/screens/skill_detail.py @@ -41,6 +41,23 @@ def compose(self) -> ComposeResult: ) if s.description: yield Static(s.description, id="skill-modal-desc") + meta_lines: list[str] = [] + if s.when_to_use: + meta_lines.append(f"**When to use:** {s.when_to_use}") + if s.user_invocable is not None: + meta_lines.append( + f"**User-invocable:** {'yes' if s.user_invocable else 'no'}" + ) + if s.allowed_tools: + meta_lines.append( + f"**Allowed tools:** {', '.join(s.allowed_tools)}" + ) + if s.disallowed_tools: + meta_lines.append( + f"**Disallowed tools:** {', '.join(s.disallowed_tools)}" + ) + if meta_lines: + yield Static("\n\n".join(meta_lines), id="skill-modal-meta") with VerticalScroll(id="skill-modal-body"): yield Markdown(s.body or "_(empty)_") diff --git a/tests/test_render.py b/tests/test_render.py index f5777db..23d72d4 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -283,9 +283,8 @@ def test_item_body_settings_returns_none() -> None: policy_restrictions={}, company_announcements=(), spinner_tips=(), - local_overrides=(), hooks_raw={}, - hooks_dir_files=0, + hooks_dir_files=(), ) # Settings bundle has no single "body" — yanking it is a no-op. assert item_body(bundle) is None diff --git a/tests/test_scanner.py b/tests/test_scanner.py index e358608..e59bd5d 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -26,7 +26,7 @@ def test_scan_local_source(sample_claude_root: Path) -> None: assert result.settings.theme == "dark" assert result.settings.effort_level == "high" assert len(result.settings.permissions_allow) == 2 - assert result.settings.hooks_dir_files == 2 + assert result.settings.hooks_dir_files == ("orphan.sh", "present-hook.sh") command_names = {c.name for c in result.commands} assert command_names == {"good", "plain", "broken-fm"}