From 8ec8851be2279e674f115b6725f3317e3f7c134b Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Mon, 25 May 2026 14:07:49 +0530 Subject: [PATCH 1/4] feat: extend Claude Code surface coverage - user/project agents, rules, CLAUDE.local.md, agent-memory dirs - @path imports resolved (5-hop cap, cycle detection) - ~/.claude.json: user MCP, per-project trust, OAuth presence-only - settings: default agent, claudeMdExcludes - actions/runner subprocess-mocked tests --- src/agentpeek/models.py | 101 +++++++- src/agentpeek/sources/local.py | 449 ++++++++++++++++++++++++++++++++- src/agentpeek/tui/render.py | 215 +++++++++++++++- tests/test_actions.py | 127 ++++++++++ tests/test_scanner.py | 364 +++++++++++++++++++++++++- 5 files changed, 1244 insertions(+), 12 deletions(-) create mode 100644 tests/test_actions.py diff --git a/src/agentpeek/models.py b/src/agentpeek/models.py index 8e22394..cb608ae 100644 --- a/src/agentpeek/models.py +++ b/src/agentpeek/models.py @@ -43,6 +43,13 @@ class SettingsBundle: # surfaced as a list so users can see actual script names without # opening the filesystem. hooks_dir_files: tuple[str, ...] + # `agent` setting: default subagent for sessions in this scope. + # Set via `.claude/settings.json` and overridable on the CLI. + default_agent: str | None = None + # `claudeMdExcludes`: glob patterns excluding specific CLAUDE.md + # files from the load order. Surfaced because they answer + # "why isn't this CLAUDE.md taking effect?" debugging questions. + claude_md_excludes: tuple[str, ...] = () @dataclass(frozen=True, slots=True) @@ -174,7 +181,26 @@ class Plugin: ) -MemoryKind = Literal["claude_md", "memory_index", "memory_entry"] +MemoryKind = Literal[ + "claude_md", + "claude_local_md", + "memory_index", + "memory_entry", + "agent_memory_index", + "agent_memory_entry", +] + + +@dataclass(frozen=True, slots=True) +class MemoryImport: + # Resolved import target encountered while expanding `@path/to/file` + # references inside a CLAUDE.md body. `resolved_path` is None when + # the import couldn't be resolved (file missing, cycle, depth cap + # hit); `reason` carries a short marker for the renderer. + raw: str + resolved_path: Path | None + depth: int + reason: str | None = None @dataclass(frozen=True, slots=True) @@ -184,6 +210,59 @@ class MemoryFile: has_frontmatter: bool kind: MemoryKind project_label: str | None + # Auto-memory entries belonging to a specific subagent carry the + # agent name resolved from the parent directory; otherwise None. + agent_name: str | None = None + # Resolved @path imports referenced from this body (recursive, max 5 + # hops, cycles marked). Empty when the file isn't a CLAUDE.md or + # contains no imports. + imports: tuple[MemoryImport, ...] = () + + +@dataclass(frozen=True, slots=True) +class Agent: + # User- or project-scope subagent (`~/.claude/agents/` or + # `/.claude/agents/`). Plugin-bundled agents use the + # separate PluginAgent type — they live inside Plugin.agents and + # are constructed by parsers.plugin_contents. + path: Path + name: str + description: str | None + body: str + # `tools` and `disallowedTools` accept either a comma-separated + # string or a YAML list in the canonical schema. Normalized to a + # tuple of token strings here. + tools: tuple[str, ...] = () + disallowed_tools: tuple[str, ...] = () + model: str | None = None + permission_mode: str | None = None + max_turns: int | None = None + skills: tuple[str, ...] = () + memory: str | None = None + background: bool | None = None + effort: str | None = None + isolation: str | None = None + color: str | None = None + initial_prompt: str | None = None + # Raw mcpServers + hooks bodies — both have complex schemas that + # don't compress into a tuple of strings cleanly. We carry them as + # presence flags + a stringified summary so the detail card can + # show "configured" / "(none)". + has_mcp_servers: bool = False + has_hooks: bool = False + + +@dataclass(frozen=True, slots=True) +class Rule: + # `.claude/rules/*.md` — path-scoped or always-loaded context rule. + # Per docs, `paths:` frontmatter is optional; absent means the rule + # loads at session start with the same priority as `.claude/CLAUDE.md`. + path: Path + name: str + description: str | None + paths_globs: tuple[str, ...] + always_loaded: bool + body: str @dataclass(frozen=True, slots=True) @@ -214,6 +293,19 @@ class MCPServer: auth_pending: bool = False +@dataclass(frozen=True, slots=True) +class TrustEntry: + # Per-project trust state from `~/.claude.json[projects][]`. + # Surfaced in the Settings detail card so users can see which + # projects they've trusted, which tools are pre-approved, and the + # per-project enable/disable lists for `.mcp.json` servers. + project_path: str + trust_accepted: bool + allowed_tools: tuple[str, ...] + enabled_mcpjson_servers: tuple[str, ...] + disabled_mcpjson_servers: tuple[str, ...] + + @dataclass(frozen=True, slots=True) class ScanResult: source: str @@ -226,6 +318,13 @@ class ScanResult: keybindings: KeybindingsBundle | None mcp: tuple[MCPServer, ...] warnings: tuple[ScanWarning, ...] + agents: tuple[Agent, ...] = () + rules: tuple[Rule, ...] = () + # `~/.claude.json` highlights, only populated at user scope. Empty + # tuple when the file is absent or the scope isn't user. + trust_entries: tuple[TrustEntry, ...] = () + oauth_session_present: bool = False + claude_json_path: Path | None = None @classmethod def empty( diff --git a/src/agentpeek/sources/local.py b/src/agentpeek/sources/local.py index 5fbc9a3..4421c92 100644 --- a/src/agentpeek/sources/local.py +++ b/src/agentpeek/sources/local.py @@ -8,18 +8,22 @@ from typing import ClassVar, cast from agentpeek.models import ( + Agent, HookSpec, KeybindingEntry, KeybindingsBundle, MCPServer, MemoryFile, + MemoryImport, MemoryKind, Plugin, PluginInstallation, + Rule, ScanResult, ScanWarning, SettingsBundle, SlashCommand, + TrustEntry, ) from agentpeek.parsers import load_frontmatter, load_json from agentpeek.parsers.coerce import ( @@ -69,7 +73,10 @@ def scan(self, root: Path) -> ScanResult: plugins = self._scan_plugins(root, warnings) memory = self._scan_memory(root, warnings) keybindings = self._scan_keybindings(root, warnings) - mcp = self._scan_mcp(root, warnings) + mcp_servers, claude_json_extras = self._scan_mcp(root, warnings) + agents = self._scan_agents(root, warnings) + rules = self._scan_rules(root, warnings) + trust_entries, oauth_present, claude_json_path = claude_json_extras return ScanResult( source=self.name, root=root, @@ -79,8 +86,13 @@ def scan(self, root: Path) -> ScanResult: plugins=plugins, memory=memory, keybindings=keybindings, - mcp=mcp, + mcp=mcp_servers, warnings=tuple(warnings), + agents=agents, + rules=rules, + trust_entries=trust_entries, + oauth_session_present=oauth_present, + claude_json_path=claude_json_path, ) def _scan_settings( @@ -141,6 +153,17 @@ def _scan_settings( spinner_override = as_dict(remote_data.get("spinnerTipsOverride")) or {} spinner_tips = as_str_tuple(spinner_override.get("tips")) + # Local settings overrides user; absent on either side falls through. + default_agent = as_str(local_data.get("agent")) or as_str( + user_data.get("agent") + ) + # `claudeMdExcludes` is a list of globs. Union across user + local + # since both layers can contribute exclusions in practice. + claude_md_excludes = _union_str_tuple( + as_str_tuple(user_data.get("claudeMdExcludes")), + as_str_tuple(local_data.get("claudeMdExcludes")), + ) + 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 +184,8 @@ def _scan_settings( spinner_tips=spinner_tips, hooks_raw=MappingProxyType(hooks_raw), hooks_dir_files=hooks_dir_files, + default_agent=default_agent, + claude_md_excludes=claude_md_excludes, ) def _scan_hooks( @@ -335,7 +360,7 @@ def _scan_plugins( ) return tuple(results) - def _scan_memory( + def _scan_memory( # noqa: PLR0912 self, root: Path, warnings: list[ScanWarning] ) -> tuple[MemoryFile, ...]: results: list[MemoryFile] = [] @@ -349,6 +374,57 @@ def _scan_memory( warnings=warnings, ) ) + # CLAUDE.local.md sits at project root only per docs (no + # `.claude/` variant, no user-scope variant). When the scan + # root is `/.claude`, the parent directory holds it. + local_candidates = [root.parent / "CLAUDE.local.md", root / "CLAUDE.local.md"] + seen_paths: set[Path] = set() + for local_path in local_candidates: + try: + resolved = local_path.resolve() + except OSError: + continue + if resolved in seen_paths or not local_path.is_file(): + continue + seen_paths.add(resolved) + results.append( + _read_memory_file( + local_path, + kind="claude_local_md", + project_label=None, + warnings=warnings, + ) + ) + + # Subagent persistent memory directories. Per docs: user/project + # shared (`/agent-memory//`) and project-local + # (`/agent-memory-local//`). Each contains a + # MEMORY.md index plus optional topic files. We surface the + # parent dir name via `project_label` so the renderer can + # distinguish shared vs local without inspecting the path. + for parent_name in ("agent-memory", "agent-memory-local"): + parent = root / parent_name + if not parent.is_dir(): + continue + for agent_dir in sorted(parent.iterdir()): + if not agent_dir.is_dir(): + continue + for md_path in sorted(agent_dir.glob("*.md")): + kind: MemoryKind = ( + "agent_memory_index" + if md_path.name == "MEMORY.md" + else "agent_memory_entry" + ) + results.append( + _read_memory_file( + md_path, + kind=kind, + project_label=parent_name, + warnings=warnings, + agent_name=agent_dir.name, + ) + ) + projects_dir = root / "projects" if projects_dir.is_dir(): for proj_dir in sorted(projects_dir.iterdir()): @@ -372,6 +448,150 @@ def _scan_memory( ) return tuple(results) + def _scan_agents( + self, root: Path, warnings: list[ScanWarning] + ) -> tuple[Agent, ...]: + agents_dir = root / "agents" + if not agents_dir.is_dir(): + return () + results: list[Agent] = [] + for md_path in sorted(agents_dir.rglob("*.md")): + file, warning = load_frontmatter(md_path, category="agents") + if warning is not None: + warnings.append(warning) + # Identity comes from the `name` frontmatter field per docs, + # not the filename or the relative path. Fall back to the + # filename stem when frontmatter is absent or invalid. + fallback_name = md_path.stem + if file is None: + results.append( + Agent( + path=md_path, + name=fallback_name, + description=None, + body=_safe_read_text(md_path), + ) + ) + continue + name = ( + read_str_field( + file.metadata, "name", + path=md_path, category="agents", warnings=warnings, + ) + or fallback_name + ) + results.append( + Agent( + path=md_path, + name=name, + description=read_str_field( + file.metadata, "description", + path=md_path, category="agents", warnings=warnings, + ), + body=file.body, + tools=read_string_list_field( + file.metadata, "tools", + path=md_path, category="agents", warnings=warnings, + ), + disallowed_tools=read_string_list_field( + file.metadata, "disallowedTools", + path=md_path, category="agents", warnings=warnings, + ), + model=read_str_field( + file.metadata, "model", + path=md_path, category="agents", warnings=warnings, + ), + permission_mode=read_str_field( + file.metadata, "permissionMode", + path=md_path, category="agents", warnings=warnings, + ), + max_turns=as_int(file.metadata.get("maxTurns")), + skills=read_string_list_field( + file.metadata, "skills", + path=md_path, category="agents", warnings=warnings, + ), + memory=read_str_field( + file.metadata, "memory", + path=md_path, category="agents", warnings=warnings, + ), + background=_as_bool_or_none(file.metadata.get("background")), + effort=read_str_field( + file.metadata, "effort", + path=md_path, category="agents", warnings=warnings, + ), + isolation=read_str_field( + file.metadata, "isolation", + path=md_path, category="agents", warnings=warnings, + ), + color=read_str_field( + file.metadata, "color", + path=md_path, category="agents", warnings=warnings, + ), + initial_prompt=read_str_field( + file.metadata, "initialPrompt", + path=md_path, category="agents", warnings=warnings, + ), + has_mcp_servers=bool(file.metadata.get("mcpServers")), + has_hooks=bool(file.metadata.get("hooks")), + ) + ) + return tuple(results) + + def _scan_rules( + self, root: Path, warnings: list[ScanWarning] + ) -> tuple[Rule, ...]: + rules_dir = root / "rules" + if not rules_dir.is_dir(): + return () + results: list[Rule] = [] + for md_path in sorted(rules_dir.rglob("*.md")): + file, warning = load_frontmatter(md_path, category="rules") + if warning is not None: + warnings.append(warning) + # Per-rule identifier reflects the on-disk layout so users + # can find the file from the listing. + rel = str(md_path.relative_to(rules_dir).with_suffix("")).replace( + "\\", "/" + ) + if file is None: + results.append( + Rule( + path=md_path, + name=rel, + description=None, + paths_globs=(), + always_loaded=True, + body=_safe_read_text(md_path), + ) + ) + continue + globs = read_string_list_field( + file.metadata, "paths", + path=md_path, category="rules", warnings=warnings, + ) + # Frontmatter `name` overrides the relative-path identifier. + name = ( + read_str_field( + file.metadata, "name", + path=md_path, category="rules", warnings=warnings, + ) + or rel + ) + results.append( + Rule( + path=md_path, + name=name, + description=read_str_field( + file.metadata, "description", + path=md_path, category="rules", warnings=warnings, + ), + paths_globs=globs, + always_loaded=not globs, + body=file.body, + ) + ) + return tuple(results) + def _scan_keybindings( self, root: Path, warnings: list[ScanWarning] ) -> KeybindingsBundle | None: @@ -447,9 +667,12 @@ def _scan_keybindings( ) return KeybindingsBundle(path=path, entries=tuple(entries)) - def _scan_mcp( + def _scan_mcp( # noqa: PLR0912 self, root: Path, warnings: list[ScanWarning] - ) -> tuple[MCPServer, ...]: + ) -> tuple[ + tuple[MCPServer, ...], + tuple[tuple[TrustEntry, ...], bool, Path | None], + ]: results: list[MCPServer] = [] seen_names: set[str] = set() for filename in ("settings.json", "remote-settings.json"): @@ -512,7 +735,27 @@ def _scan_mcp( auth_pending=True, ) ) - return tuple(results) + + # `~/.claude.json` lives at $HOME, not inside ~/.claude/. Only + # surfaced when the current scan root IS the user-level config + # directory — its contents are user-global, never project-scoped. + claude_json_data: tuple[tuple[TrustEntry, ...], bool, Path | None] = ( + ((), False, None) + ) + try: + home_claude = (Path.home() / ".claude").resolve() + scan_root_resolved = root.resolve() + except OSError: + scan_root_resolved = root + home_claude = Path.home() / ".claude" + if scan_root_resolved == home_claude: + claude_json_path = Path.home() / ".claude.json" + if claude_json_path.is_file(): + claude_json_data = _scan_claude_json( + claude_json_path, results, seen_names, warnings + ) + + return tuple(results), claude_json_data _DEFAULT_VAR_RE = re.compile(r"\$\{[A-Za-z_][A-Za-z0-9_]*:-([^}]*)\}") @@ -890,6 +1133,7 @@ def _read_memory_file( kind: MemoryKind, project_label: str | None, warnings: list[ScanWarning], + agent_name: str | None = None, ) -> MemoryFile: file, warning = load_frontmatter(path, category="memory") if warning is not None: @@ -900,13 +1144,206 @@ def _read_memory_file( else: body = file.body has_fm = bool(file.metadata) + # CLAUDE.md (and its .local sibling) support `@path/to/file` imports + # up to 5 hops deep. Resolve them here so the detail card can render + # the full graph the agent actually sees, not just the importer. + imports: tuple[MemoryImport, ...] = () + if kind in ("claude_md", "claude_local_md"): + imports = _resolve_memory_imports(path, body) return MemoryFile( path=path, body=body, has_frontmatter=has_fm, kind=kind, project_label=project_label, + agent_name=agent_name, + imports=imports, + ) + + +# `@` matches when `@` is at start-of-line or preceded by whitespace. +# The path token stops at the first whitespace or end of line. +_IMPORT_RE = re.compile(r"(?:^|(?<=\s))@(\S+)") +_IMPORT_MAX_DEPTH = 5 + + +def _resolve_memory_imports(root_path: Path, body: str) -> tuple[MemoryImport, ...]: + """Walk `@path` references reachable from `body`, depth-first, up to 5 hops. + + Returns one entry per reference encountered (including duplicates + seen via different parents). Cycles and depth-cap hits are surfaced + via the `reason` field rather than silently truncated so the renderer + can mark them. + """ + try: + root_resolved = root_path.resolve() + except OSError: + root_resolved = root_path + visited: set[Path] = {root_resolved} + out: list[MemoryImport] = [] + _walk_imports(root_path, body, visited, 1, out) + return tuple(out) + + +def _walk_imports( + parent_path: Path, + body: str, + visited: set[Path], + depth: int, + out: list[MemoryImport], +) -> None: + for match in _IMPORT_RE.finditer(body): + raw = match.group(1) + target = _resolve_import_target(parent_path, raw) + if target is None: + out.append( + MemoryImport( + raw=raw, resolved_path=None, depth=depth, reason="unresolved" + ) + ) + continue + try: + resolved = target.resolve() + except OSError: + out.append( + MemoryImport( + raw=raw, resolved_path=None, depth=depth, reason="unresolved" + ) + ) + continue + if resolved in visited: + out.append( + MemoryImport( + raw=raw, resolved_path=resolved, depth=depth, reason="cycle" + ) + ) + continue + if not target.is_file(): + out.append( + MemoryImport( + raw=raw, resolved_path=resolved, depth=depth, reason="missing" + ) + ) + continue + if depth >= _IMPORT_MAX_DEPTH: + out.append( + MemoryImport( + raw=raw, + resolved_path=resolved, + depth=depth, + reason="depth-cap", + ) + ) + continue + out.append(MemoryImport(raw=raw, resolved_path=resolved, depth=depth)) + visited.add(resolved) + try: + nested_body = target.read_text(encoding="utf-8-sig") + except OSError: + continue + _walk_imports(target, nested_body, visited, depth + 1, out) + + +def _resolve_import_target(parent_path: Path, raw: str) -> Path | None: + """Resolve a raw `@` reference against the file that contained it. + + Accepts absolute paths, `~/` home-relative paths, and bare relative + paths (resolved relative to `parent_path.parent`). Returns None when + the token can't be parsed as a path at all. + """ + if not raw: + return None + if raw.startswith("~/"): + return Path.home() / raw[2:] + candidate = Path(raw) + if candidate.is_absolute(): + return candidate + return parent_path.parent / candidate + + +def _as_bool_or_none(v: object) -> bool | None: + """Accept JSON/YAML `true`/`false` as bool, anything else as None.""" + if isinstance(v, bool): + return v + return None + + +def _scan_claude_json( + path: Path, + mcp_results: list[MCPServer], + seen_names: set[str], + warnings: list[ScanWarning], +) -> tuple[tuple[TrustEntry, ...], bool, Path | None]: + """Surface user-scope MCP servers and per-project trust state from `~/.claude.json`. + + The OAuth session field is detected by presence only — its value is + never read or stored to avoid leaking credentials through the + inspector. MCP servers are appended to the caller's `mcp_results` + list with the auth-cache file as `source_path` for provenance. + """ + data, warning = load_json(path, category="settings") + if warning is not None: + warnings.append(warning) + if not isinstance(data, dict): + return (), False, path + data_d = cast("dict[str, object]", data) + # User-scope MCP servers — `mcpServers` at top level. + servers = data_d.get("mcpServers") + if isinstance(servers, dict): + for srv_name, srv in cast("dict[str, object]", servers).items(): + if str(srv_name) in seen_names or not isinstance(srv, dict): + continue + srv_d = cast("dict[str, object]", srv) + args_raw = srv_d.get("args") + args_tuple: tuple[str, ...] = ( + tuple(str(a) for a in cast("list[object]", args_raw)) + if isinstance(args_raw, list) + else () + ) + env_obj = as_str_dict(srv_d.get("env")) + mcp_results.append( + MCPServer( + name=str(srv_name), + source_path=path, + command=as_str(srv_d.get("command")), + args=args_tuple, + env=MappingProxyType(env_obj), + ) + ) + seen_names.add(str(srv_name)) + # Per-project trust state — `projects..{hasTrustDialogAccepted, + # allowedTools, enabledMcpjsonServers, disabledMcpjsonServers}`. + trust_entries: list[TrustEntry] = [] + projects = data_d.get("projects") + if isinstance(projects, dict): + for proj_path, proj_state in cast("dict[str, object]", projects).items(): + if not isinstance(proj_state, dict): + continue + state_d = cast("dict[str, object]", proj_state) + trust_entries.append( + TrustEntry( + project_path=str(proj_path), + trust_accepted=bool(state_d.get("hasTrustDialogAccepted", False)), + allowed_tools=as_str_tuple(state_d.get("allowedTools")), + enabled_mcpjson_servers=as_str_tuple( + state_d.get("enabledMcpjsonServers") + ), + disabled_mcpjson_servers=as_str_tuple( + state_d.get("disabledMcpjsonServers") + ), + ) + ) + # OAuth session presence. Different installs use slightly different + # field names; treat any non-empty `oauthAccount` mapping as a signal + # that an authenticated session is configured. We never read the + # value itself. + oauth_value = data_d.get("oauthAccount") + has_account = ( + isinstance(oauth_value, dict) + and len(cast("dict[str, object]", oauth_value)) > 0 ) + oauth_present = has_account or bool(data_d.get("oauthToken")) + return tuple(trust_entries), oauth_present, path # Cap how many session logs we crack open per project. A handful is diff --git a/src/agentpeek/tui/render.py b/src/agentpeek/tui/render.py index b0a0ba1..159c578 100644 --- a/src/agentpeek/tui/render.py +++ b/src/agentpeek/tui/render.py @@ -12,15 +12,18 @@ from textual.widgets import DataTable, Markdown, Static from agentpeek.models import ( + Agent, HookSpec, KeybindingEntry, KeybindingsBundle, MCPServer, MemoryFile, + MemoryImport, Plugin, PluginAgent, PluginManifest, PluginSkill, + Rule, ScanReport, ScanResult, ScanWarning, @@ -34,6 +37,8 @@ ("commands", "Slash commands"), ("plugins", "Plugins"), ("skills", "Skills"), + ("agents", "Agents"), + ("rules", "Rules"), ("memory", "Memory"), ("keybindings", "Keybindings"), ("mcp", "MCP servers"), @@ -304,6 +309,8 @@ def item_body(payload: object) -> str | None: """ if isinstance(payload, MemoryFile | SlashCommand | PluginSkill | PluginAgent): return payload.body + if isinstance(payload, Agent | Rule): + return payload.body if isinstance(payload, HookSpec): return payload.command return None @@ -323,6 +330,8 @@ def item_path(payload: object, result: ScanResult) -> Path | None: # noqa: PLR0 """ if isinstance(payload, MemoryFile | SlashCommand | PluginSkill): return payload.path + if isinstance(payload, Agent | Rule): + return payload.path if isinstance(payload, HookSpec): return payload.referenced_script if isinstance(payload, Plugin): @@ -354,6 +363,8 @@ def category_count(result: ScanResult, key: str) -> int: "commands": len(result.commands) + plugin_commands, "plugins": len(result.plugins), "skills": sum(len(p.skills) for p in result.plugins), + "agents": len(result.agents), + "rules": len(result.rules), "memory": len(result.memory), "keybindings": (len(result.keybindings.entries) if result.keybindings else 0), "mcp": len(result.mcp) + plugin_mcps, @@ -517,7 +528,7 @@ def category_items( # noqa: PLR0911 # would only obscure the dispatch. match key: case "settings": - return _settings_items(result.settings) + return _settings_items(result.settings, result) case "hooks": merged_hooks = result.hooks + tuple( h for p in result.plugins for h in p.hooks @@ -532,6 +543,10 @@ def category_items( # noqa: PLR0911 return _plugins_items(result.plugins) case "skills": return _skills_items(result.plugins) + case "agents": + return _agents_items(result.agents) + case "rules": + return _rules_items(result.rules) case "memory": return _memory_items(result.memory) case "keybindings": @@ -579,6 +594,10 @@ def _render_body_widgets(key: str, payload: object) -> list[Widget]: # noqa: PL return _plugins_detail_widgets(payload) case "skills": return _skills_detail_widgets(payload) + case "agents": + return _agents_detail_widgets(payload) + case "rules": + return _rules_detail_widgets(payload) case "memory": return _memory_detail_widgets(payload) case "keybindings": @@ -594,10 +613,12 @@ def _render_body_widgets(key: str, payload: object) -> list[Widget]: # noqa: PL # --- Settings ----------------------------------------------------------- -def _settings_items(s: SettingsBundle | None) -> list[tuple[Content, object]]: +def _settings_items( + s: SettingsBundle | None, result: ScanResult | None = None +) -> list[tuple[Content, object]]: if s is None: return [] - return [ + items: list[tuple[Content, object]] = [ (_scalar_label("Model", s.model), _SettingsItem("Model", "scalar", s.model)), (_scalar_label("Theme", s.theme), _SettingsItem("Theme", "scalar", s.theme)), ( @@ -612,6 +633,10 @@ 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("Default agent", s.default_agent), + _SettingsItem("Default agent", "scalar", s.default_agent), + ), ( _scalar_label( "Skip auto permission prompt", @@ -675,7 +700,50 @@ def _settings_items(s: SettingsBundle | None) -> list[tuple[Content, object]]: "Hooks dir files", "list", list(s.hooks_dir_files) ), ), + ( + _count_item_label("CLAUDE.md excludes", len(s.claude_md_excludes)), + _SettingsItem( + "CLAUDE.md excludes", "list", list(s.claude_md_excludes) + ), + ), ] + if result is not None: + if result.claude_json_path is not None: + items.append( + ( + _scalar_label( + "OAuth session (~/.claude.json)", + "present" if result.oauth_session_present else "absent", + ), + _SettingsItem( + "OAuth session (~/.claude.json)", + "scalar", + "present" if result.oauth_session_present else "absent", + ), + ) + ) + if result.trust_entries: + items.append( + ( + _count_item_label( + "Per-project trust", len(result.trust_entries) + ), + _SettingsItem( + "Per-project trust", + "dict", + { + t.project_path: ( + f"trust={'yes' if t.trust_accepted else 'no'}, " + f"allow={len(t.allowed_tools)}, " + f"mcp+{len(t.enabled_mcpjson_servers)}/" + f"-{len(t.disabled_mcpjson_servers)}" + ) + for t in result.trust_entries + }, + ), + ) + ) + return items def _scalar_label(name: str, value: object) -> Content: @@ -1102,8 +1170,11 @@ def _skills_detail_widgets(payload: object) -> list[Widget]: _MEMORY_KIND_LABEL = { "claude_md": "CLAUDE", + "claude_local_md": "CLAUDE.local", "memory_index": "index", "memory_entry": "entry", + "agent_memory_index": "agent-index", + "agent_memory_entry": "agent-entry", } @@ -1116,11 +1187,17 @@ def _memory_items(memory: tuple[MemoryFile, ...]) -> list[tuple[Content, object] " ", m.path.name, ] + if m.agent_name: + parts.append((f" [{m.agent_name}]", COLOR_INFO)) if m.project_label: parts.append((f" @ {m.project_label}", COLOR_MUTED)) + if m.kind == "claude_local_md": + parts.append((" (local override)", COLOR_WARNING)) parts.append((f" ({len(m.body)} chars", COLOR_MUTED)) if m.has_frontmatter: parts.append((" +fm", COLOR_INFO)) + if m.imports: + parts.append((f" {len(m.imports)} imports", COLOR_INFO)) parts.append((")", COLOR_MUTED)) items.append((Content.assemble(*parts), m)) return items @@ -1137,15 +1214,145 @@ def _memory_detail_widgets(payload: object) -> list[Widget]: ] if payload.has_frontmatter: parts.extend((" ", _badge("+fm", "info"))) + if payload.kind == "claude_local_md": + parts.extend((" ", _badge("local override", "warning"))) widgets: list[Widget] = [_detail_header(Content.assemble(*parts))] rows: list[tuple[str, RenderableType]] = [ ( "Project", payload.project_label or _muted_cell("(user-level)"), ), + ] + if payload.agent_name: + rows.append(("Agent", payload.agent_name)) + rows.extend( + [ + ("Path", str(payload.path)), + ("Size", f"{len(payload.body)} chars"), + ("Imports", str(len(payload.imports)) if payload.imports else "0"), + ] + ) + widgets.append(_card("Properties", Static(_kv_table(rows)))) + if payload.imports: + widgets.append(_card("Imports", _imports_table(payload.imports))) + widgets.append(_card("Body", _bounded_markdown(payload.body))) + return widgets + + +def _imports_table(imports: tuple[MemoryImport, ...]) -> Widget: + """Render resolved @imports as a depth-indented table. + + Cycles and depth-cap entries are surfaced with their reason in the + status column so users can tell what Claude actually loads vs what + was skipped to avoid infinite recursion. + """ + rows: tuple[tuple[str, ...], ...] = tuple( + ( + " " * (imp.depth - 1) + f"@{imp.raw}", + str(imp.resolved_path) if imp.resolved_path else "—", + imp.reason or "loaded", + ) + for imp in imports + ) + return _PendingDataTable(columns=("Reference", "Resolved", "Status"), rows=rows) + + +# --- Agents ------------------------------------------------------------- + + +def _agents_items(agents: tuple[Agent, ...]) -> list[tuple[Content, object]]: + items: list[tuple[Content, object]] = [] + for a in agents: + parts: list[Content | str | tuple[str, str]] = [ + (a.name, "bold"), + ] + if a.description: + preview = _truncate_preview(a.description, 60) + parts.append((f" — {preview}", COLOR_MUTED)) + if a.model: + parts.append((f" [{a.model}]", COLOR_INFO)) + items.append((Content.assemble(*parts), a)) + return items + + +def _agents_detail_widgets(payload: object) -> list[Widget]: # noqa: PLR0912 + if not isinstance(payload, Agent): + return _empty_state("agent") + widgets: list[Widget] = [_detail_header(payload.name)] + rows: list[tuple[str, RenderableType]] = [ + ("Description", payload.description or _muted_cell("—")), ("Path", str(payload.path)), - ("Size", f"{len(payload.body)} chars"), ] + if payload.model: + rows.append(("Model", payload.model)) + if payload.permission_mode: + rows.append(("Permission mode", payload.permission_mode)) + if payload.memory: + rows.append(("Memory scope", payload.memory)) + if payload.background is not None: + rows.append(("Background", "yes" if payload.background else "no")) + if payload.effort: + rows.append(("Effort", payload.effort)) + if payload.isolation: + rows.append(("Isolation", payload.isolation)) + if payload.color: + rows.append(("Color", payload.color)) + if payload.max_turns is not None: + rows.append(("Max turns", str(payload.max_turns))) + if payload.tools: + rows.append(("Tools", ", ".join(payload.tools))) + if payload.disallowed_tools: + rows.append(("Disallowed tools", ", ".join(payload.disallowed_tools))) + if payload.skills: + rows.append(("Preloaded skills", ", ".join(payload.skills))) + if payload.initial_prompt: + rows.append(("Initial prompt", payload.initial_prompt)) + if payload.has_mcp_servers: + rows.append(("MCP servers", "configured")) + if payload.has_hooks: + rows.append(("Hooks", "configured")) + widgets.append(_card("Properties", Static(_kv_table(rows)))) + widgets.append(_card("System prompt", _bounded_markdown(payload.body))) + return widgets + + +# --- Rules -------------------------------------------------------------- + + +def _rules_items(rules: tuple[Rule, ...]) -> list[tuple[Content, object]]: + items: list[tuple[Content, object]] = [] + for r in rules: + parts: list[Content | str | tuple[str, str]] = [ + (r.name, "bold"), + ] + if r.always_loaded: + parts.append((" (always loaded)", COLOR_INFO)) + elif r.paths_globs: + parts.append( + (f" paths: {', '.join(r.paths_globs)}", COLOR_MUTED) + ) + items.append((Content.assemble(*parts), r)) + return items + + +def _rules_detail_widgets(payload: object) -> list[Widget]: + if not isinstance(payload, Rule): + return _empty_state("rule") + widgets: list[Widget] = [_detail_header(payload.name)] + rows: list[tuple[str, RenderableType]] = [ + ("Path", str(payload.path)), + ("Description", payload.description or _muted_cell("—")), + ( + "Trigger", + ( + Text("always (loaded at session start)") + if payload.always_loaded + else Text("on-demand (path-scoped)") + ), + ), + ] + if payload.paths_globs: + rows.append(("Path globs", ", ".join(payload.paths_globs))) widgets.append(_card("Properties", Static(_kv_table(rows)))) widgets.append(_card("Body", _bounded_markdown(payload.body))) return widgets diff --git a/tests/test_actions.py b/tests/test_actions.py new file mode 100644 index 0000000..4f7bb25 --- /dev/null +++ b/tests/test_actions.py @@ -0,0 +1,127 @@ +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from agentpeek.actions.runner import ( + ActionResult, + run_marketplace_update, + run_plugin, +) + + +def _fake_completed( + returncode: int = 0, stdout: str = "", stderr: str = "" +) -> MagicMock: + proc = MagicMock(spec=subprocess.CompletedProcess) + proc.returncode = returncode + proc.stdout = stdout + proc.stderr = stderr + return proc + + +@pytest.mark.parametrize( + "verb", + ["enable", "disable", "update", "uninstall", "install"], +) +def test_run_plugin_passes_correct_argv(verb: str) -> None: + with patch("agentpeek.actions.runner.subprocess.run") as mock_run: + mock_run.return_value = _fake_completed(0, "done", "") + result = run_plugin(verb, "alpha@market", scope="user") # type: ignore[arg-type] + assert mock_run.called + args = mock_run.call_args.args[0] + assert args == ["claude", "plugin", verb, "alpha@market", "--scope", "user"] + assert result.ok is True + assert result.verb == verb + assert result.target == "alpha@market" + assert result.scope == "user" + + +def test_run_plugin_project_scope() -> None: + with patch("agentpeek.actions.runner.subprocess.run") as mock_run: + mock_run.return_value = _fake_completed(0) + run_plugin("update", "beta@market", scope="project") + args = mock_run.call_args.args[0] + assert "--scope" in args + assert args[args.index("--scope") + 1] == "project" + + +def test_run_plugin_failure_returncode_propagates() -> None: + with patch("agentpeek.actions.runner.subprocess.run") as mock_run: + mock_run.return_value = _fake_completed( + returncode=2, stdout="", stderr="missing dependency" + ) + result = run_plugin("update", "alpha@m", scope="user") + assert result.ok is False + assert result.returncode == 2 + assert "missing dependency" in result.stderr + + +def test_run_marketplace_update_single_target() -> None: + with patch("agentpeek.actions.runner.subprocess.run") as mock_run: + mock_run.return_value = _fake_completed(0) + result = run_marketplace_update("ds-ai-setu") + args = mock_run.call_args.args[0] + assert args == ["claude", "plugin", "marketplace", "update", "ds-ai-setu"] + assert result.verb == "marketplace update" + assert result.target == "ds-ai-setu" + assert result.scope is None + + +def test_run_marketplace_update_all() -> None: + with patch("agentpeek.actions.runner.subprocess.run") as mock_run: + mock_run.return_value = _fake_completed(0) + result = run_marketplace_update(None) + args = mock_run.call_args.args[0] + # No trailing marketplace name → refresh-all form. + assert args == ["claude", "plugin", "marketplace", "update"] + assert result.target == "(all)" + + +def test_run_plugin_handles_missing_cli() -> None: + with patch("agentpeek.actions.runner.subprocess.run") as mock_run: + mock_run.side_effect = FileNotFoundError + result = run_plugin("enable", "alpha@m", scope="user") + assert result.ok is False + assert result.returncode == -1 + assert "claude" in result.stderr.lower() + + +def test_run_plugin_handles_timeout() -> None: + with patch("agentpeek.actions.runner.subprocess.run") as mock_run: + mock_run.side_effect = subprocess.TimeoutExpired( + cmd=["claude"], timeout=60, output=b"partial output" + ) + result = run_plugin("update", "alpha@m", scope="user") + assert result.ok is False + assert result.returncode == -1 + assert "timed out" in result.stderr + assert "partial output" in result.stdout + + +def test_action_result_message_uses_last_line() -> None: + result = ActionResult( + ok=True, + verb="update", + target="alpha@m", + scope="user", + returncode=0, + stdout="line1\nfinal line", + stderr="", + elapsed_ms=100, + ) + assert result.message == "final line" + + +def test_action_result_message_falls_back() -> None: + result = ActionResult( + ok=True, + verb="update", + target="alpha@m", + scope="user", + returncode=0, + stdout="", + stderr="", + elapsed_ms=10, + ) + assert result.message == "ok" diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 322a22f..eed7415 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -1,10 +1,12 @@ import dataclasses +import json from pathlib import Path import pytest from agentpeek.health import run_cross_scope_checks from agentpeek.models import ( + MCPServer, MemoryFile, Plugin, PluginInstallation, @@ -13,7 +15,11 @@ SlashCommand, ) from agentpeek.scanner import find_project_root, redistribute_plugins, scan -from agentpeek.sources.local import _flatten_marketplace +from agentpeek.sources.local import ( + _flatten_marketplace, + _resolve_memory_imports, + _scan_claude_json, +) def test_scan_local_source(sample_claude_root: Path) -> None: @@ -430,3 +436,359 @@ def test_flatten_marketplace_omits_auto_update_when_absent() -> None: } ) assert "autoUpdate" not in flat + + +# --- v0.13: agents loader ---------------------------------------------- + + +def _make_claude_root(tmp_path: Path) -> Path: + """Build a minimal .claude/ root that detect() accepts.""" + root = tmp_path / ".claude" + root.mkdir(parents=True) + # detect() returns False on an empty dir; a settings.local.json + # alone is the cheapest indicator. + (root / "settings.local.json").write_text("{}") + return root + + +def test_scan_agents_user_scope_minimal(tmp_path: Path) -> None: + root = _make_claude_root(tmp_path) + agents_dir = root / "agents" + agents_dir.mkdir() + (agents_dir / "minimal.md").write_text( + "---\nname: minimal\ndescription: a tiny agent\n---\n\nDo a thing." + ) + report = scan(root=root) + result = report.project or report.user + assert result is not None + assert len(result.agents) == 1 + agent = result.agents[0] + assert agent.name == "minimal" + assert agent.description == "a tiny agent" + assert "Do a thing." in agent.body + assert agent.tools == () + assert agent.model is None + + +def test_scan_agents_full_frontmatter(tmp_path: Path) -> None: + root = _make_claude_root(tmp_path) + agents_dir = root / "agents" + agents_dir.mkdir() + (agents_dir / "code-reviewer.md").write_text( + "---\n" + "name: code-reviewer\n" + "description: Reviews code\n" + "tools: Read, Grep, Glob\n" + "disallowedTools: Write, Edit\n" + "model: sonnet\n" + "permissionMode: plan\n" + "memory: project\n" + "background: true\n" + "color: purple\n" + "skills:\n" + " - api-conventions\n" + " - error-handling\n" + "mcpServers:\n" + " - github\n" + "hooks:\n" + " PreToolUse: []\n" + "---\n\nYou are a code reviewer." + ) + report = scan(root=root) + result = report.project or report.user + assert result is not None + agent = result.agents[0] + assert agent.tools == ("Read", "Grep", "Glob") + assert agent.disallowed_tools == ("Write", "Edit") + assert agent.model == "sonnet" + assert agent.permission_mode == "plan" + assert agent.memory == "project" + assert agent.background is True + assert agent.color == "purple" + assert agent.skills == ("api-conventions", "error-handling") + assert agent.has_mcp_servers is True + assert agent.has_hooks is True + + +def test_scan_agents_recursive_subdirs(tmp_path: Path) -> None: + root = _make_claude_root(tmp_path) + nested = root / "agents" / "review" / "deep" + nested.mkdir(parents=True) + (nested / "security.md").write_text( + "---\nname: review-security\ndescription: deep-nested agent\n---\nbody" + ) + report = scan(root=root) + result = report.project or report.user + assert result is not None + assert len(result.agents) == 1 + assert result.agents[0].name == "review-security" + + +def test_scan_agents_falls_back_when_frontmatter_missing(tmp_path: Path) -> None: + root = _make_claude_root(tmp_path) + agents_dir = root / "agents" + agents_dir.mkdir() + # No frontmatter at all — name should fall back to the filename stem. + (agents_dir / "bare.md").write_text("just a body, no fm") + report = scan(root=root) + result = report.project or report.user + assert result is not None + assert len(result.agents) == 1 + assert result.agents[0].name == "bare" + + +# --- v0.13: rules loader ---------------------------------------------- + + +def test_scan_rules_path_scoped(tmp_path: Path) -> None: + root = _make_claude_root(tmp_path) + rules_dir = root / "rules" + rules_dir.mkdir() + (rules_dir / "api.md").write_text( + "---\n" + "paths:\n" + " - 'src/api/**/*.ts'\n" + " - 'lib/**/*.ts'\n" + "---\n" + "API validation rules" + ) + report = scan(root=root) + result = report.project or report.user + assert result is not None + assert len(result.rules) == 1 + rule = result.rules[0] + assert rule.paths_globs == ("src/api/**/*.ts", "lib/**/*.ts") + assert rule.always_loaded is False + assert "API validation rules" in rule.body + + +def test_scan_rules_always_loaded(tmp_path: Path) -> None: + root = _make_claude_root(tmp_path) + rules_dir = root / "rules" + rules_dir.mkdir() + (rules_dir / "global.md").write_text("no frontmatter at all") + report = scan(root=root) + result = report.project or report.user + assert result is not None + rule = result.rules[0] + assert rule.paths_globs == () + assert rule.always_loaded is True + + +def test_scan_rules_recursive(tmp_path: Path) -> None: + root = _make_claude_root(tmp_path) + nested = root / "rules" / "frontend" + nested.mkdir(parents=True) + (nested / "react.md").write_text("---\npaths: ['**/*.tsx']\n---\nuse hooks") + report = scan(root=root) + result = report.project or report.user + assert result is not None + assert len(result.rules) == 1 + assert result.rules[0].name == "frontend/react" + + +# --- v0.13: CLAUDE.local.md ------------------------------------------- + + +def test_scan_memory_picks_up_claude_local_md(tmp_path: Path) -> None: + # Per docs, CLAUDE.local.md sits at project root (one above .claude/). + project = tmp_path / "proj" + root = project / ".claude" + root.mkdir(parents=True) + (root / "settings.local.json").write_text("{}") + (project / "CLAUDE.local.md").write_text("personal override") + report = scan(root=root) + result = report.project or report.user + assert result is not None + local = [m for m in result.memory if m.kind == "claude_local_md"] + assert len(local) == 1 + assert "personal override" in local[0].body + + +# --- v0.13: @path imports ---------------------------------------------- + + +def test_resolve_memory_imports_single(tmp_path: Path) -> None: + target = tmp_path / "imported.md" + target.write_text("imported body") + parent = tmp_path / "CLAUDE.md" + parent.write_text("see @imported.md") + imports = _resolve_memory_imports(parent, parent.read_text()) + assert len(imports) == 1 + assert imports[0].raw == "imported.md" + assert imports[0].resolved_path == target.resolve() + assert imports[0].reason is None + + +def test_resolve_memory_imports_recursive(tmp_path: Path) -> None: + leaf = tmp_path / "leaf.md" + leaf.write_text("leaf body") + mid = tmp_path / "mid.md" + mid.write_text("see @leaf.md") + root = tmp_path / "root.md" + root.write_text("see @mid.md") + imports = _resolve_memory_imports(root, root.read_text()) + assert len(imports) == 2 + assert imports[0].depth == 1 + assert imports[1].depth == 2 + + +def test_resolve_memory_imports_cycle(tmp_path: Path) -> None: + a = tmp_path / "a.md" + b = tmp_path / "b.md" + a.write_text("see @b.md") + b.write_text("see @a.md") + imports = _resolve_memory_imports(a, a.read_text()) + # b should resolve cleanly; b's import back to a should be a cycle. + cycles = [i for i in imports if i.reason == "cycle"] + assert len(cycles) == 1 + + +def test_resolve_memory_imports_missing(tmp_path: Path) -> None: + parent = tmp_path / "CLAUDE.md" + parent.write_text("see @nonexistent.md") + imports = _resolve_memory_imports(parent, parent.read_text()) + assert len(imports) == 1 + assert imports[0].reason == "missing" + + +def test_resolve_memory_imports_home_prefix( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / "global.md").write_text("home body") + parent = tmp_path / "CLAUDE.md" + parent.write_text("see @~/global.md") + imports = _resolve_memory_imports(parent, parent.read_text()) + assert len(imports) == 1 + assert imports[0].reason is None + + +# --- v0.13: ~/.claude.json -------------------------------------------- + + +def test_scan_claude_json_surfaces_user_mcp_servers(tmp_path: Path) -> None: + path = tmp_path / ".claude.json" + path.write_text( + json.dumps( + { + "mcpServers": { + "github": {"command": "gh", "args": ["mcp"]}, + }, + "projects": {}, + } + ) + ) + mcp_results: list[MCPServer] = [] + seen_names: set[str] = set() + trust, oauth, recorded = _scan_claude_json(path, mcp_results, seen_names, []) + assert recorded == path + assert any(m.name == "github" for m in mcp_results) + assert trust == () + assert oauth is False + + +def test_scan_claude_json_trust_entries(tmp_path: Path) -> None: + path = tmp_path / ".claude.json" + path.write_text( + json.dumps( + { + "projects": { + "/Users/me/repo": { + "hasTrustDialogAccepted": True, + "allowedTools": ["Read", "Grep"], + "enabledMcpjsonServers": ["github"], + "disabledMcpjsonServers": [], + } + } + } + ) + ) + trust, _, _ = _scan_claude_json(path, [], set(), []) + assert len(trust) == 1 + entry = trust[0] + assert entry.project_path == "/Users/me/repo" + assert entry.trust_accepted is True + assert entry.allowed_tools == ("Read", "Grep") + assert entry.enabled_mcpjson_servers == ("github",) + assert entry.disabled_mcpjson_servers == () + + +def test_scan_claude_json_oauth_presence_only(tmp_path: Path) -> None: + path = tmp_path / ".claude.json" + # Non-empty oauthAccount should flip presence to True without + # the test reading the value. + path.write_text( + json.dumps({"oauthAccount": {"emailAddress": "redacted"}}) + ) + _, oauth, _ = _scan_claude_json(path, [], set(), []) + assert oauth is True + + +def test_scan_claude_json_oauth_absent(tmp_path: Path) -> None: + path = tmp_path / ".claude.json" + path.write_text(json.dumps({"oauthAccount": {}})) + _, oauth, _ = _scan_claude_json(path, [], set(), []) + assert oauth is False + + +# --- v0.13: settings extensions --------------------------------------- + + +def test_scan_settings_surfaces_default_agent(tmp_path: Path) -> None: + root = _make_claude_root(tmp_path) + (root / "settings.json").write_text( + json.dumps({"agent": "code-reviewer"}) + ) + report = scan(root=root) + result = report.project or report.user + assert result is not None + assert result.settings is not None + assert result.settings.default_agent == "code-reviewer" + + +def test_scan_settings_surfaces_claude_md_excludes(tmp_path: Path) -> None: + root = _make_claude_root(tmp_path) + (root / "settings.json").write_text( + json.dumps({"claudeMdExcludes": ["**/other-team/CLAUDE.md"]}) + ) + report = scan(root=root) + result = report.project or report.user + assert result is not None + assert result.settings is not None + assert result.settings.claude_md_excludes == ("**/other-team/CLAUDE.md",) + + +# --- v0.13: agent-memory directories ---------------------------------- + + +def test_scan_agent_memory_user_scope(tmp_path: Path) -> None: + root = _make_claude_root(tmp_path) + mem = root / "agent-memory" / "code-reviewer" + mem.mkdir(parents=True) + (mem / "MEMORY.md").write_text("# Code reviewer memory") + (mem / "topic.md").write_text("# topic body") + report = scan(root=root) + result = report.project or report.user + assert result is not None + agent_memories = [ + m for m in result.memory + if m.kind in ("agent_memory_index", "agent_memory_entry") + ] + assert len(agent_memories) == 2 + assert all(m.agent_name == "code-reviewer" for m in agent_memories) + index = next(m for m in agent_memories if m.kind == "agent_memory_index") + assert index.project_label == "agent-memory" + + +def test_scan_agent_memory_local_scope(tmp_path: Path) -> None: + root = _make_claude_root(tmp_path) + mem = root / "agent-memory-local" / "debugger" + mem.mkdir(parents=True) + (mem / "MEMORY.md").write_text("# local agent memory") + report = scan(root=root) + result = report.project or report.user + assert result is not None + locals_ = [m for m in result.memory if m.project_label == "agent-memory-local"] + assert len(locals_) == 1 + assert locals_[0].agent_name == "debugger" From 99b431c19e0965ad62c804557fda802b5dbc85b0 Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Mon, 25 May 2026 14:08:01 +0530 Subject: [PATCH 2/4] chore: bump to 0.13.0, refresh readme, link license --- README.md | 18 +++++++++++------- pyproject.toml | 2 +- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 385828b..ca1978a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A TUI for inspecting agent CLI configurations: what's installed, what's enabled, what's broken. -agentpeek scans your Claude Code config (`~/.claude/` and any project-level `.claude/` directory it finds by walking up from `cwd`) and surfaces every loose file the agent depends on: settings, hooks, slash commands, memory files, MCP servers, installed plugins (with their full contents: manifest, skills, agents, commands, hooks, MCPs), and any health warnings detected during the scan. +agentpeek scans your Claude Code config (`~/.claude/` and any project-level `.claude/` directory it finds by walking up from `cwd`) and surfaces every loose file the agent depends on: settings, hooks, slash commands, memory files, MCP servers, user/project subagents, path-scoped rules, installed plugins (with their full contents: manifest, skills, agents, commands, hooks, MCPs), and any health warnings detected during the scan. ## Install @@ -22,12 +22,16 @@ Requires Python ≥ 3.11. Linux and macOS only. ## What you get -- **Three-zone TUI**: sidebar of 9 categories on the left, items list in the middle, detail pane on the right. Drill-down on every category. +- **Three-zone TUI**: sidebar of 11 categories on the left, items list in the middle, detail pane on the right. Drill-down on every category. - **Multi-scope**: user-scoped (`~/.claude/`) and project-scoped (`.claude/` found by walking up from cwd) configurations shown side by side with `[U]` / `[P]` badges. Health checks compare the two and flag conflicts. -- **Plugin contents**: every installed plugin is opened up - manifest, skills, agents, slash commands, hooks, MCP servers, and the marketplace's auto-update state. Plugin-contributed commands / hooks / MCPs also merge into the top-level categories with a `[plug:]` provenance tag. -- **Auto-memory**: Claude Code project memories (under `~/.claude/projects//memory/`) are scanned and labelled with their actual project path (resolved from session logs, not the lossy directory encoding). -- **Health checks**: orphan hook scripts, missing plugin install paths, conflicting key bindings, scope override conflicts, cross-scope layered memory, hooks referencing dynamic env vars (`${CLAUDE_PROJECT_DIR}`, `$HOME`, …) flagged so you know which won't resolve at scan time, and more. Severity-coloured warning cards in the detail pane. -- **Write actions**: update, enable/disable, uninstall a plugin or refresh a marketplace without leaving the TUI. Calls shell out to `claude plugin …`; agentpeek never edits Claude state files directly. Re-scan is automatic after every action. Pass `--read-only` to disable. +- **Subagents**: user and project agents under `agents/*.md` are scanned recursively, with full frontmatter (tools, model, permission mode, memory scope, color, and the rest) shown in the detail card. +- **Path-scoped rules**: `.claude/rules/*.md` files at user and project scope are listed with their `paths:` globs (or marked "always loaded" when the field is absent), so you can answer "why is Claude doing X here?" by category instead of by guessing. +- **Plugin contents**: every installed plugin is opened up: manifest, skills, agents, slash commands, hooks, MCP servers, and the marketplace's auto-update state. Plugin-contributed commands / hooks / MCPs also merge into the top-level categories with a `[plug:]` provenance tag. +- **Memory layer**: `CLAUDE.md`, `CLAUDE.local.md` (project-root personal overrides), and subagent persistent memory directories (`agent-memory/` and `agent-memory-local/`) are all surfaced. CLAUDE.md `@path/to/file` imports are resolved recursively (up to 5 hops, cycles detected) so you see what Claude actually loads. +- **Auto-memory**: Claude Code project memories under `~/.claude/projects//memory/` are scanned and labelled with their actual project path (resolved from session logs, not the lossy directory encoding). +- **`~/.claude.json`**: user-scope MCP servers, per-project trust state (allowed tools, enabled / disabled `.mcp.json` servers), and OAuth session presence (display-only - the token value is never read or shown). Folded into the MCP and Settings cards. +- **Health checks**: orphan hook scripts, missing plugin install paths, conflicting key bindings, scope override conflicts, cross-scope layered memory, hooks referencing dynamic env vars (`${CLAUDE_PROJECT_DIR}`, `$HOME`, ...) flagged so you know which won't resolve at scan time, and more. Severity-coloured warning cards in the detail pane. +- **Write actions**: update, enable/disable, uninstall a plugin or refresh a marketplace without leaving the TUI. Calls shell out to `claude plugin ...`; agentpeek never edits Claude state files directly. Re-scan is automatic after every action. Pass `--read-only` to disable. ## Keybindings @@ -63,4 +67,4 @@ Built on Textual: the theme follows `Ctrl+P` (palette) and reflows colours acros ## License -GPL-3.0-only. See `LICENSE`. +GPL-3.0-only. See [LICENSE](LICENSE). diff --git a/pyproject.toml b/pyproject.toml index fc86580..491d362 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentpeek" -version = "0.12.1" +version = "0.13.0" description = "TUI inspector for agent CLI configuration directories like ~/.claude/." readme = "README.md" requires-python = ">=3.11" From 176336d7d4f78cdca5e6b4e7da4d1552184937cc Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Sun, 24 May 2026 00:24:19 +0530 Subject: [PATCH 3/4] fix: settings.enabled_plugins missing remote-settings entries - _merge_enabled_plugins now unions across user + local + remote - mirrors _collect_enabled_plugins (used by Plugin.enabled) - regression test added --- src/agentpeek/sources/local.py | 24 ++++++++++++++++-------- tests/test_scanner.py | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/agentpeek/sources/local.py b/src/agentpeek/sources/local.py index 4421c92..e1caad7 100644 --- a/src/agentpeek/sources/local.py +++ b/src/agentpeek/sources/local.py @@ -137,7 +137,7 @@ def _scan_settings( warnings=warnings, ) - enabled_plugins = _merge_enabled_plugins(user_data, local_data) + enabled_plugins = _merge_enabled_plugins(user_data, local_data, remote_data) hooks_raw = _merge_hooks_raw(user_data, local_data) hooks_dir_files = _list_hooks_dir_files(root) @@ -849,15 +849,23 @@ def _safe_read_text(path: Path) -> str: def _merge_enabled_plugins( - user_data: dict[str, object], local_data: dict[str, object] + user_data: dict[str, object], + local_data: dict[str, object], + remote_data: dict[str, object], ) -> tuple[str, ...]: - """Union enabledPlugins across user + local settings, keeping truthy keys.""" - user_plugins = as_dict(user_data.get("enabledPlugins")) or {} - local_plugins = as_dict(local_data.get("enabledPlugins")) or {} + """Union enabledPlugins across user + local + remote settings, keeping truthy keys. + + Mirrors `_collect_enabled_plugins`, which feeds `Plugin.enabled` on + each plugin row. Excluding `remote-settings.json` (enterprise / org-pushed) + here meant the Settings card understated the enabled set, even though + the Plugins category correctly showed those entries as enabled. + """ enabled: set[str] = set() - for k, v in {**user_plugins, **local_plugins}.items(): - if bool(v): - enabled.add(str(k)) + for source in (user_data, local_data, remote_data): + plugins = as_dict(source.get("enabledPlugins")) or {} + for k, v in plugins.items(): + if bool(v): + enabled.add(str(k)) return tuple(sorted(enabled)) diff --git a/tests/test_scanner.py b/tests/test_scanner.py index eed7415..b4be096 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -735,6 +735,27 @@ def test_scan_claude_json_oauth_absent(tmp_path: Path) -> None: # --- v0.13: settings extensions --------------------------------------- +def test_scan_settings_enabled_plugins_unions_remote(tmp_path: Path) -> None: + # Regression: SettingsBundle.enabled_plugins must include + # remote-settings.json so the Settings card matches the Plugins + # category (which already unioned all three settings files). + root = _make_claude_root(tmp_path) + (root / "settings.json").write_text( + json.dumps({"enabledPlugins": {"user-plugin@m": True}}) + ) + (root / "remote-settings.json").write_text( + json.dumps({"enabledPlugins": {"org-plugin@m": True}}) + ) + report = scan(root=root) + result = report.project or report.user + assert result is not None + assert result.settings is not None + assert set(result.settings.enabled_plugins) == { + "user-plugin@m", + "org-plugin@m", + } + + def test_scan_settings_surfaces_default_agent(tmp_path: Path) -> None: root = _make_claude_root(tmp_path) (root / "settings.json").write_text( From 386643b1e3b765e56e4d4bf1c97e9c629195b4f7 Mon Sep 17 00:00:00 2001 From: rattle99 <24495512+rattle99@users.noreply.github.com> Date: Sun, 24 May 2026 00:28:29 +0530 Subject: [PATCH 4/4] fix: strip trailing punctuation from @path imports - @path. (sentence end) and @path`, (backtick + punct) now resolve - avoids spurious "missing" markers from prose around the reference --- src/agentpeek/sources/local.py | 12 ++++++++++-- tests/test_scanner.py | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/agentpeek/sources/local.py b/src/agentpeek/sources/local.py index e1caad7..9fde13a 100644 --- a/src/agentpeek/sources/local.py +++ b/src/agentpeek/sources/local.py @@ -1170,8 +1170,12 @@ def _read_memory_file( # `@` matches when `@` is at start-of-line or preceded by whitespace. -# The path token stops at the first whitespace or end of line. +# The path token stops at the first whitespace or end of line. Common +# sentence-trailing punctuation is stripped before resolution so prose +# like "see @path." or "@path`." doesn't end up looking for a file +# named "path." or "path`.". _IMPORT_RE = re.compile(r"(?:^|(?<=\s))@(\S+)") +_IMPORT_TRAILING_PUNCT = ".,;:?!)\"']>`" _IMPORT_MAX_DEPTH = 5 @@ -1201,7 +1205,11 @@ def _walk_imports( out: list[MemoryImport], ) -> None: for match in _IMPORT_RE.finditer(body): - raw = match.group(1) + raw = match.group(1).rstrip(_IMPORT_TRAILING_PUNCT) + if not raw: + # Bare `@` followed only by punctuation. Skip silently — it's + # not a real import reference. + continue target = _resolve_import_target(parent_path, raw) if target is None: out.append( diff --git a/tests/test_scanner.py b/tests/test_scanner.py index b4be096..e655e7e 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -644,6 +644,25 @@ def test_resolve_memory_imports_cycle(tmp_path: Path) -> None: assert len(cycles) == 1 +def test_resolve_memory_imports_strips_trailing_punctuation(tmp_path: Path) -> None: + # Trailing sentence-punctuation (`.`, `,`, `` ` ``) on a `@path` + # token should be stripped before resolution. Otherwise prose like + # "see @path." or "@path`," would look for a file named "path." + # or "path`,". + target_a = tmp_path / "a.md" + target_b = tmp_path / "b.md" + target_c = tmp_path / "c.md" + for t in (target_a, target_b, target_c): + t.write_text("body") + parent = tmp_path / "CLAUDE.md" + parent.write_text("See @a.md.\nAlso @b.md`,\nFinally @c.md`.") + imports = _resolve_memory_imports(parent, parent.read_text()) + resolved = [i for i in imports if i.reason is None] + assert len(resolved) == 3 + by_path = {i.resolved_path for i in resolved} + assert by_path == {target_a.resolve(), target_b.resolve(), target_c.resolve()} + + def test_resolve_memory_imports_missing(tmp_path: Path) -> None: parent = tmp_path / "CLAUDE.md" parent.write_text("see @nonexistent.md")